From 58cfd76675ab4951a88d3bf0a64d47cb661096b6 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Mon, 22 Dec 2025 16:37:05 -0300 Subject: [PATCH] Add admin backoffice routes and dashboard --- .../internal/api/handlers/admin_handlers.go | 297 +++++++++++++ .../internal/api/handlers/core_handlers.go | 51 ++- .../api/middleware/auth_middleware.go | 52 +++ backend/internal/dto/requests.go | 4 +- backend/internal/models/job.go | 2 +- backend/internal/models/login_audit.go | 14 + backend/internal/models/tag.go | 13 + backend/internal/router/router.go | 18 +- backend/internal/services/admin_service.go | 292 ++++++++++++ backend/internal/services/audit_service.go | 72 +++ backend/internal/services/job_service.go | 7 + .../013_create_backoffice_tables.sql | 27 ++ .../014_update_job_status_constraint.sql | 18 + .../src/app/dashboard/backoffice/page.tsx | 418 ++++++++++++++++++ frontend/src/app/dashboard/companies/page.tsx | 4 +- frontend/src/app/dashboard/page.tsx | 4 +- frontend/src/app/dashboard/users/page.tsx | 4 +- frontend/src/components/sidebar.tsx | 9 +- frontend/src/lib/api.ts | 87 ++++ frontend/src/lib/auth.ts | 14 +- 20 files changed, 1393 insertions(+), 14 deletions(-) create mode 100644 backend/internal/api/handlers/admin_handlers.go create mode 100644 backend/internal/models/login_audit.go create mode 100644 backend/internal/models/tag.go create mode 100644 backend/internal/services/admin_service.go create mode 100644 backend/internal/services/audit_service.go create mode 100644 backend/migrations/013_create_backoffice_tables.sql create mode 100644 backend/migrations/014_update_job_status_constraint.sql create mode 100644 frontend/src/app/dashboard/backoffice/page.tsx diff --git a/backend/internal/api/handlers/admin_handlers.go b/backend/internal/api/handlers/admin_handlers.go new file mode 100644 index 0000000..6f79181 --- /dev/null +++ b/backend/internal/api/handlers/admin_handlers.go @@ -0,0 +1,297 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/services" +) + +type AdminHandlers struct { + adminService *services.AdminService + auditService *services.AuditService + jobService *services.JobService +} + +type RoleAccess struct { + Role string `json:"role"` + Description string `json:"description"` + Actions []string `json:"actions"` +} + +type UpdateCompanyStatusRequest struct { + Active *bool `json:"active,omitempty"` + Verified *bool `json:"verified,omitempty"` +} + +type UpdateJobStatusRequest struct { + Status string `json:"status"` +} + +type CreateTagRequest struct { + Name string `json:"name"` + Category string `json:"category"` +} + +type UpdateTagRequest struct { + Name *string `json:"name,omitempty"` + Active *bool `json:"active,omitempty"` +} + +func NewAdminHandlers(adminService *services.AdminService, auditService *services.AuditService, jobService *services.JobService) *AdminHandlers { + return &AdminHandlers{ + adminService: adminService, + auditService: auditService, + jobService: jobService, + } +} + +func (h *AdminHandlers) ListAccessRoles(w http.ResponseWriter, r *http.Request) { + roles := []RoleAccess{ + { + Role: "admin", + Description: "Administrador geral da plataforma", + Actions: []string{ + "criar/editar/excluir usuários", + "aprovar empresas", + "moderar vagas", + "gerir tags e categorias", + }, + }, + { + Role: "moderador", + Description: "Moderação de vagas e conteúdo", + Actions: []string{ + "aprovar, recusar ou pausar vagas", + "marcar vagas denunciadas", + "revisar empresas pendentes", + }, + }, + { + Role: "suporte", + Description: "Suporte ao usuário", + Actions: []string{ + "acessar perfil de usuário", + "resetar senhas", + "analisar logs de acesso", + }, + }, + { + Role: "financeiro", + Description: "Gestão financeira e faturamento", + Actions: []string{ + "ver planos e pagamentos", + "exportar relatórios financeiros", + "controlar notas e cobranças", + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(roles) +} + +func (h *AdminHandlers) ListLoginAudits(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + entries, err := h.auditService.ListLogins(r.Context(), limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +func (h *AdminHandlers) ListCompanies(w http.ResponseWriter, r *http.Request) { + var verified *bool + if verifiedParam := r.URL.Query().Get("verified"); verifiedParam != "" { + value := verifiedParam == "true" + verified = &value + } + + companies, err := h.adminService.ListCompanies(r.Context(), verified) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(companies) +} + +func (h *AdminHandlers) UpdateCompanyStatus(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid company ID", http.StatusBadRequest) + return + } + + var req UpdateCompanyStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + company, err := h.adminService.UpdateCompanyStatus(r.Context(), id, req.Active, req.Verified) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(company) +} + +func (h *AdminHandlers) ListJobs(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + status := r.URL.Query().Get("status") + + filter := dto.JobFilterQuery{ + PaginationQuery: dto.PaginationQuery{ + Page: page, + Limit: limit, + }, + } + + if status != "" { + filter.Status = &status + } + + jobs, total, err := h.jobService.GetJobs(filter) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := dto.PaginatedResponse{ + Data: jobs, + Pagination: dto.Pagination{ + Page: page, + Limit: limit, + Total: total, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (h *AdminHandlers) UpdateJobStatus(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid job ID", http.StatusBadRequest) + return + } + + var req UpdateJobStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + if req.Status == "" { + http.Error(w, "Status is required", http.StatusBadRequest) + return + } + + status := req.Status + job, err := h.jobService.UpdateJob(id, dto.UpdateJobRequest{Status: &status}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(job) +} + +func (h *AdminHandlers) DuplicateJob(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid job ID", http.StatusBadRequest) + return + } + + job, err := h.adminService.DuplicateJob(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(job) +} + +func (h *AdminHandlers) ListTags(w http.ResponseWriter, r *http.Request) { + category := r.URL.Query().Get("category") + if category == "" { + category = "" + } + + var categoryFilter *string + if category != "" { + categoryFilter = &category + } + + tags, err := h.adminService.ListTags(r.Context(), categoryFilter) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tags) +} + +func (h *AdminHandlers) CreateTag(w http.ResponseWriter, r *http.Request) { + var req CreateTagRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + if req.Name == "" || req.Category == "" { + http.Error(w, "Name and category are required", http.StatusBadRequest) + return + } + + tag, err := h.adminService.CreateTag(r.Context(), req.Name, req.Category) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(tag) +} + +func (h *AdminHandlers) UpdateTag(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid tag ID", http.StatusBadRequest) + return + } + + var req UpdateTagRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + tag, err := h.adminService.UpdateTag(r.Context(), id, req.Name, req.Active) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tag) +} diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index d726b1c..c713210 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -2,13 +2,16 @@ package handlers import ( "encoding/json" + "net" "net/http" + "strings" "github.com/rede5/gohorsejobs/backend/internal/api/middleware" "github.com/rede5/gohorsejobs/backend/internal/core/dto" "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user" + "github.com/rede5/gohorsejobs/backend/internal/services" ) type CoreHandlers struct { @@ -18,9 +21,10 @@ type CoreHandlers struct { listUsersUC *user.ListUsersUseCase deleteUserUC *user.DeleteUserUseCase listCompaniesUC *tenant.ListCompaniesUseCase + auditService *services.AuditService } -func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase) *CoreHandlers { +func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService) *CoreHandlers { return &CoreHandlers{ loginUC: l, createCompanyUC: c, @@ -28,6 +32,7 @@ func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *us listUsersUC: list, deleteUserUC: del, listCompaniesUC: lc, + auditService: auditService, } } @@ -55,6 +60,23 @@ func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) { return } + if h.auditService != nil { + ipAddress := extractClientIP(r) + userAgent := r.UserAgent() + var userAgentPtr *string + if userAgent != "" { + userAgentPtr = &userAgent + } + + _ = h.auditService.RecordLogin(r.Context(), services.LoginAuditInput{ + UserID: resp.User.ID, + Identifier: resp.User.Email, + Roles: resp.User.Roles, + IPAddress: ipAddress, + UserAgent: userAgentPtr, + }) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } @@ -210,3 +232,30 @@ func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"}) } + +func extractClientIP(r *http.Request) *string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + if len(parts) > 0 { + ip := strings.TrimSpace(parts[0]) + if ip != "" { + return &ip + } + } + } + + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return &realIP + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && host != "" { + return &host + } + + if r.RemoteAddr != "" { + return &r.RemoteAddr + } + + return nil +} diff --git a/backend/internal/api/middleware/auth_middleware.go b/backend/internal/api/middleware/auth_middleware.go index cbf3693..9629830 100644 --- a/backend/internal/api/middleware/auth_middleware.go +++ b/backend/internal/api/middleware/auth_middleware.go @@ -54,6 +54,58 @@ func (m *Middleware) HeaderAuthGuard(next http.Handler) http.Handler { }) } +// RequireRoles ensures the authenticated user has at least one of the required roles. +func (m *Middleware) RequireRoles(roles ...string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + roleValues := extractRoles(r.Context().Value(ContextRoles)) + if len(roleValues) == 0 { + http.Error(w, "Roles not found", http.StatusForbidden) + return + } + + if hasRole(roleValues, roles) { + next.ServeHTTP(w, r) + return + } + + http.Error(w, "Forbidden: insufficient permissions", http.StatusForbidden) + }) + } +} + +func extractRoles(value interface{}) []string { + switch roles := value.(type) { + case []string: + return roles + case []interface{}: + result := make([]string, 0, len(roles)) + for _, role := range roles { + if text, ok := role.(string); ok { + result = append(result, text) + } + } + return result + default: + return []string{} + } +} + +func hasRole(userRoles []string, allowedRoles []string) bool { + roleSet := make(map[string]struct{}, len(userRoles)) + for _, role := range userRoles { + roleSet[strings.ToLower(role)] = struct{}{} + } + + for _, role := range allowedRoles { + if _, ok := roleSet[strings.ToLower(role)]; ok { + return true + } + } + + return false +} + // TenantGuard ensures that the request is made by a user belonging to the prompt tenant // Note: In this architecture, the token *defines* the tenant. So HeaderAuthGuard implicitly guards the tenant. // This middleware is for extra checks if URL params conflict with Token tenant. diff --git a/backend/internal/dto/requests.go b/backend/internal/dto/requests.go index 7847bc1..d8ae50f 100755 --- a/backend/internal/dto/requests.go +++ b/backend/internal/dto/requests.go @@ -17,7 +17,7 @@ type CreateJobRequest struct { Benefits map[string]interface{} `json:"benefits,omitempty"` VisaSupport bool `json:"visaSupport"` LanguageLevel *string `json:"languageLevel,omitempty"` - Status string `json:"status" validate:"oneof=draft open closed"` + Status string `json:"status" validate:"oneof=draft open closed review published paused expired archived reported"` } // UpdateJobRequest represents the request to update a job @@ -36,7 +36,7 @@ type UpdateJobRequest struct { Benefits map[string]interface{} `json:"benefits,omitempty"` VisaSupport *bool `json:"visaSupport,omitempty"` LanguageLevel *string `json:"languageLevel,omitempty"` - Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft open closed"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft open closed review published paused expired archived reported"` } // CreateApplicationRequest represents a job application (guest or logged user) diff --git a/backend/internal/models/job.go b/backend/internal/models/job.go index 4b27d9b..132ded0 100755 --- a/backend/internal/models/job.go +++ b/backend/internal/models/job.go @@ -36,7 +36,7 @@ type Job struct { LanguageLevel *string `json:"languageLevel,omitempty" db:"language_level"` // N5-N1, beginner, none // Status - Status string `json:"status" db:"status"` // open, closed, draft + Status string `json:"status" db:"status"` // draft, review, published, paused, expired, archived, reported, open, closed IsFeatured bool `json:"isFeatured" db:"is_featured"` // Featured job flag // Metadata diff --git a/backend/internal/models/login_audit.go b/backend/internal/models/login_audit.go new file mode 100644 index 0000000..427cf0d --- /dev/null +++ b/backend/internal/models/login_audit.go @@ -0,0 +1,14 @@ +package models + +import "time" + +// LoginAudit captures authentication access history. +type LoginAudit struct { + ID int `json:"id" db:"id"` + UserID string `json:"userId" db:"user_id"` + Identifier string `json:"identifier" db:"identifier"` + Roles string `json:"roles" db:"roles"` + IPAddress *string `json:"ipAddress,omitempty" db:"ip_address"` + UserAgent *string `json:"userAgent,omitempty" db:"user_agent"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} diff --git a/backend/internal/models/tag.go b/backend/internal/models/tag.go new file mode 100644 index 0000000..d92e3e4 --- /dev/null +++ b/backend/internal/models/tag.go @@ -0,0 +1,13 @@ +package models + +import "time" + +// Tag represents a backoffice-managed job tag/category. +type Tag struct { + ID int `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Category string `json:"category" db:"category"` + Active bool `json:"active" db:"active"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 057e88e..e4025b0 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -57,8 +57,11 @@ func NewRouter() http.Handler { deleteUserUC := userUC.NewDeleteUserUseCase(userRepo) // Handlers & Middleware - coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC) + auditService := services.NewAuditService(database.DB) + coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC, auditService) authMiddleware := middleware.NewMiddleware(authService) + adminService := services.NewAdminService(database.DB) + adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService) // Initialize Legacy Handlers jobHandler := handlers.NewJobHandler(jobService) @@ -137,6 +140,19 @@ func NewRouter() http.Handler { mux.HandleFunc("PUT /api/v1/jobs/{id}", jobHandler.UpdateJob) mux.HandleFunc("DELETE /api/v1/jobs/{id}", jobHandler.DeleteJob) + // --- ADMIN ROUTES --- + adminOnly := authMiddleware.RequireRoles("ADMIN", "SUPERADMIN", "admin", "superadmin") + mux.Handle("GET /api/v1/admin/access/roles", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListAccessRoles)))) + mux.Handle("GET /api/v1/admin/audit/logins", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListLoginAudits)))) + mux.Handle("GET /api/v1/admin/companies", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListCompanies)))) + mux.Handle("PATCH /api/v1/admin/companies/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateCompanyStatus)))) + mux.Handle("GET /api/v1/admin/jobs", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListJobs)))) + mux.Handle("PATCH /api/v1/admin/jobs/{id}/status", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateJobStatus)))) + mux.Handle("POST /api/v1/admin/jobs/{id}/duplicate", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.DuplicateJob)))) + mux.Handle("GET /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListTags)))) + mux.Handle("POST /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateTag)))) + mux.Handle("PATCH /api/v1/admin/tags/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateTag)))) + // Application Routes mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication) mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications) diff --git a/backend/internal/services/admin_service.go b/backend/internal/services/admin_service.go new file mode 100644 index 0000000..4439507 --- /dev/null +++ b/backend/internal/services/admin_service.go @@ -0,0 +1,292 @@ +package services + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type AdminService struct { + DB *sql.DB +} + +func NewAdminService(db *sql.DB) *AdminService { + return &AdminService{DB: db} +} + +func (s *AdminService) ListCompanies(ctx context.Context, verified *bool) ([]models.Company, error) { + baseQuery := ` + SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at + FROM companies + ` + + var args []interface{} + if verified != nil { + baseQuery += " WHERE verified = $1" + args = append(args, *verified) + } + baseQuery += " ORDER BY created_at DESC" + + rows, err := s.DB.QueryContext(ctx, baseQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var companies []models.Company + for rows.Next() { + var c models.Company + if err := rows.Scan( + &c.ID, + &c.Name, + &c.Slug, + &c.Type, + &c.Document, + &c.Address, + &c.RegionID, + &c.CityID, + &c.Phone, + &c.Email, + &c.Website, + &c.LogoURL, + &c.Description, + &c.Active, + &c.Verified, + &c.CreatedAt, + &c.UpdatedAt, + ); err != nil { + return nil, err + } + companies = append(companies, c) + } + + return companies, nil +} + +func (s *AdminService) UpdateCompanyStatus(ctx context.Context, id int, active *bool, verified *bool) (*models.Company, error) { + company, err := s.getCompanyByID(ctx, id) + if err != nil { + return nil, err + } + + if active != nil { + company.Active = *active + } + if verified != nil { + company.Verified = *verified + } + + company.UpdatedAt = time.Now() + query := ` + UPDATE companies + SET active = $1, verified = $2, updated_at = $3 + WHERE id = $4 + ` + _, err = s.DB.ExecContext(ctx, query, company.Active, company.Verified, company.UpdatedAt, id) + if err != nil { + return nil, err + } + + return company, nil +} + +func (s *AdminService) DuplicateJob(ctx context.Context, id int) (*models.Job, error) { + query := ` + SELECT company_id, created_by, title, description, salary_min, salary_max, salary_type, + employment_type, work_mode, working_hours, location, region_id, city_id, + requirements, benefits, visa_support, language_level + FROM jobs + WHERE id = $1 + ` + + var job models.Job + if err := s.DB.QueryRowContext(ctx, query, id).Scan( + &job.CompanyID, + &job.CreatedBy, + &job.Title, + &job.Description, + &job.SalaryMin, + &job.SalaryMax, + &job.SalaryType, + &job.EmploymentType, + &job.WorkMode, + &job.WorkingHours, + &job.Location, + &job.RegionID, + &job.CityID, + &job.Requirements, + &job.Benefits, + &job.VisaSupport, + &job.LanguageLevel, + ); err != nil { + return nil, err + } + + job.Status = "draft" + job.IsFeatured = false + job.CreatedAt = time.Now() + job.UpdatedAt = time.Now() + + insertQuery := ` + INSERT INTO jobs ( + company_id, created_by, title, description, salary_min, salary_max, salary_type, + employment_type, work_mode, working_hours, location, region_id, city_id, + requirements, benefits, visa_support, language_level, status, is_featured, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + RETURNING id + ` + + if err := s.DB.QueryRowContext(ctx, insertQuery, + job.CompanyID, + job.CreatedBy, + job.Title, + job.Description, + job.SalaryMin, + job.SalaryMax, + job.SalaryType, + job.EmploymentType, + job.WorkMode, + job.WorkingHours, + job.Location, + job.RegionID, + job.CityID, + job.Requirements, + job.Benefits, + job.VisaSupport, + job.LanguageLevel, + job.Status, + job.IsFeatured, + job.CreatedAt, + job.UpdatedAt, + ).Scan(&job.ID); err != nil { + return nil, err + } + + return &job, nil +} + +func (s *AdminService) ListTags(ctx context.Context, category *string) ([]models.Tag, error) { + baseQuery := `SELECT id, name, category, active, created_at, updated_at FROM job_tags` + var args []interface{} + if category != nil && *category != "" { + baseQuery += " WHERE category = $1" + args = append(args, *category) + } + baseQuery += " ORDER BY name ASC" + + rows, err := s.DB.QueryContext(ctx, baseQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []models.Tag + for rows.Next() { + var t models.Tag + if err := rows.Scan(&t.ID, &t.Name, &t.Category, &t.Active, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, err + } + tags = append(tags, t) + } + + return tags, nil +} + +func (s *AdminService) CreateTag(ctx context.Context, name string, category string) (*models.Tag, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("tag name is required") + } + + now := time.Now() + tag := models.Tag{ + Name: strings.TrimSpace(name), + Category: category, + Active: true, + CreatedAt: now, + UpdatedAt: now, + } + + query := ` + INSERT INTO job_tags (name, category, active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + ` + if err := s.DB.QueryRowContext(ctx, query, tag.Name, tag.Category, tag.Active, tag.CreatedAt, tag.UpdatedAt).Scan(&tag.ID); err != nil { + return nil, err + } + + return &tag, nil +} + +func (s *AdminService) UpdateTag(ctx context.Context, id int, name *string, active *bool) (*models.Tag, error) { + tag, err := s.getTagByID(ctx, id) + if err != nil { + return nil, err + } + + if name != nil { + trimmed := strings.TrimSpace(*name) + if trimmed != "" { + tag.Name = trimmed + } + } + if active != nil { + tag.Active = *active + } + tagUpdatedAt := time.Now() + query := ` + UPDATE job_tags + SET name = $1, active = $2, updated_at = $3 + WHERE id = $4 + ` + _, err = s.DB.ExecContext(ctx, query, tag.Name, tag.Active, tagUpdatedAt, id) + if err != nil { + return nil, err + } + + tag.UpdatedAt = tagUpdatedAt + return tag, nil +} + +func (s *AdminService) getCompanyByID(ctx context.Context, id int) (*models.Company, error) { + query := ` + SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at + FROM companies WHERE id = $1 + ` + var c models.Company + if err := s.DB.QueryRowContext(ctx, query, id).Scan( + &c.ID, + &c.Name, + &c.Slug, + &c.Type, + &c.Document, + &c.Address, + &c.RegionID, + &c.CityID, + &c.Phone, + &c.Email, + &c.Website, + &c.LogoURL, + &c.Description, + &c.Active, + &c.Verified, + &c.CreatedAt, + &c.UpdatedAt, + ); err != nil { + return nil, err + } + return &c, nil +} + +func (s *AdminService) getTagByID(ctx context.Context, id int) (*models.Tag, error) { + query := `SELECT id, name, category, active, created_at, updated_at FROM job_tags WHERE id = $1` + var t models.Tag + if err := s.DB.QueryRowContext(ctx, query, id).Scan(&t.ID, &t.Name, &t.Category, &t.Active, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, err + } + return &t, nil +} diff --git a/backend/internal/services/audit_service.go b/backend/internal/services/audit_service.go new file mode 100644 index 0000000..84f8b25 --- /dev/null +++ b/backend/internal/services/audit_service.go @@ -0,0 +1,72 @@ +package services + +import ( + "context" + "database/sql" + "strings" + + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type AuditService struct { + DB *sql.DB +} + +func NewAuditService(db *sql.DB) *AuditService { + return &AuditService{DB: db} +} + +type LoginAuditInput struct { + UserID string + Identifier string + Roles []string + IPAddress *string + UserAgent *string +} + +func (s *AuditService) RecordLogin(ctx context.Context, input LoginAuditInput) error { + roles := strings.Join(input.Roles, ",") + query := ` + INSERT INTO login_audit (user_id, identifier, roles, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5) + ` + _, err := s.DB.ExecContext(ctx, query, input.UserID, input.Identifier, roles, input.IPAddress, input.UserAgent) + return err +} + +func (s *AuditService) ListLogins(ctx context.Context, limit int) ([]models.LoginAudit, error) { + if limit <= 0 { + limit = 50 + } + + query := ` + SELECT id, user_id, identifier, roles, ip_address, user_agent, created_at + FROM login_audit + ORDER BY created_at DESC + LIMIT $1 + ` + rows, err := s.DB.QueryContext(ctx, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var audits []models.LoginAudit + for rows.Next() { + var entry models.LoginAudit + if err := rows.Scan( + &entry.ID, + &entry.UserID, + &entry.Identifier, + &entry.Roles, + &entry.IPAddress, + &entry.UserAgent, + &entry.CreatedAt, + ); err != nil { + return nil, err + } + audits = append(audits, entry) + } + + return audits, nil +} diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index 37360f0..44191fa 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -94,6 +94,13 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany argId++ } + if filter.Status != nil && *filter.Status != "" { + baseQuery += fmt.Sprintf(" AND j.status = $%d", argId) + countQuery += fmt.Sprintf(" AND j.status = $%d", argId) + args = append(args, *filter.Status) + argId++ + } + // Add more filters as needed... // Pagination diff --git a/backend/migrations/013_create_backoffice_tables.sql b/backend/migrations/013_create_backoffice_tables.sql new file mode 100644 index 0000000..e89db6f --- /dev/null +++ b/backend/migrations/013_create_backoffice_tables.sql @@ -0,0 +1,27 @@ +-- Migration: Create backoffice support tables +-- Description: Tags for job categorization and login audit logs + +CREATE TABLE IF NOT EXISTS job_tags ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + category VARCHAR(30) NOT NULL CHECK (category IN ('area', 'level', 'stack')), + active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_job_tags_category ON job_tags(category); +CREATE INDEX IF NOT EXISTS idx_job_tags_active ON job_tags(active); + +CREATE TABLE IF NOT EXISTS login_audit ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + identifier VARCHAR(255) NOT NULL, + roles TEXT NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_login_audit_user_id ON login_audit(user_id); +CREATE INDEX IF NOT EXISTS idx_login_audit_created_at ON login_audit(created_at DESC); diff --git a/backend/migrations/014_update_job_status_constraint.sql b/backend/migrations/014_update_job_status_constraint.sql new file mode 100644 index 0000000..1393848 --- /dev/null +++ b/backend/migrations/014_update_job_status_constraint.sql @@ -0,0 +1,18 @@ +-- Migration: Update job status workflow +-- Description: Allow extended workflow statuses for moderation and lifecycle + +ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check; + +ALTER TABLE jobs + ADD CONSTRAINT jobs_status_check + CHECK (status IN ( + 'open', + 'closed', + 'draft', + 'review', + 'published', + 'paused', + 'expired', + 'archived', + 'reported' + )); diff --git a/frontend/src/app/dashboard/backoffice/page.tsx b/frontend/src/app/dashboard/backoffice/page.tsx new file mode 100644 index 0000000..bc3fcbd --- /dev/null +++ b/frontend/src/app/dashboard/backoffice/page.tsx @@ -0,0 +1,418 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + adminAccessApi, + adminAuditApi, + adminCompaniesApi, + adminJobsApi, + adminTagsApi, + type AdminCompany, + type AdminJob, + type AdminLoginAudit, + type AdminRoleAccess, + type AdminTag, +} from "@/lib/api" +import { getCurrentUser, isAdminUser } from "@/lib/auth" +import { toast } from "sonner" +import { Archive, CheckCircle, Copy, PauseCircle, Plus, RefreshCw, XCircle } from "lucide-react" + +const jobStatusBadge: Record = { + draft: { label: "Draft", variant: "outline" }, + review: { label: "Review", variant: "secondary" }, + published: { label: "Published", variant: "default" }, + paused: { label: "Paused", variant: "outline" }, + expired: { label: "Expired", variant: "destructive" }, + archived: { label: "Archived", variant: "outline" }, + reported: { label: "Reported", variant: "destructive" }, + open: { label: "Open", variant: "default" }, + closed: { label: "Closed", variant: "outline" }, +} + +export default function BackofficePage() { + const router = useRouter() + const [roles, setRoles] = useState([]) + const [audits, setAudits] = useState([]) + const [companies, setCompanies] = useState([]) + const [jobs, setJobs] = useState([]) + const [tags, setTags] = useState([]) + const [loading, setLoading] = useState(true) + const [creatingTag, setCreatingTag] = useState(false) + const [tagForm, setTagForm] = useState({ name: "", category: "area" as "area" | "level" | "stack" }) + + useEffect(() => { + const user = getCurrentUser() + if (!isAdminUser(user)) { + router.push("/dashboard") + return + } + loadBackoffice() + }, [router]) + + const loadBackoffice = async () => { + try { + setLoading(true) + const [rolesData, auditData, companiesData, jobsData, tagsData] = await Promise.all([ + adminAccessApi.listRoles(), + adminAuditApi.listLogins(20), + adminCompaniesApi.list(false), + adminJobsApi.list({ status: "review", limit: 10 }), + adminTagsApi.list(), + ]) + setRoles(rolesData) + setAudits(auditData) + setCompanies(companiesData) + setJobs(jobsData.data || []) + setTags(tagsData) + } catch (error) { + console.error("Error loading backoffice:", error) + toast.error("Failed to load backoffice data") + } finally { + setLoading(false) + } + } + + const handleApproveCompany = async (companyId: number) => { + try { + await adminCompaniesApi.updateStatus(companyId, { verified: true }) + toast.success("Company approved") + loadBackoffice() + } catch (error) { + console.error("Error approving company:", error) + toast.error("Failed to approve company") + } + } + + const handleDeactivateCompany = async (companyId: number) => { + try { + await adminCompaniesApi.updateStatus(companyId, { active: false }) + toast.success("Company deactivated") + loadBackoffice() + } catch (error) { + console.error("Error deactivating company:", error) + toast.error("Failed to deactivate company") + } + } + + const handleJobStatus = async (jobId: number, status: string) => { + try { + await adminJobsApi.updateStatus(jobId, status) + toast.success("Job status updated") + loadBackoffice() + } catch (error) { + console.error("Error updating job status:", error) + toast.error("Failed to update job status") + } + } + + const handleDuplicateJob = async (jobId: number) => { + try { + await adminJobsApi.duplicate(jobId) + toast.success("Job duplicated as draft") + loadBackoffice() + } catch (error) { + console.error("Error duplicating job:", error) + toast.error("Failed to duplicate job") + } + } + + const handleCreateTag = async () => { + if (!tagForm.name.trim()) { + toast.error("Tag name is required") + return + } + try { + setCreatingTag(true) + await adminTagsApi.create({ name: tagForm.name.trim(), category: tagForm.category }) + toast.success("Tag created") + setTagForm({ name: "", category: "area" }) + loadBackoffice() + } catch (error) { + console.error("Error creating tag:", error) + toast.error("Failed to create tag") + } finally { + setCreatingTag(false) + } + } + + const handleToggleTag = async (tag: AdminTag) => { + try { + await adminTagsApi.update(tag.id, { active: !tag.active }) + toast.success("Tag updated") + loadBackoffice() + } catch (error) { + console.error("Error updating tag:", error) + toast.error("Failed to update tag") + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+
+

