diff --git a/backend/generate_keys.go b/backend/generate_keys.go new file mode 100644 index 0000000..337f7ae --- /dev/null +++ b/backend/generate_keys.go @@ -0,0 +1,61 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "os" +) + +func main() { + // Generate Key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + + // Validate Key + if err := privateKey.Validate(); err != nil { + panic(err) + } + + // Dump Private Key + privDER := x509.MarshalPKCS1PrivateKey(privateKey) + privBlock := pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privDER, + } + privPEM := pem.EncodeToMemory(&privBlock) + privBase64 := base64.StdEncoding.EncodeToString(privPEM) + + // Dump Public Key + pubDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + panic(err) + } + pubBlock := pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubDER, + } + pubPEM := pem.EncodeToMemory(&pubBlock) + pubBase64 := base64.StdEncoding.EncodeToString(pubPEM) + + // Append to .env + f, err := os.OpenFile(".env", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + panic(err) + } + defer f.Close() + + if _, err := f.WriteString(fmt.Sprintf("\nRSA_PRIVATE_KEY_BASE64=%s\n", privBase64)); err != nil { + panic(err) + } + if _, err := f.WriteString(fmt.Sprintf("RSA_PUBLIC_KEY_BASE64=%s\n", pubBase64)); err != nil { + panic(err) + } + + fmt.Println("✅ Keys appended to .env successfully") +} diff --git a/backend/internal/api/handlers/credentials_handler.go b/backend/internal/api/handlers/credentials_handler.go new file mode 100644 index 0000000..faf9384 --- /dev/null +++ b/backend/internal/api/handlers/credentials_handler.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/rede5/gohorsejobs/backend/internal/services" +) + +type CredentialsHandler struct { + credentialsService *services.CredentialsService +} + +func NewCredentialsHandler(s *services.CredentialsService) *CredentialsHandler { + return &CredentialsHandler{credentialsService: s} +} + +// ListCredentials returns a list of configured services (metadata only, no secrets) +// GET /api/v1/system/credentials +func (h *CredentialsHandler) ListCredentials(w http.ResponseWriter, r *http.Request) { + services, err := h.credentialsService.ListConfiguredServices(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "services": services, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// SaveCredential saves encrypted credentials for a service +// POST /api/v1/system/credentials +func (h *CredentialsHandler) SaveCredential(w http.ResponseWriter, r *http.Request) { + var req struct { + ServiceName string `json:"serviceName"` + Payload interface{} `json:"payload"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ServiceName == "" { + http.Error(w, "Service name is required", http.StatusBadRequest) + return + } + + // Marshaling the payload to JSON string before encryption + payloadBytes, err := json.Marshal(req.Payload) + if err != nil { + http.Error(w, "Invalid payload format", http.StatusBadRequest) + return + } + + // Encrypt using the service + encryptedPayload, err := h.credentialsService.EncryptPayload(string(payloadBytes)) + if err != nil { + http.Error(w, "Failed to encrypt credentials: "+err.Error(), http.StatusInternalServerError) + return + } + + // Determine user making the change (from context or default) + updatedBy := "admin" // TODO: Extract from context keys if available + + if err := h.credentialsService.SaveCredentials(r.Context(), req.ServiceName, encryptedPayload, updatedBy); err != nil { + http.Error(w, "Failed to save credentials: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "Credentials saved successfully"}) +} + +// DeleteCredential removes credentials for a service +// DELETE /api/v1/system/credentials/{service} +func (h *CredentialsHandler) DeleteCredential(w http.ResponseWriter, r *http.Request) { + serviceName := r.PathValue("service") + if serviceName == "" { + http.Error(w, "Service name is required", http.StatusBadRequest) + return + } + + if err := h.credentialsService.DeleteCredentials(r.Context(), serviceName); err != nil { + http.Error(w, "Failed to delete credentials: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "Credentials deleted successfully"}) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 762dd0e..094cdf7 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -98,6 +98,7 @@ func NewRouter() http.Handler { ) settingsHandler := apiHandlers.NewSettingsHandler(settingsService) + credentialsHandler := apiHandlers.NewCredentialsHandler(credentialsService) // Added storageHandler := apiHandlers.NewStorageHandler(storageService) adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService) locationHandlers := apiHandlers.NewLocationHandlers(locationService) @@ -236,6 +237,11 @@ func NewRouter() http.Handler { mux.Handle("GET /api/v1/system/settings/{key}", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(settingsHandler.GetSettings))) mux.Handle("POST /api/v1/system/settings/{key}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(settingsHandler.SaveSettings)))) + // System Credentials + mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.ListCredentials)))) + mux.Handle("POST /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.SaveCredential)))) + mux.Handle("DELETE /api/v1/system/credentials/{service}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.DeleteCredential)))) + // Storage (Presigned URL) mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) // Storage (Direct Proxy) diff --git a/backoffice/src/plans/plans.controller.ts b/backoffice/src/plans/plans.controller.ts index 8c70168..6d8f58f 100644 --- a/backoffice/src/plans/plans.controller.ts +++ b/backoffice/src/plans/plans.controller.ts @@ -2,22 +2,72 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, NotFoundException } import { ApiTags, ApiOperation, ApiBody, ApiBearerAuth } from '@nestjs/swagger'; import { PlansService, Plan } from './plans.service'; +import { IsString, IsNumber, IsArray, IsBoolean, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + class CreatePlanDto { - id: string; + @ApiPropertyOptional() + @IsString() + @IsOptional() + id?: string; + + @ApiProperty() + @IsString() name: string; + + @ApiProperty() + @IsString() description: string; + + @ApiProperty() + @IsNumber() monthlyPrice: number; + + @ApiProperty() + @IsNumber() yearlyPrice: number; + + @ApiProperty() + @IsArray() + @IsString({ each: true }) features: string[]; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() popular?: boolean; } class UpdatePlanDto { + @ApiPropertyOptional() + @IsString() + @IsOptional() name?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() description?: string; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() monthlyPrice?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() yearlyPrice?: number; + + @ApiPropertyOptional() + @IsArray() + @IsString({ each: true }) + @IsOptional() features?: string[]; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() popular?: boolean; } @@ -47,7 +97,11 @@ export class PlansController { @ApiOperation({ summary: 'Create a new plan' }) @ApiBody({ type: CreatePlanDto }) createPlan(@Body() body: CreatePlanDto) { - return this.plansService.createPlan(body); + const plan: Plan = { + ...body, + id: body.id || body.name.toLowerCase().replace(/\s+/g, '-'), + }; + return this.plansService.createPlan(plan); } @Patch(':id') diff --git a/frontend/src/app/dashboard/backoffice/page.tsx b/frontend/src/app/dashboard/backoffice/page.tsx index b3cf0a0..122fbec 100644 --- a/frontend/src/app/dashboard/backoffice/page.tsx +++ b/frontend/src/app/dashboard/backoffice/page.tsx @@ -14,6 +14,18 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" import { adminAccessApi, adminAuditApi, @@ -21,6 +33,7 @@ import { adminJobsApi, adminTagsApi, backofficeApi, + plansApi, type AdminCompany, type AdminJob, type AdminLoginAudit, @@ -29,12 +42,12 @@ import { } from "@/lib/api" import { getCurrentUser, isAdminUser } from "@/lib/auth" import { toast } from "sonner" -import { Archive, CheckCircle, Copy, PauseCircle, Plus, RefreshCw, XCircle } from "lucide-react" +import { Archive, CheckCircle, Copy, ExternalLink, PauseCircle, Plus, RefreshCw, XCircle } from "lucide-react" const auditDateFormatter = new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short", - timeZone: "UTC", + timeZone: "America/Sao_Paulo", }) const jobStatusBadge: Record = { @@ -73,16 +86,26 @@ export default function BackofficePage() { // ... imports and other state ... + // ... imports and other state ... + const [plans, setPlans] = useState([]) + const [activeTab, setActiveTab] = useState("dashboard") + + // Plan Form State + const [isPlanDialogOpen, setIsPlanDialogOpen] = useState(false) + const [planForm, setPlanForm] = useState({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }) + const [editingPlanId, setEditingPlanId] = useState(null) + const loadBackoffice = async (silent = false) => { try { if (!silent) setLoading(true) - const [rolesData, auditData, companiesData, jobsData, tagsData, statsData] = await Promise.all([ + const [rolesData, auditData, companiesData, jobsData, tagsData, statsData, plansData] = await Promise.all([ adminAccessApi.listRoles(), adminAuditApi.listLogins(20), adminCompaniesApi.list(false), adminJobsApi.list({ status: "review", limit: 10 }), adminTagsApi.list(), - backofficeApi.admin.getStats().catch(() => null), // Fail gracefully if backoffice API is down + backofficeApi.admin.getStats().catch(() => null), + plansApi.getAll().catch(() => []) ]) setRoles(rolesData) setAudits(auditData) @@ -90,6 +113,8 @@ export default function BackofficePage() { setJobs(jobsData.data || []) setTags(tagsData) setStats(statsData) + + setPlans(plansData) } catch (error) { console.error("Error loading backoffice:", error) toast.error("Failed to load backoffice data") @@ -97,11 +122,6 @@ export default function BackofficePage() { if (!silent) setLoading(false) } } - // ... - - // Handlers follow... - - const handleApproveCompany = async (companyId: string) => { try { @@ -177,6 +197,51 @@ export default function BackofficePage() { } } + const handleSavePlan = async () => { + try { + const payload = { + ...planForm, + monthlyPrice: Number(planForm.monthlyPrice), + yearlyPrice: Number(planForm.yearlyPrice), + features: Array.isArray(planForm.features) ? planForm.features : planForm.features.split(',').map((f: string) => f.trim()) + } + + if (editingPlanId) { + await plansApi.update(editingPlanId, payload) + toast.success("Plan updated") + } else { + await plansApi.create(payload) + toast.success("Plan created") + } + setIsPlanDialogOpen(false) + setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }) + setEditingPlanId(null) + loadBackoffice(true) + } catch (error) { + toast.error("Failed to save plan") + } + } + + const handleDeletePlan = async (id: string) => { + if (!confirm("Delete this plan?")) return + try { + await plansApi.delete(id) + toast.success("Plan deleted") + loadBackoffice(true) + } catch (error) { + toast.error("Failed to delete plan") + } + } + + const handlePurgeCache = async () => { + try { + await backofficeApi.externalServices.purgeCloudflareCache() + toast.success("Cloudflare cache purged") + } catch (error) { + toast.error("Failed to purge cache") + } + } + if (loading) { return (
@@ -186,298 +251,289 @@ export default function BackofficePage() { } return ( -
- +

Backoffice

SaaS Administration & Operations

- +
+ +
- {/* Stats Overview */} - {stats && ( -
- - - Total Revenue - $ - - -
${stats.monthlyRevenue?.toLocaleString() || '0'}
-

+20.1% from last month

-
-
- - - Active Subscriptions - - - -
{stats.activeSubscriptions || 0}
-

+180 since last hour

-
-
- - - Companies -
- - -
{stats.totalCompanies || 0}
-

Platform total

-
- - - - New (Month) - - - -
+{stats.newCompaniesThisMonth || 0}
-

Since start of month

-
-
-
- )} + + + Dashboard + Plans + Stripe + System + - - - 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} - ))} + + {/* Stats Overview */} + {stats && ( +
+ + + Total Revenue + $ + + +
${stats.monthlyRevenue?.toLocaleString() || '0'}
+

+20.1% from last month

+
+
+ + + Active Subscriptions + + + +
{stats.activeSubscriptions || 0}
+

+180 since last hour

+
+
+ + + Companies +
+ + +
{stats.totalCompanies || 0}
+

Platform total

+
+ + + + New (Month) + + + +
+{stats.newCompaniesThisMonth || 0}
+

Since start of month

+
+
+
+ )} + +
+ + + Empresas pendentes + Aprovação e verificação de empresas. + + +
+ + + Empresa + Status + Ações + + + + {companies.slice(0, 5).map((company) => ( + + {company.name} + + {company.verified ? Verificada : Pendente} + + + + + + ))} + +
+
+
+ + + Auditoria Recente + Últimos acessos. + + +
+ {audits.slice(0, 5).map((audit) => ( +
+
+

{audit.identifier}

+

{auditDateFormatter.format(new Date(audit.createdAt))}

+
+
{audit.roles}
- - - ))} - - - - + ))} +
+
+
+
+ - - - 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 || "-"} - {auditDateFormatter.format(new Date(audit.createdAt))} - - ))} - -
-
-
- - - - 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 - )} - - -
+ + + Name + Monthly + Yearly + Actions + + + + {plans.map((plan) => ( + + {plan.name} + ${plan.monthlyPrice} + ${plan.yearlyPrice} + + + + + + ))} + +
+
+
+ + + + + {editingPlanId ? 'Edit Plan' : 'Create Plan'} + +
+
+ + setPlanForm({ ...planForm, name: e.target.value })} /> +
+
+ + setPlanForm({ ...planForm, description: e.target.value })} /> +
+
+
+ + setPlanForm({ ...planForm, monthlyPrice: e.target.value })} /> +
+
+ + setPlanForm({ ...planForm, yearlyPrice: e.target.value })} /> +
+
+
+ +