From 754acd9d3c01714e98c545314c5e1e1e106c0eee Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Mon, 23 Feb 2026 12:42:06 -0600 Subject: [PATCH] feat: merge backoffice into settings and add CRUD for credentials - Move backoffice functionality into settings page as new tab - Remove standalone backoffice page and sidebar link - Add edit/delete buttons for credentials management - Update credentials service to allow overwriting existing credentials - Add API documentation for system credentials endpoints --- .../src/credentials/credentials.service.ts | 2 - docs/API.md | 94 +++ .../src/app/dashboard/backoffice/page.tsx | 554 ------------------ frontend/src/app/dashboard/settings/page.tsx | 498 +++++++++++++++- frontend/src/components/confirm-modal.tsx | 58 ++ frontend/src/components/sidebar.tsx | 6 - 6 files changed, 635 insertions(+), 577 deletions(-) delete mode 100644 frontend/src/app/dashboard/backoffice/page.tsx create mode 100644 frontend/src/components/confirm-modal.tsx diff --git a/backoffice/src/credentials/credentials.service.ts b/backoffice/src/credentials/credentials.service.ts index 81c6a68..1a7c50c 100644 --- a/backoffice/src/credentials/credentials.service.ts +++ b/backoffice/src/credentials/credentials.service.ts @@ -35,8 +35,6 @@ export class CredentialsService { let cred = await this.credentialsRepo.findOne({ where: { serviceName } }); if (!cred) { cred = this.credentialsRepo.create({ serviceName }); - } else { - throw new BadRequestException('Credentials already configured for this service.'); } cred.encryptedPayload = encrypted; cred.updatedBy = updatedBy; diff --git a/docs/API.md b/docs/API.md index 6af0815..5fd5bc1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -397,6 +397,100 @@ GET /docs/ --- +## 🔑 Module: System Credentials + +Manage encrypted credentials for external services (Stripe, SMTP, Storage, etc.). + +### List Configured Services +```http +GET /api/v1/system/credentials +``` +| Field | Auth | Roles | Description | +|-------|------|-------|-------------| +| Protected | ✅ | superadmin, admin | List all configured services (metadata only, no secrets) | + +**Response (200):** +```json +{ + "services": [ + { + "service_name": "stripe", + "updated_at": "2026-02-23T10:30:00Z", + "updated_by": "admin-uuid", + "is_configured": true + }, + { + "service_name": "smtp", + "updated_at": "2026-02-22T14:00:00Z", + "updated_by": "superadmin-uuid", + "is_configured": true + } + ] +} +``` + +### Save Credentials +```http +POST /api/v1/system/credentials +``` +| Field | Auth | Roles | Description | +|-------|------|-------|-------------| +| Protected | ✅ | superadmin, admin | Create or update service credentials | + +**Request:** +```json +{ + "serviceName": "stripe", + "payload": { + "secretKey": "sk_live_xxx", + "webhookSecret": "whsec_xxx", + "publishableKey": "pk_live_xxx" + } +} +``` + +**Response (200):** +```json +{ + "message": "Credentials saved successfully" +} +``` + +**Supported Services:** +| Service Name | Fields | +|--------------|--------| +| `stripe` | `secretKey`, `webhookSecret`, `publishableKey` | +| `smtp` | `host`, `port`, `username`, `password`, `from_email`, `from_name`, `secure` | +| `storage` | `endpoint`, `region`, `bucket`, `accessKey`, `secretKey` | +| `cloudflare_config` | `apiToken`, `zoneId` | +| `firebase` | `serviceAccountJson` (JSON string) | +| `appwrite` | `endpoint`, `projectId`, `apiKey` | +| `lavinmq` | `amqpUrl` | +| `cpanel` | `host`, `username`, `apiToken` | + +### Delete Credentials +```http +DELETE /api/v1/system/credentials/{serviceName} +``` +| Field | Auth | Roles | Description | +|-------|------|-------|-------------| +| Protected | ✅ | superadmin, admin | Delete service credentials | + +**Response (200):** +```json +{ + "success": true +} +``` + +**Security Notes:** +- Credentials are encrypted with RSA-OAEP-SHA256 before storage +- Only metadata (service name, timestamps) is returned on list operations +- Actual secret values cannot be retrieved after saving +- Updating credentials overwrites the existing configuration + +--- + ## 🔑 Permission Matrix | Route | Guest | JobSeeker | Recruiter | CompanyAdmin | Admin | SuperAdmin | diff --git a/frontend/src/app/dashboard/backoffice/page.tsx b/frontend/src/app/dashboard/backoffice/page.tsx deleted file mode 100644 index 6da5db5..0000000 --- a/frontend/src/app/dashboard/backoffice/page.tsx +++ /dev/null @@ -1,554 +0,0 @@ -"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 { - 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, - adminCompaniesApi, - adminJobsApi, - adminTagsApi, - backofficeApi, - plansApi, - 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, ExternalLink, PauseCircle, Plus, RefreshCw, XCircle } from "lucide-react" -import { ConfirmModal } from "@/components/confirm-modal" - -const auditDateFormatter = new Intl.DateTimeFormat("pt-BR", { - dateStyle: "short", - timeStyle: "short", - timeZone: "America/Sao_Paulo", -}) - -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 [stats, setStats] = useState(null) - - // ... 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 [deletePlanId, setDeletePlanId] = useState(null) - - const loadBackoffice = async (silent = false) => { - try { - if (!silent) setLoading(true) - 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), - plansApi.getAll().catch(() => []) - ]) - setRoles(rolesData) - setAudits(auditData) - setCompanies(companiesData.data || []) - setJobs(jobsData.data || []) - setTags(tagsData) - setStats(statsData) - - setPlans(plansData) - } catch (error) { - console.error("Error loading backoffice:", error) - toast.error("Failed to load backoffice data") - } finally { - if (!silent) setLoading(false) - } - } - - const handleApproveCompany = async (companyId: string) => { - try { - await adminCompaniesApi.updateStatus(companyId, { verified: true }) - toast.success("Company approved") - loadBackoffice(true) - } catch (error) { - console.error("Error approving company:", error) - toast.error("Failed to approve company") - } - } - - const handleDeactivateCompany = async (companyId: string) => { - try { - await adminCompaniesApi.updateStatus(companyId, { active: false }) - toast.success("Company deactivated") - loadBackoffice(true) - } catch (error) { - console.error("Error deactivating company:", error) - toast.error("Failed to deactivate company") - } - } - - const handleJobStatus = async (jobId: string, status: string) => { - try { - await adminJobsApi.updateStatus(jobId, status) - toast.success("Job status updated") - loadBackoffice(true) - } catch (error) { - console.error("Error updating job status:", error) - toast.error("Failed to update job status") - } - } - - const handleDuplicateJob = async (jobId: string) => { - try { - await adminJobsApi.duplicate(jobId) - toast.success("Job duplicated as draft") - loadBackoffice(true) - } 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(true) - } 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(true) - } catch (error) { - console.error("Error updating tag:", error) - toast.error("Failed to update tag") - } - } - - 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) => { - setDeletePlanId(id) - } - - const confirmDeletePlan = async () => { - if (!deletePlanId) return - try { - await plansApi.delete(deletePlanId) - toast.success("Plan deleted") - loadBackoffice(true) - } catch (error) { - toast.error("Failed to delete plan") - } finally { - setDeletePlanId(null) - } - } - - const handlePurgeCache = async () => { - try { - await backofficeApi.externalServices.purgeCloudflareCache() - toast.success("Cloudflare cache purged") - } catch (error) { - toast.error("Failed to purge cache") - } - } - - if (loading) { - return ( -
-
-
- ) - } - - return ( -
-
-
-

Backoffice

-

SaaS Administration & Operations

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

{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}

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

{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}

-
-
- - - 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}
-
- ))} -
-
-
-
- - - -
- -
- - - Plans Management - Configure subscription plans. - - - - - - 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 })} /> -
-
-
- -