Backoffice

+

Controle administrativo do GoHorse Jobs

+
+ +
+ + + + Gestão de usuários & acesso + Perfis, permissões e ações disponíveis no RBAC. + + + + + + Perfil + Descrição + Ações principais + + + + {roles.map((role) => ( + + {role.role} + {role.description} + +
+ {role.actions.map((action) => ( + {action} + ))} +
+
+
+ ))} +
+
+
+
+ + + + Auditoria de login + Histórico recente de acessos ao painel administrativo. + + + + + + Usuário + Roles + IP + Data + + + + {audits.map((audit) => ( + + {audit.identifier} + {audit.roles} + {audit.ipAddress || "-"} + {new Date(audit.createdAt).toLocaleString()} + + ))} + +
+
+
+ + + + Empresas pendentes + Aprovação e verificação de empresas. + + + + + + Empresa + Email + Status + Ações + + + + {companies.length === 0 && ( + + + Nenhuma empresa pendente. + + + )} + {companies.map((company) => ( + + {company.name} + {company.email || "-"} + + {company.verified ? ( + Verificada + ) : ( + Pendente + )} + + + + + + + ))} + +
+
+
+ + + + Moderação de vagas + Fluxo: rascunho → revisão → publicada → expirada/arquivada. + + + + + + Título + Empresa + Status + Ações + + + + {jobs.length === 0 && ( + + + Nenhuma vaga aguardando revisão. + + + )} + {jobs.map((job) => { + const statusConfig = jobStatusBadge[job.status] || { label: job.status, variant: "outline" } + return ( + + {job.title} + {job.companyName || "-"} + + {statusConfig.label} + + + + + + + + + ) + })} + +
+
+
+ + + + Tags e categorias + Áreas, níveis e stacks customizáveis. + + +
+ setTagForm({ ...tagForm, name: event.target.value })} + /> + + +
+ + + + Tag + Categoria + Status + Ações + + + + {tags.map((tag) => ( + + {tag.name} + {tag.category} + + {tag.active ? ( + Ativa + ) : ( + Inativa + )} + + + + + + ))} + +
+
+
+
+ ) +} diff --git a/frontend/src/app/dashboard/companies/page.tsx b/frontend/src/app/dashboard/companies/page.tsx index bcbcd1b..e14d1f1 100644 --- a/frontend/src/app/dashboard/companies/page.tsx +++ b/frontend/src/app/dashboard/companies/page.tsx @@ -19,7 +19,7 @@ import { import { Label } from "@/components/ui/label" import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle } from "lucide-react" import { companiesApi, type ApiCompany } from "@/lib/api" -import { getCurrentUser } from "@/lib/auth" +import { getCurrentUser, isAdminUser } from "@/lib/auth" import { toast } from "sonner" export default function AdminCompaniesPage() { @@ -37,7 +37,7 @@ export default function AdminCompaniesPage() { useEffect(() => { const user = getCurrentUser() - if (!user || (!user.roles?.includes("superadmin") && user.role !== "admin")) { + if (!isAdminUser(user)) { router.push("/dashboard") return } diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 220f8c7..2636c66 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" -import { getCurrentUser } from "@/lib/auth" +import { getCurrentUser, isAdminUser } from "@/lib/auth" import { AdminDashboardContent } from "@/components/dashboard-contents/admin-dashboard" import { CompanyDashboardContent } from "@/components/dashboard-contents/company-dashboard" import { CandidateDashboardContent } from "@/components/dashboard-contents/candidate-dashboard" @@ -29,7 +29,7 @@ export default function DashboardPage() { if (!user) return null // Role-based rendering - if (user.role === "admin" || user.roles?.includes("superadmin")) { + if (isAdminUser(user)) { return } diff --git a/frontend/src/app/dashboard/users/page.tsx b/frontend/src/app/dashboard/users/page.tsx index 998cc65..282d0f8 100644 --- a/frontend/src/app/dashboard/users/page.tsx +++ b/frontend/src/app/dashboard/users/page.tsx @@ -20,7 +20,7 @@ import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Plus, Search, Trash2, Loader2, RefreshCw } from "lucide-react" import { usersApi, type ApiUser } from "@/lib/api" -import { getCurrentUser } from "@/lib/auth" +import { getCurrentUser, isAdminUser } from "@/lib/auth" import { toast } from "sonner" export default function AdminUsersPage() { @@ -39,7 +39,7 @@ export default function AdminUsersPage() { useEffect(() => { const user = getCurrentUser() - if (!user || (!user.roles?.includes("superadmin") && user.role !== "admin")) { + if (!isAdminUser(user)) { router.push("/dashboard") return } diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx index 7beb138..8567195 100644 --- a/frontend/src/components/sidebar.tsx +++ b/frontend/src/components/sidebar.tsx @@ -5,7 +5,7 @@ import Image from "next/image" import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" import { LayoutDashboard, Briefcase, Users, MessageSquare, Building2, FileText } from "lucide-react" -import { getCurrentUser } from "@/lib/auth" +import { getCurrentUser, isAdminUser } from "@/lib/auth" const adminItems = [ { @@ -33,6 +33,11 @@ const adminItems = [ href: "/dashboard/companies", icon: Building2, }, + { + title: "Backoffice", + href: "/dashboard/backoffice", + icon: FileText, + }, { title: "Messages", href: "/dashboard/messages", @@ -81,7 +86,7 @@ export function Sidebar() { const user = getCurrentUser() let items = candidateItems - if (user?.role === "admin" || user?.roles?.includes("superadmin")) { + if (isAdminUser(user)) { items = adminItems } else if (user?.role === "company") { items = companyItems diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 858f361..7602fe7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -147,6 +147,93 @@ export const jobsApi = { getById: (id: number) => apiRequest(`/jobs/${id}`), }; +// Admin Backoffice API +export interface AdminRoleAccess { + role: string; + description: string; + actions: string[]; +} + +export interface AdminLoginAudit { + id: number; + userId: string; + identifier: string; + roles: string; + ipAddress?: string; + userAgent?: string; + createdAt: string; +} + +export interface AdminCompany extends ApiCompany {} + +export interface AdminJob extends ApiJob {} + +export interface AdminTag { + id: number; + name: string; + category: "area" | "level" | "stack"; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export const adminAccessApi = { + listRoles: () => apiRequest("/api/v1/admin/access/roles"), +}; + +export const adminAuditApi = { + listLogins: (limit = 50) => apiRequest(`/api/v1/admin/audit/logins?limit=${limit}`), +}; + +export const adminCompaniesApi = { + list: (verified?: boolean) => { + const query = typeof verified === "boolean" ? `?verified=${verified}` : ""; + return apiRequest(`/api/v1/admin/companies${query}`); + }, + updateStatus: (id: number, data: { active?: boolean; verified?: boolean }) => + apiRequest(`/api/v1/admin/companies/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }), +}; + +export const adminJobsApi = { + list: (params?: { page?: number; limit?: number; status?: string }) => { + const query = new URLSearchParams(); + if (params?.page) query.set("page", String(params.page)); + if (params?.limit) query.set("limit", String(params.limit)); + if (params?.status) query.set("status", params.status); + const queryStr = query.toString(); + return apiRequest>(`/api/v1/admin/jobs${queryStr ? `?${queryStr}` : ""}`); + }, + updateStatus: (id: number, status: string) => + apiRequest(`/api/v1/admin/jobs/${id}/status`, { + method: "PATCH", + body: JSON.stringify({ status }), + }), + duplicate: (id: number) => + apiRequest(`/api/v1/admin/jobs/${id}/duplicate`, { + method: "POST", + }), +}; + +export const adminTagsApi = { + list: (category?: "area" | "level" | "stack") => { + const query = category ? `?category=${category}` : ""; + return apiRequest(`/api/v1/admin/tags${query}`); + }, + create: (data: { name: string; category: "area" | "level" | "stack" }) => + apiRequest("/api/v1/admin/tags", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: number, data: { name?: string; active?: boolean }) => + apiRequest(`/api/v1/admin/tags/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }), +}; + // Transform API job to frontend Job format export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job { // Format salary diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 3617a0f..c341477 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -42,7 +42,7 @@ export async function login( // Note: The backend returns roles as an array of strings. The frontend expects a single 'role' or we need to adapt. // For now we map the first role or main role to the 'role' field. let userRole: "candidate" | "admin" | "company" = "candidate"; - if (data.user.roles.includes("superadmin") || data.user.roles.includes("admin")) { + if (data.user.roles.includes("superadmin") || data.user.roles.includes("admin") || data.user.roles.includes("ADMIN") || data.user.roles.includes("SUPERADMIN")) { userRole = "admin"; } else if (data.user.roles.includes("companyAdmin") || data.user.roles.includes("recruiter")) { userRole = "company"; @@ -86,6 +86,18 @@ export function getCurrentUser(): User | null { return null; } +export function isAdminUser(user: User | null): boolean { + if (!user) return false; + const roles = user.roles || []; + return ( + user.role === "admin" || + roles.includes("superadmin") || + roles.includes("admin") || + roles.includes("ADMIN") || + roles.includes("SUPERADMIN") + ); +} + export function isAuthenticated(): boolean { return getCurrentUser() !== null; }