diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml
index a5d1e7d..46b00f2 100644
--- a/.forgejo/workflows/deploy.yaml
+++ b/.forgejo/workflows/deploy.yaml
@@ -10,11 +10,11 @@ on:
- 'frontend/**'
env:
- REGISTRY: rg.fr-par.scw.cloud/funcscwinfrastructureascodehdz4uzhb
- NAMESPACE: a5034510-9763-40e8-ac7e-1836e7a61460
+ REGISTRY: in.gohorsejobs.com
+ NAMESPACE: gohorsejobsdev
jobs:
- # Job: Deploy no Servidor (Pull das imagens do Scaleway)
+ # Job: Deploy no Servidor (Pull das imagens do Forgejo)
deploy-dev:
runs-on: docker
steps:
@@ -41,6 +41,11 @@ jobs:
else
echo "backoffice=false" >> $GITHUB_OUTPUT
fi
+ if git diff --name-only HEAD~1 HEAD | grep -q "^seeder-api/"; then
+ echo "seeder=true" >> $GITHUB_OUTPUT
+ else
+ echo "seeder=false" >> $GITHUB_OUTPUT
+ fi
- name: Deploy via SSH
uses: https://github.com/appleboy/ssh-action@v1.0.3
@@ -50,33 +55,45 @@ jobs:
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.PORT || 22 }}
script: |
- # Login no Scaleway Registry
- echo "${{ secrets.SCW_SECRET_KEY }}" | podman login ${{ env.REGISTRY }} -u nologin --password-stdin
+ # Login no Forgejo Registry (usando segredos do Drone/Forgejo)
+ echo "${{ secrets.HARBOR_PASSWORD }}" | podman login ${{ env.REGISTRY }} -u ${{ secrets.HARBOR_USERNAME }} --password-stdin
# --- DEPLOY DO BACKEND ---
if [ "${{ steps.check.outputs.backend }}" == "true" ]; then
echo "Pulling e reiniciando Backend..."
- podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backend:dev-latest
- podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backend:dev-latest localhost/gohorsejobs-backend-dev:latest
+ # Nome da imagem no Drone: gohorsejobs-backend
+ podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-backend:latest
+ podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-backend:latest localhost/gohorsejobs-backend-dev:latest
sudo systemctl restart gohorsejobs-backend-dev
fi
# --- DEPLOY DO FRONTEND ---
if [ "${{ steps.check.outputs.frontend }}" == "true" ]; then
echo "Pulling e reiniciando Frontend..."
- podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/frontend:dev-latest
- podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/frontend:dev-latest localhost/gohorsejobs-frontend-dev:latest
+ # Assumindo gohorsejobs-frontend no mesmo namespace
+ podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-frontend:latest
+ podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-frontend:latest localhost/gohorsejobs-frontend-dev:latest
sudo systemctl restart gohorsejobs-frontend-dev
fi
# --- DEPLOY DO BACKOFFICE ---
if [ "${{ steps.check.outputs.backoffice }}" == "true" ]; then
echo "Pulling e reiniciando Backoffice..."
- podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:dev-latest
- podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:dev-latest localhost/gohorsejobs-backoffice-dev:latest
+ # Nome no Drone: backoffice
+ podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:latest
+ podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:latest localhost/gohorsejobs-backoffice-dev:latest
sudo systemctl restart gohorsejobs-backoffice-dev
fi
+ # --- DEPLOY DO SEEDER ---
+ if [ "${{ steps.check.outputs.seeder }}" == "true" ]; then
+ echo "Pulling e reiniciando Seeder..."
+ # Assumindo gohorsejobs-seeder
+ podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-seeder:latest
+ podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-seeder:latest localhost/gohorsejobs-seeder-dev:latest
+ sudo systemctl restart gohorsejobs-seeder-dev
+ fi
+
# --- LIMPEZA ---
echo "Limpando imagens antigas..."
podman image prune -f || true
\ No newline at end of file
diff --git a/backend/internal/api/controllers/storage_controller.go b/backend/internal/api/controllers/storage_controller.go
new file mode 100644
index 0000000..955b223
--- /dev/null
+++ b/backend/internal/api/controllers/storage_controller.go
@@ -0,0 +1,48 @@
+package controllers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/rede5/gohorsejobs/backend/internal/services"
+)
+
+type StorageController struct {
+ storageService *services.StorageService
+}
+
+func NewStorageController(storageService *services.StorageService) *StorageController {
+ return &StorageController{storageService: storageService}
+}
+
+// TestConnection verifies if the configured Object Storage is accessible
+// @Summary Test Object Storage Connection
+// @Description Checks if the current configuration (DB or Env) allows access to the S3 bucket.
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Success 200 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Router /admin/storage/test-connection [post]
+func (c *StorageController) TestConnection(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ err := c.storageService.TestConnection(ctx)
+ if err != nil {
+ sendError(w, http.StatusInternalServerError, "Connection failed: "+err.Error())
+ return
+ }
+
+ sendSuccess(w, map[string]string{"message": "Connection successful"})
+}
+
+func sendError(w http.ResponseWriter, code int, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ json.NewEncoder(w).Encode(map[string]string{"error": message})
+}
+
+func sendSuccess(w http.ResponseWriter, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(data)
+}
diff --git a/backend/internal/core/domain/entity/user.go b/backend/internal/core/domain/entity/user.go
index 580c52c..d60320b 100644
--- a/backend/internal/core/domain/entity/user.go
+++ b/backend/internal/core/domain/entity/user.go
@@ -16,8 +16,9 @@ const (
RoleCandidate = "candidate"
// User Status
- UserStatusActive = "active"
- UserStatusInactive = "inactive"
+ UserStatusActive = "active"
+ UserStatusInactive = "inactive"
+ UserStatusForceChangePassword = "force_change_password"
)
// User represents a user within a specific Tenant (Company).
diff --git a/backend/internal/core/dto/user_auth.go b/backend/internal/core/dto/user_auth.go
index d43b9a0..12a00e5 100644
--- a/backend/internal/core/dto/user_auth.go
+++ b/backend/internal/core/dto/user_auth.go
@@ -8,8 +8,9 @@ type LoginRequest struct {
}
type AuthResponse struct {
- Token string `json:"token"`
- User UserResponse `json:"user"`
+ Token string `json:"token"`
+ User UserResponse `json:"user"`
+ MustChangePassword bool `json:"mustChangePassword"`
}
type CreateUserRequest struct {
diff --git a/backend/internal/core/usecases/auth/login.go b/backend/internal/core/usecases/auth/login.go
index b62b60c..3e20e65 100644
--- a/backend/internal/core/usecases/auth/login.go
+++ b/backend/internal/core/usecases/auth/login.go
@@ -48,11 +48,15 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*d
fmt.Printf("[LOGIN DEBUG] Password verification PASSED\n")
// 3. Check Status
- if !strings.EqualFold(user.Status, entity.UserStatusActive) {
- fmt.Printf("[LOGIN DEBUG] Status check FAILED: Expected %s, got '%s'\n", entity.UserStatusActive, user.Status)
+ isActive := strings.EqualFold(user.Status, entity.UserStatusActive)
+ isForceChange := strings.EqualFold(user.Status, entity.UserStatusForceChangePassword)
+
+ if !isActive && !isForceChange {
+ fmt.Printf("[LOGIN DEBUG] Status check FAILED: Expected %s or %s, got '%s'\n",
+ entity.UserStatusActive, entity.UserStatusForceChangePassword, user.Status)
return nil, errors.New("account inactive")
}
- fmt.Printf("[LOGIN DEBUG] Status check PASSED\n")
+ fmt.Printf("[LOGIN DEBUG] Status check PASSED (Active: %v, ForceChange: %v)\n", isActive, isForceChange)
// 4. Generate Token
roles := make([]string, len(user.Roles))
@@ -68,7 +72,8 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*d
// 5. Return Response
return &dto.AuthResponse{
- Token: token,
+ Token: token,
+ MustChangePassword: isForceChange,
User: dto.UserResponse{
ID: user.ID,
Name: user.Name,
diff --git a/backend/internal/core/usecases/auth/login_force_reset_test.go b/backend/internal/core/usecases/auth/login_force_reset_test.go
new file mode 100644
index 0000000..8ba6379
--- /dev/null
+++ b/backend/internal/core/usecases/auth/login_force_reset_test.go
@@ -0,0 +1,52 @@
+package auth_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
+ "github.com/rede5/gohorsejobs/backend/internal/core/dto"
+ "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
+)
+
+func TestLoginUseCase_Execute_ForceChangePassword(t *testing.T) {
+ // Setup Mocks (Reusing from register_candidate_test.go)
+ authSvc := &MockAuthService{
+ GenerateTokenFunc: func(userID, tenantID string, roles []string) (string, error) {
+ return "mock-token", nil
+ },
+ }
+
+ userRepo := &MockUserRepo{
+ FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
+ return &entity.User{
+ ID: "lol-user",
+ Email: email,
+ PasswordHash: "hashed_pass",
+ Status: entity.UserStatusForceChangePassword,
+ Roles: []entity.Role{},
+ }, nil
+ },
+ }
+
+ uc := auth.NewLoginUseCase(userRepo, authSvc)
+
+ input := dto.LoginRequest{
+ Email: "lol@gohorsejobs.com",
+ Password: "password",
+ }
+
+ resp, err := uc.Execute(context.Background(), input)
+
+ if err != nil {
+ t.Fatalf("Expected no error for force_change_password status, got %v", err)
+ }
+
+ if resp == nil {
+ t.Fatal("Expected response, got nil")
+ }
+
+ if !resp.MustChangePassword {
+ t.Error("Expected MustChangePassword to be true, got false")
+ }
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 58ed96d..ba4af33 100755
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -6,6 +6,7 @@ import (
"os"
"time"
+ // Added this import
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
"github.com/rede5/gohorsejobs/backend/internal/database"
"github.com/rede5/gohorsejobs/backend/internal/handlers"
@@ -276,7 +277,7 @@ func NewRouter() http.Handler {
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)
// Apply middleware chain: Security Headers -> Rate Limiting -> CORS -> Router
- // Order matters: outer middleware runs first
+ // Order matters: outer middleware
var handler http.Handler = mux
handler = middleware.CORSMiddleware(handler)
handler = legacyMiddleware.SanitizeMiddleware(handler) // Sanitize XSS from JSON bodies
diff --git a/backend/internal/services/storage_service.go b/backend/internal/services/storage_service.go
index 54c8a75..974d28f 100644
--- a/backend/internal/services/storage_service.go
+++ b/backend/internal/services/storage_service.go
@@ -89,3 +89,54 @@ func (s *StorageService) GetPresignedUploadURL(ctx context.Context, key string,
return req.URL, nil
}
+
+// TestConnection checks if the creds are valid and bucket is accessible
+func (s *StorageService) TestConnection(ctx context.Context) error {
+ psClient, bucket, err := s.getClient(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get client: %w", err)
+ }
+ // Note: PresignClient doesn't strictly validate creds against the cloud until used,
+ // checking existence via HeadBucket or ListBuckets using a real S3 client would be better.
+ // But getClient returns a PresignClient.
+ // We need a standard client to Verify.
+ // Re-instantiating logic or Refactoring `getClient` to return `*s3.Client` is best.
+ // For now, let's refactor `getClient` slightly to expose specific logic or just create a one-off checker here.
+
+ // Refetch raw creds to make a standard client
+ payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
+ if err != nil {
+ return fmt.Errorf("failed to get storage credentials: %w", err)
+ }
+ var uCfg UploadConfig
+ if err := json.Unmarshal([]byte(payload), &uCfg); err != nil {
+ return fmt.Errorf("failed to parse storage credentials: %w", err)
+ }
+
+ cfg, err := config.LoadDefaultConfig(ctx,
+ config.WithRegion(uCfg.Region),
+ config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(uCfg.AccessKey, uCfg.SecretKey, "")),
+ )
+ if err != nil {
+ return err
+ }
+
+ client := s3.NewFromConfig(cfg, func(o *s3.Options) {
+ o.BaseEndpoint = aws.String(uCfg.Endpoint)
+ o.UsePathStyle = true
+ })
+
+ // Try HeadBucket
+ _, err = client.HeadBucket(ctx, &s3.HeadBucketInput{
+ Bucket: aws.String(uCfg.Bucket),
+ })
+ if err != nil {
+ return fmt.Errorf("connection failed: %w", err)
+ }
+
+ // Just to be sure, presign client creation (original logic)
+ _ = psClient
+ _ = bucket
+
+ return nil
+}
diff --git a/backend/migrations/032_update_superadmin_lol.sql b/backend/migrations/032_update_superadmin_lol.sql
new file mode 100644
index 0000000..a19c73f
--- /dev/null
+++ b/backend/migrations/032_update_superadmin_lol.sql
@@ -0,0 +1,12 @@
+-- Migration: Update Super Admin to 'lol' and force password reset
+-- Description: Updates the superadmin identifier, email, name, and sets status to enforce password change.
+
+UPDATE users
+SET
+ identifier = 'lol',
+ email = 'lol@gohorsejobs.com',
+ full_name = 'Dr. Horse Expert',
+ name = 'Dr. Horse Expert',
+ status = 'force_change_password',
+ updated_at = CURRENT_TIMESTAMP
+WHERE identifier = 'superadmin' OR email = 'admin@gohorsejobs.com';
diff --git a/frontend/src/app/dashboard/settings/page.tsx b/frontend/src/app/dashboard/settings/page.tsx
index e8ae873..d7f90ff 100644
--- a/frontend/src/app/dashboard/settings/page.tsx
+++ b/frontend/src/app/dashboard/settings/page.tsx
@@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
-import { settingsApi, credentialsApi, ConfiguredService } from "@/lib/api"
+import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
import { toast } from "sonner"
import { Loader2, Check, Key, Trash2, Eye, EyeOff } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -46,6 +46,20 @@ export default function SettingsPage() {
const [credentialPayload, setCredentialPayload] = useState