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
This commit is contained in:
Tiago Yamamoto 2026-02-23 12:42:06 -06:00
parent f22bd51c5d
commit 754acd9d3c
6 changed files with 635 additions and 577 deletions

View file

@ -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;

View file

@ -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 |

View file

@ -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<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
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<AdminRoleAccess[]>([])
const [audits, setAudits] = useState<AdminLoginAudit[]>([])
const [companies, setCompanies] = useState<AdminCompany[]>([])
const [jobs, setJobs] = useState<AdminJob[]>([])
const [tags, setTags] = useState<AdminTag[]>([])
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<any>(null)
// ... imports and other state ...
// ... imports and other state ...
const [plans, setPlans] = useState<any[]>([])
const [activeTab, setActiveTab] = useState("dashboard")
// Plan Form State
const [isPlanDialogOpen, setIsPlanDialogOpen] = useState(false)
const [planForm, setPlanForm] = useState<any>({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] })
const [editingPlanId, setEditingPlanId] = useState<string | null>(null)
const [deletePlanId, setDeletePlanId] = useState<string | null>(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 (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Backoffice</h1>
<p className="text-muted-foreground mt-1">SaaS Administration & Operations</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => loadBackoffice(false)} className="gap-2">
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
</div>
<Tabs defaultValue="dashboard" className="space-y-4">
<TabsList>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
<TabsTrigger value="plans">Plans</TabsTrigger>
<TabsTrigger value="stripe">Stripe</TabsTrigger>
<TabsTrigger value="system">System</TabsTrigger>
</TabsList>
<TabsContent value="dashboard" className="space-y-4">
{/* Stats Overview */}
{stats && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<span className="text-xs text-muted-foreground">$</span>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
<p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
<p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Companies</CardTitle>
<div className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalCompanies || 0}</div>
<p className="text-xs text-muted-foreground">Platform total</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">New (Month)</CardTitle>
<Plus className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+{stats.newCompaniesThisMonth || 0}</div>
<p className="text-xs text-muted-foreground">Since start of month</p>
</CardContent>
</Card>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle>Empresas pendentes</CardTitle>
<CardDescription>Aprovação e verificação de empresas.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Empresa</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.slice(0, 5).map((company) => (
<TableRow key={company.id}>
<TableCell className="font-medium">{company.name}</TableCell>
<TableCell>
{company.verified ? <Badge className="bg-green-500">Verificada</Badge> : <Badge variant="secondary">Pendente</Badge>}
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}>
<CheckCircle className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle>Auditoria Recente</CardTitle>
<CardDescription>Últimos acessos.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-8">
{audits.slice(0, 5).map((audit) => (
<div key={audit.id} className="flex items-center">
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">{audit.identifier}</p>
<p className="text-xs text-muted-foreground">{auditDateFormatter.format(new Date(audit.createdAt))}</p>
</div>
<div className="ml-auto font-medium text-xs text-muted-foreground">{audit.roles}</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="plans" className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}>
<Plus className="mr-2 h-4 w-4" /> Create Plan
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Plans Management</CardTitle>
<CardDescription>Configure subscription plans.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Monthly</TableHead>
<TableHead>Yearly</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{plans.map((plan) => (
<TableRow key={plan.id}>
<TableCell className="font-medium">{plan.name}</TableCell>
<TableCell>${plan.monthlyPrice}</TableCell>
<TableCell>${plan.yearlyPrice}</TableCell>
<TableCell className="text-right space-x-2">
<Button size="sm" variant="outline" onClick={() => { setEditingPlanId(plan.id); setPlanForm({ ...plan }); setIsPlanDialogOpen(true) }}>Edit</Button>
<Button size="sm" variant="destructive" onClick={() => handleDeletePlan(plan.id)}>Delete</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={isPlanDialogOpen} onOpenChange={setIsPlanDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingPlanId ? 'Edit Plan' : 'Create Plan'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Name</Label>
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} />
</div>
<div className="grid gap-2">
<Label>Description</Label>
<Input value={planForm.description} onChange={(e) => setPlanForm({ ...planForm, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Monthly Price</Label>
<Input type="number" value={planForm.monthlyPrice} onChange={(e) => setPlanForm({ ...planForm, monthlyPrice: e.target.value })} />
</div>
<div className="grid gap-2">
<Label>Yearly Price</Label>
<Input type="number" value={planForm.yearlyPrice} onChange={(e) => setPlanForm({ ...planForm, yearlyPrice: e.target.value })} />
</div>
</div>
<div className="grid gap-2">
<Label>Features (comma separated)</Label>
<Textarea value={Array.isArray(planForm.features) ? planForm.features.join(', ') : planForm.features} onChange={(e) => setPlanForm({ ...planForm, features: e.target.value })} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsPlanDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSavePlan}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="stripe" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Stripe Integration</CardTitle>
<CardDescription>Manage subscriptions and payments directly in Stripe Dashboard.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 border rounded bg-muted/20">
<p className="text-sm">
For security and advanced management (refunds, disputes, tax settings), please use the official Stripe Dashboard.
</p>
<div className="mt-4">
<a href="https://dashboard.stripe.com" target="_blank" rel="noreferrer">
<Button variant="outline">
Open Stripe Dashboard <ExternalLink className="ml-2 h-4 w-4" />
</Button>
</a>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>System & Caching</CardTitle>
<CardDescription>Maintenance tasks.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded">
<div>
<p className="font-medium">Cloudflare Cache</p>
<p className="text-sm text-muted-foreground">Purge all cached files from the edge.</p>
</div>
<Button variant="outline" onClick={handlePurgeCache}>Purge Cache</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tags Management</CardTitle>
</CardHeader>
<CardContent>
{/* Reusing existing Tags Table logic here if desired, or keep it in a sub-section */}
<div className="flex flex-col md:flex-row gap-4 mb-4">
<Input placeholder="New Tag" value={tagForm.name} onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })} />
<Select value={tagForm.category} onValueChange={(val: any) => setTagForm({ ...tagForm, category: val })}>
<SelectTrigger className="w-40"><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>
<SelectItem value="area">Area</SelectItem>
<SelectItem value="level">Level</SelectItem>
<SelectItem value="stack">Stack</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreateTag} disabled={creatingTag}><Plus className="mr-2 h-4 w-4" /> Add</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tag</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tags.map((tag) => (
<TableRow key={tag.id}>
<TableCell>{tag.name}</TableCell>
<TableCell>{tag.category}</TableCell>
<TableCell>{tag.active ? <Badge className="bg-green-500">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => handleToggleTag(tag)}>Toggle</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<ConfirmModal
isOpen={!!deletePlanId}
onClose={() => setDeletePlanId(null)}
onConfirm={confirmDeletePlan}
title="Are you sure you want to delete this plan?"
description="This action cannot be undone."
/>
</div>
)
}

View file

@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
import { toast } from "sonner"
import { Loader2, Check, Key } from "lucide-react"
import { Loader2, Check, Key, CheckCircle, Plus, ExternalLink } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Dialog,
@ -21,6 +21,33 @@ import {
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Textarea } from "@/components/ui/textarea"
import { ConfirmModal } from "@/components/confirm-modal"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const auditDateFormatter = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "America/Sao_Paulo",
})
const jobStatusBadge: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
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" },
}
interface ThemeConfig {
logoUrl: string
@ -87,10 +114,6 @@ export default function SettingsPage() {
}
}
useEffect(() => {
Promise.all([fetchSettings(), fetchCredentials()]).finally(() => setLoading(false))
}, [])
const handleSaveTheme = async () => {
setSaving(true)
try {
@ -222,12 +245,169 @@ export default function SettingsPage() {
}
}
// ... existing imports ...
// Note: State definition needs update to object payload
const handleDeleteCredential = async (serviceName: string) => {
if (!confirm(`Are you sure you want to delete credentials for ${schemas[serviceName]?.label || serviceName}?`)) return
try {
await credentialsApi.delete(serviceName)
toast.success(`Credentials for ${schemas[serviceName]?.label || serviceName} deleted`)
fetchCredentials()
} catch (error) {
console.error("Failed to delete credential", error)
toast.error("Failed to delete credential")
}
}
// State migrated from backoffice
// const [credentialPayload, setCredentialPayload] = useState<any>({})
const isSelectedConfigured = selectedService ? configuredMap.has(selectedService) : false
// State migrated from backoffice
const [roles, setRoles] = useState<any[]>([])
const [audits, setAudits] = useState<any[]>([])
const [companies, setCompanies] = useState<any[]>([])
const [jobs, setJobs] = useState<any[]>([])
const [tags, setTags] = useState<any[]>([])
const [creatingTag, setCreatingTag] = useState(false)
const [tagForm, setTagForm] = useState({ name: "", category: "area" as "area" | "level" | "stack" })
const [stats, setStats] = useState<any>(null)
const [plans, setPlans] = useState<any[]>([])
const [isPlanDialogOpen, setIsPlanDialogOpen] = useState(false)
const [planForm, setPlanForm] = useState<any>({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] })
const [editingPlanId, setEditingPlanId] = useState<string | null>(null)
const [deletePlanId, setDeletePlanId] = useState<string | null>(null)
const loadBackoffice = async (silent = false) => {
try {
if (!silent) setLoading(true)
const { adminAccessApi, adminAuditApi, adminCompaniesApi, adminJobsApi, adminTagsApi, backofficeApi, plansApi } = await import("@/lib/api")
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 {
const { adminCompaniesApi } = await import("@/lib/api")
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 handleCreateTag = async () => {
if (!tagForm.name.trim()) {
toast.error("Tag name is required")
return
}
try {
const { adminTagsApi } = await import("@/lib/api")
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: any) => {
try {
const { adminTagsApi } = await import("@/lib/api")
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 { plansApi } = await import("@/lib/api")
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 {
const { plansApi } = await import("@/lib/api")
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 {
const { backofficeApi } = await import("@/lib/api")
await backofficeApi.externalServices.purgeCloudflareCache()
toast.success("Cloudflare cache purged")
} catch (error) {
toast.error("Failed to purge cache")
}
}
useEffect(() => {
Promise.all([fetchSettings(), fetchCredentials(), loadBackoffice()]).finally(() => setLoading(false))
}, [])
return (
<div className="space-y-6">
{/* Header ... */}
@ -237,10 +417,11 @@ export default function SettingsPage() {
</div>
<Separator />
<Tabs defaultValue="integrations" className="space-y-4"> {/* Default to integrations for visibility */}
<Tabs defaultValue="integrations" className="space-y-4">
<TabsList>
<TabsTrigger value="theme">Branding & Theme</TabsTrigger>
<TabsTrigger value="integrations">Integrations & Credentials</TabsTrigger>
<TabsTrigger value="backoffice">Backoffice</TabsTrigger>
</TabsList>
{/* Theme Tab Content (unchanged) */}
@ -306,7 +487,7 @@ export default function SettingsPage() {
<Card>
<CardHeader>
<CardTitle>External Services</CardTitle>
<CardDescription>Manage credentials for third-party integrations securely. Keys are encrypted and locked after saving.</CardDescription>
<CardDescription>Manage credentials for third-party integrations securely. Keys are encrypted before storage.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@ -329,23 +510,31 @@ export default function SettingsPage() {
: 'Not configured'}
</p>
</div>
<div className="flex items-center gap-2 mt-auto">
<div className="flex flex-col gap-2 mt-auto">
{svc.service_name === 'storage' && svc.is_configured && (
<Button variant="secondary" size="sm" className="w-full mb-2" onClick={handleTestStorageConnection} disabled={testingConnection}>
<Button variant="secondary" size="sm" className="w-full" onClick={handleTestStorageConnection} disabled={testingConnection}>
{testingConnection ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
</Button>
)}
<div className="flex w-full gap-2">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="w-full"
className="flex-1"
onClick={() => handleOpenCredentialDialog(svc.service_name)}
disabled={svc.is_configured}
>
<Key className="w-3 h-3 mr-2" />
{svc.is_configured ? "Configured" : "Setup"}
{svc.is_configured ? "Edit" : "Setup"}
</Button>
{svc.is_configured && (
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteCredential(svc.service_name)}
>
Delete
</Button>
)}
</div>
</div>
</div>
@ -354,7 +543,286 @@ export default function SettingsPage() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="backoffice" className="space-y-4">
<Tabs defaultValue="dashboard" className="space-y-4">
<TabsList>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
<TabsTrigger value="plans">Plans</TabsTrigger>
<TabsTrigger value="stripe">Stripe</TabsTrigger>
<TabsTrigger value="system">System</TabsTrigger>
</TabsList>
<TabsContent value="dashboard" className="space-y-4">
{/* Stats Overview */}
{stats && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<span className="text-xs text-muted-foreground">$</span>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
<p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
<p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Companies</CardTitle>
<div className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalCompanies || 0}</div>
<p className="text-xs text-muted-foreground">Platform total</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">New (Month)</CardTitle>
<Plus className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+{stats.newCompaniesThisMonth || 0}</div>
<p className="text-xs text-muted-foreground">Since start of month</p>
</CardContent>
</Card>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle>Empresas pendentes</CardTitle>
<CardDescription>Aprovação e verificação de empresas.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Empresa</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.slice(0, 5).map((company) => (
<TableRow key={company.id}>
<TableCell className="font-medium">{company.name}</TableCell>
<TableCell>
{company.verified ? <Badge className="bg-green-500">Verificada</Badge> : <Badge variant="secondary">Pendente</Badge>}
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}>
<CheckCircle className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle>Auditoria Recente</CardTitle>
<CardDescription>Últimos acessos.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-8">
{audits.slice(0, 5).map((audit) => (
<div key={audit.id} className="flex items-center">
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">{audit.identifier}</p>
<p className="text-xs text-muted-foreground">{auditDateFormatter.format(new Date(audit.createdAt))}</p>
</div>
<div className="ml-auto font-medium text-xs text-muted-foreground">{audit.roles}</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="plans" className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}>
<Plus className="mr-2 h-4 w-4" /> Create Plan
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Plans Management</CardTitle>
<CardDescription>Configure subscription plans.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Monthly</TableHead>
<TableHead>Yearly</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{plans.map((plan) => (
<TableRow key={plan.id}>
<TableCell className="font-medium">{plan.name}</TableCell>
<TableCell>${plan.monthlyPrice}</TableCell>
<TableCell>${plan.yearlyPrice}</TableCell>
<TableCell className="text-right space-x-2">
<Button size="sm" variant="outline" onClick={() => { setEditingPlanId(plan.id); setPlanForm({ ...plan }); setIsPlanDialogOpen(true) }}>Edit</Button>
<Button size="sm" variant="destructive" onClick={() => handleDeletePlan(plan.id)}>Delete</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={isPlanDialogOpen} onOpenChange={setIsPlanDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingPlanId ? 'Edit Plan' : 'Create Plan'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Name</Label>
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} />
</div>
<div className="grid gap-2">
<Label>Description</Label>
<Input value={planForm.description} onChange={(e) => setPlanForm({ ...planForm, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Monthly Price</Label>
<Input type="number" value={planForm.monthlyPrice} onChange={(e) => setPlanForm({ ...planForm, monthlyPrice: e.target.value })} />
</div>
<div className="grid gap-2">
<Label>Yearly Price</Label>
<Input type="number" value={planForm.yearlyPrice} onChange={(e) => setPlanForm({ ...planForm, yearlyPrice: e.target.value })} />
</div>
</div>
<div className="grid gap-2">
<Label>Features (comma separated)</Label>
<Textarea value={Array.isArray(planForm.features) ? planForm.features.join(', ') : planForm.features} onChange={(e) => setPlanForm({ ...planForm, features: e.target.value })} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsPlanDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSavePlan}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="stripe" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Stripe Integration</CardTitle>
<CardDescription>Manage subscriptions and payments directly in Stripe Dashboard.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 border rounded bg-muted/20">
<p className="text-sm">
For security and advanced management (refunds, disputes, tax settings), please use the official Stripe Dashboard.
</p>
<div className="mt-4">
<a href="https://dashboard.stripe.com" target="_blank" rel="noreferrer">
<Button variant="outline">
Open Stripe Dashboard <ExternalLink className="ml-2 h-4 w-4" />
</Button>
</a>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>System & Caching</CardTitle>
<CardDescription>Maintenance tasks.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded">
<div>
<p className="font-medium">Cloudflare Cache</p>
<p className="text-sm text-muted-foreground">Purge all cached files from the edge.</p>
</div>
<Button variant="outline" onClick={handlePurgeCache}>Purge Cache</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tags Management</CardTitle>
</CardHeader>
<CardContent>
{/* Reusing existing Tags Table logic here if desired, or keep it in a sub-section */}
<div className="flex flex-col md:flex-row gap-4 mb-4">
<Input placeholder="New Tag" value={tagForm.name} onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })} />
<Select value={tagForm.category} onValueChange={(val: any) => setTagForm({ ...tagForm, category: val })}>
<SelectTrigger className="w-40"><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>
<SelectItem value="area">Area</SelectItem>
<SelectItem value="level">Level</SelectItem>
<SelectItem value="stack">Stack</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreateTag} disabled={creatingTag}><Plus className="mr-2 h-4 w-4" /> Add</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tag</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tags.map((tag) => (
<TableRow key={tag.id}>
<TableCell>{tag.name}</TableCell>
<TableCell>{tag.category}</TableCell>
<TableCell>{tag.active ? <Badge className="bg-green-500">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => handleToggleTag(tag)}>Toggle</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
<ConfirmModal
isOpen={!!deletePlanId}
onClose={() => setDeletePlanId(null)}
onConfirm={confirmDeletePlan}
title="Are you sure you want to delete this plan?"
description="This action cannot be undone."
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>

View file

@ -0,0 +1,58 @@
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
interface ConfirmModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
description?: string
cancelText?: string
confirmText?: string
isDestructive?: boolean
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
description,
cancelText = "Cancelar",
confirmText = "Confirmar",
isDestructive = true,
}: ConfirmModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription className="mt-2">{description}</DialogDescription>}
</DialogHeader>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={onClose}>
{cancelText}
</Button>
<Button
variant={isDestructive ? "destructive" : "default"}
onClick={() => {
onConfirm()
onClose()
}}
>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -40,11 +40,6 @@ const Sidebar = () => {
href: "/dashboard/companies",
icon: Building2,
},
{
title: t('sidebar.backoffice'),
href: "/dashboard/backoffice",
icon: FileText,
},
{
title: "Seeder",
href: "/dashboard/seeder",
@ -124,7 +119,6 @@ const Sidebar = () => {
items = isSuperadmin
? adminItems
: adminItems.filter(item =>
item.href !== "/dashboard/backoffice" &&
item.href !== "/dashboard/companies" &&
!('superadminOnly' in item && item.superadminOnly)
)