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({}) const [isDialogOpen, setIsDialogOpen] = useState(false) const [showPassword, setShowPassword] = useState(false) + const [testingConnection, setTestingConnection] = useState(false) + + const handleTestStorageConnection = async () => { + setTestingConnection(true) + try { + const res = await storageApi.testConnection() + toast.success(res.message || "Connection successful") + } catch (error: any) { + console.error("Storage connection test failed", error) + toast.error(error.message || "Connection failed") + } finally { + setTestingConnection(false) + } + } const fetchSettings = async () => { try { @@ -303,15 +317,22 @@ export default function SettingsPage() {

- - {svc.is_configured && ( - )} +
+ + {svc.is_configured && ( + + )} +
))} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9ebeb96..d7d8725 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -718,6 +718,12 @@ export const credentialsApi = { }), }; +export const storageApi = { + testConnection: () => apiRequest<{ message: string }>("/api/v1/admin/storage/test-connection", { + method: "POST" + }), +}; + // --- Email Templates & Settings --- export interface EmailTemplate { id: string;