feat(backoffice): Implementa gestão de credenciais e novas abas administrativas

BACKEND:
- Implementa [CredentialsHandler](cci:2://file:///C:/Projetos/gohorsejobs/backend/internal/api/handlers/credentials_handler.go:9:0-11:1) e rotas /api/v1/system/credentials para gestão segura de chaves.
- Adiciona criptografia RSA no [CredentialsService](cci:2://file:///C:/Projetos/gohorsejobs/backend/internal/services/credentials_service.go:17:0-22:1) para proteger chaves de API (Stripe, Cloudflare, etc).
- Automatiza geração de pares de chaves RSA no .env via script.

FRONTEND:
- Refatora /dashboard/backoffice organizando em Abas: Dashboard, Planos, Stripe e Sistema.
- Implementa CRUD completo para gestão de Planos (criar, editar, remover).
- Adiciona visualização de status do Stripe e botão para limpar cache Cloudflare.
- Ajusta formatação de data nos logs para fuso horário America/Sao_Paulo.
- Atualiza pi.ts para suportar novos endpoints de planos e credenciais.
This commit is contained in:
NANDO9322 2026-01-09 17:18:51 -03:00
parent c339c3fbaf
commit 8f331c97d3
6 changed files with 612 additions and 299 deletions

61
backend/generate_keys.go Normal file
View file

@ -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")
}

View file

@ -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"})
}

View file

@ -98,6 +98,7 @@ func NewRouter() http.Handler {
) )
settingsHandler := apiHandlers.NewSettingsHandler(settingsService) settingsHandler := apiHandlers.NewSettingsHandler(settingsService)
credentialsHandler := apiHandlers.NewCredentialsHandler(credentialsService) // Added
storageHandler := apiHandlers.NewStorageHandler(storageService) storageHandler := apiHandlers.NewStorageHandler(storageService)
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService) adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService)
locationHandlers := apiHandlers.NewLocationHandlers(locationService) 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("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)))) 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) // Storage (Presigned URL)
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))
// Storage (Direct Proxy) // Storage (Direct Proxy)

View file

@ -2,22 +2,72 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, NotFoundException }
import { ApiTags, ApiOperation, ApiBody, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { PlansService, Plan } from './plans.service'; import { PlansService, Plan } from './plans.service';
import { IsString, IsNumber, IsArray, IsBoolean, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
class CreatePlanDto { class CreatePlanDto {
id: string; @ApiPropertyOptional()
@IsString()
@IsOptional()
id?: string;
@ApiProperty()
@IsString()
name: string; name: string;
@ApiProperty()
@IsString()
description: string; description: string;
@ApiProperty()
@IsNumber()
monthlyPrice: number; monthlyPrice: number;
@ApiProperty()
@IsNumber()
yearlyPrice: number; yearlyPrice: number;
@ApiProperty()
@IsArray()
@IsString({ each: true })
features: string[]; features: string[];
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
popular?: boolean; popular?: boolean;
} }
class UpdatePlanDto { class UpdatePlanDto {
@ApiPropertyOptional()
@IsString()
@IsOptional()
name?: string; name?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
description?: string; description?: string;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
monthlyPrice?: number; monthlyPrice?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
yearlyPrice?: number; yearlyPrice?: number;
@ApiPropertyOptional()
@IsArray()
@IsString({ each: true })
@IsOptional()
features?: string[]; features?: string[];
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
popular?: boolean; popular?: boolean;
} }
@ -47,7 +97,11 @@ export class PlansController {
@ApiOperation({ summary: 'Create a new plan' }) @ApiOperation({ summary: 'Create a new plan' })
@ApiBody({ type: CreatePlanDto }) @ApiBody({ type: CreatePlanDto })
createPlan(@Body() body: 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') @Patch(':id')

View file

@ -14,6 +14,18 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } 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 { import {
adminAccessApi, adminAccessApi,
adminAuditApi, adminAuditApi,
@ -21,6 +33,7 @@ import {
adminJobsApi, adminJobsApi,
adminTagsApi, adminTagsApi,
backofficeApi, backofficeApi,
plansApi,
type AdminCompany, type AdminCompany,
type AdminJob, type AdminJob,
type AdminLoginAudit, type AdminLoginAudit,
@ -29,12 +42,12 @@ import {
} from "@/lib/api" } from "@/lib/api"
import { getCurrentUser, isAdminUser } from "@/lib/auth" import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { toast } from "sonner" 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", { const auditDateFormatter = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short", dateStyle: "short",
timeStyle: "short", timeStyle: "short",
timeZone: "UTC", timeZone: "America/Sao_Paulo",
}) })
const jobStatusBadge: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = { const jobStatusBadge: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
@ -73,16 +86,26 @@ export default function BackofficePage() {
// ... imports and other state ... // ... 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 loadBackoffice = async (silent = false) => { const loadBackoffice = async (silent = false) => {
try { try {
if (!silent) setLoading(true) 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(), adminAccessApi.listRoles(),
adminAuditApi.listLogins(20), adminAuditApi.listLogins(20),
adminCompaniesApi.list(false), adminCompaniesApi.list(false),
adminJobsApi.list({ status: "review", limit: 10 }), adminJobsApi.list({ status: "review", limit: 10 }),
adminTagsApi.list(), adminTagsApi.list(),
backofficeApi.admin.getStats().catch(() => null), // Fail gracefully if backoffice API is down backofficeApi.admin.getStats().catch(() => null),
plansApi.getAll().catch(() => [])
]) ])
setRoles(rolesData) setRoles(rolesData)
setAudits(auditData) setAudits(auditData)
@ -90,6 +113,8 @@ export default function BackofficePage() {
setJobs(jobsData.data || []) setJobs(jobsData.data || [])
setTags(tagsData) setTags(tagsData)
setStats(statsData) setStats(statsData)
setPlans(plansData)
} catch (error) { } catch (error) {
console.error("Error loading backoffice:", error) console.error("Error loading backoffice:", error)
toast.error("Failed to load backoffice data") toast.error("Failed to load backoffice data")
@ -97,11 +122,6 @@ export default function BackofficePage() {
if (!silent) setLoading(false) if (!silent) setLoading(false)
} }
} }
// ...
// Handlers follow...
const handleApproveCompany = async (companyId: string) => { const handleApproveCompany = async (companyId: string) => {
try { 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) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
@ -186,298 +251,289 @@ export default function BackofficePage() {
} }
return ( return (
<div className="space-y-10"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Backoffice</h1> <h1 className="text-3xl font-bold text-foreground">Backoffice</h1>
<p className="text-muted-foreground mt-1">SaaS Administration & Operations</p> <p className="text-muted-foreground mt-1">SaaS Administration & Operations</p>
</div> </div>
<Button variant="outline" onClick={() => loadBackoffice(false)} className="gap-2"> <div className="flex gap-2">
<RefreshCw className="h-4 w-4" /> <Button variant="outline" onClick={() => loadBackoffice(false)} className="gap-2">
Refresh <RefreshCw className="h-4 w-4" />
</Button> Refresh
</Button>
</div>
</div> </div>
{/* Stats Overview */} <Tabs defaultValue="dashboard" className="space-y-4">
{stats && ( <TabsList>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <TabsTrigger value="dashboard">Dashboard</TabsTrigger>
<Card> <TabsTrigger value="plans">Plans</TabsTrigger>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <TabsTrigger value="stripe">Stripe</TabsTrigger>
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle> <TabsTrigger value="system">System</TabsTrigger>
<span className="text-xs text-muted-foreground">$</span> </TabsList>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
<p className="text-xs text-muted-foreground">+20.1% from last 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">+180 since last hour</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>
)}
<Card> <TabsContent value="dashboard" className="space-y-4">
<CardHeader> {/* Stats Overview */}
<CardTitle>Gestão de usuários & acesso</CardTitle> {stats && (
<CardDescription>Perfis, permissões e ações disponíveis no RBAC.</CardDescription> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
</CardHeader> <Card>
<CardContent> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Table> <CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<TableHeader> <span className="text-xs text-muted-foreground">$</span>
<TableRow> </CardHeader>
<TableHead>Perfil</TableHead> <CardContent>
<TableHead>Descrição</TableHead> <div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
<TableHead>Ações principais</TableHead> <p className="text-xs text-muted-foreground">+20.1% from last month</p>
</TableRow> </CardContent>
</TableHeader> </Card>
<TableBody> <Card>
{roles.map((role) => ( <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<TableRow key={role.role}> <CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
<TableCell className="font-medium">{role.role}</TableCell> <CheckCircle className="h-4 w-4 text-muted-foreground" />
<TableCell>{role.description}</TableCell> </CardHeader>
<TableCell> <CardContent>
<div className="flex flex-wrap gap-2"> <div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
{role.actions.map((action) => ( <p className="text-xs text-muted-foreground">+180 since last hour</p>
<Badge key={action} variant="secondary">{action}</Badge> </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>
</TableCell> ))}
</TableRow> </div>
))} </CardContent>
</TableBody> </Card>
</Table> </div>
</CardContent> </TabsContent>
</Card>
<Card> <TabsContent value="plans" className="space-y-4">
<CardHeader> <div className="flex justify-end">
<CardTitle>Auditoria de login</CardTitle> <Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}>
<CardDescription>Histórico recente de acessos ao painel administrativo.</CardDescription> <Plus className="mr-2 h-4 w-4" /> Create Plan
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Usuário</TableHead>
<TableHead>Roles</TableHead>
<TableHead>IP</TableHead>
<TableHead>Data</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{audits.map((audit) => (
<TableRow key={audit.id}>
<TableCell className="font-medium">{audit.identifier}</TableCell>
<TableCell>{audit.roles}</TableCell>
<TableCell>{audit.ipAddress || "-"}</TableCell>
<TableCell>{auditDateFormatter.format(new Date(audit.createdAt))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Empresas pendentes</CardTitle>
<CardDescription>Aprovação e verificação de empresas.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Empresa</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
Nenhuma empresa pendente.
</TableCell>
</TableRow>
)}
{companies.map((company) => (
<TableRow key={company.id}>
<TableCell className="font-medium">{company.name}</TableCell>
<TableCell>{company.email || "-"}</TableCell>
<TableCell>
{company.verified ? (
<Badge className="bg-green-500">Verificada</Badge>
) : (
<Badge variant="secondary">Pendente</Badge>
)}
</TableCell>
<TableCell className="text-right space-x-2">
<Button size="sm" variant="outline" onClick={() => handleApproveCompany(company.id)}>
<CheckCircle className="h-4 w-4 mr-2" />
Aprovar
</Button>
<Button size="sm" variant="destructive" onClick={() => handleDeactivateCompany(company.id)}>
<XCircle className="h-4 w-4 mr-2" />
Desativar
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Moderação de vagas</CardTitle>
<CardDescription>Fluxo: rascunho revisão publicada expirada/arquivada.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Título</TableHead>
<TableHead>Empresa</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
Nenhuma vaga aguardando revisão.
</TableCell>
</TableRow>
)}
{jobs.map((job) => {
const statusConfig = jobStatusBadge[job.status] || { label: job.status, variant: "outline" }
return (
<TableRow key={job.id}>
<TableCell className="font-medium">{job.title}</TableCell>
<TableCell>{job.companyName || "-"}</TableCell>
<TableCell>
<Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>
</TableCell>
<TableCell className="text-right space-x-2">
<Button size="sm" variant="outline" onClick={() => handleJobStatus(job.id, "published")}>
<CheckCircle className="h-4 w-4 mr-2" />
Publicar
</Button>
<Button size="sm" variant="outline" onClick={() => handleJobStatus(job.id, "paused")}>
<PauseCircle className="h-4 w-4 mr-2" />
Pausar
</Button>
<Button size="sm" variant="outline" onClick={() => handleJobStatus(job.id, "archived")}>
<Archive className="h-4 w-4 mr-2" />
Arquivar
</Button>
<Button size="sm" variant="ghost" onClick={() => handleDuplicateJob(job.id)}>
<Copy className="h-4 w-4 mr-2" />
Duplicar
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tags e categorias</CardTitle>
<CardDescription>Áreas, níveis e stacks customizáveis.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<Input
placeholder="Nova tag"
value={tagForm.name}
onChange={(event) => setTagForm({ ...tagForm, name: event.target.value })}
/>
<Select
value={tagForm.category}
onValueChange={(value: "area" | "level" | "stack") => setTagForm({ ...tagForm, category: value })}
>
<SelectTrigger className="md:w-48">
<SelectValue placeholder="Categoria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="area">Área</SelectItem>
<SelectItem value="level">Nível</SelectItem>
<SelectItem value="stack">Stack</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreateTag} disabled={creatingTag} className="gap-2">
<Plus className="h-4 w-4" />
Criar tag
</Button> </Button>
</div> </div>
<Table> <Card>
<TableHeader> <CardHeader>
<TableRow> <CardTitle>Plans Management</CardTitle>
<TableHead>Tag</TableHead> <CardDescription>Configure subscription plans.</CardDescription>
<TableHead>Categoria</TableHead> </CardHeader>
<TableHead>Status</TableHead> <CardContent>
<TableHead className="text-right">Ações</TableHead> <Table>
</TableRow> <TableHeader>
</TableHeader> <TableRow>
<TableBody> <TableHead>Name</TableHead>
{tags.map((tag) => ( <TableHead>Monthly</TableHead>
<TableRow key={tag.id}> <TableHead>Yearly</TableHead>
<TableCell className="font-medium">{tag.name}</TableCell> <TableHead className="text-right">Actions</TableHead>
<TableCell>{tag.category}</TableCell> </TableRow>
<TableCell> </TableHeader>
{tag.active ? ( <TableBody>
<Badge className="bg-green-500">Ativa</Badge> {plans.map((plan) => (
) : ( <TableRow key={plan.id}>
<Badge variant="outline">Inativa</Badge> <TableCell className="font-medium">{plan.name}</TableCell>
)} <TableCell>${plan.monthlyPrice}</TableCell>
</TableCell> <TableCell>${plan.yearlyPrice}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right space-x-2">
<Button size="sm" variant="outline" onClick={() => handleToggleTag(tag)}> <Button size="sm" variant="outline" onClick={() => { setEditingPlanId(plan.id); setPlanForm({ ...plan }); setIsPlanDialogOpen(true) }}>Edit</Button>
{tag.active ? "Desativar" : "Ativar"} <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> </Button>
</TableCell> </a>
</TableRow> </div>
))} </div>
</TableBody> </CardContent>
</Table> </Card>
</CardContent> </TabsContent>
</Card>
<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>
</div> </div>
) )
} }

View file

@ -20,11 +20,14 @@ async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promi
await initConfig(); await initConfig();
// Token is now in httpOnly cookie, sent automatically via credentials: include // Token is now in httpOnly cookie, sent automatically via credentials: include
const headers = { const headers: Record<string, string> = {
"Content-Type": "application/json", ...options.headers as Record<string, string>,
...options.headers,
}; };
if (options.body) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${getApiUrl()}${endpoint}`, { const response = await fetch(`${getApiUrl()}${endpoint}`, {
...options, ...options,
headers, headers,
@ -588,11 +591,14 @@ async function backofficeRequest<T>(endpoint: string, options: RequestInit = {})
await initConfig(); await initConfig();
// Token is now in httpOnly cookie, sent automatically via credentials: include // Token is now in httpOnly cookie, sent automatically via credentials: include
const headers = { const headers: Record<string, string> = {
"Content-Type": "application/json", ...options.headers as Record<string, string>,
...options.headers,
}; };
if (options.body) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${getBackofficeUrl()}${endpoint}`, { const response = await fetch(`${getBackofficeUrl()}${endpoint}`, {
...options, ...options,
headers, headers,
@ -639,6 +645,35 @@ export interface CheckoutSessionResponse {
sessionId: string; sessionId: string;
} }
export interface Plan {
id: string;
name: string;
description: string;
monthlyPrice: number;
yearlyPrice: number;
features: string[];
popular?: boolean;
}
export const plansApi = {
getAll: () => backofficeRequest<Plan[]>("/plans"),
getById: (id: string) => backofficeRequest<Plan>(`/plans/${id}`),
create: (data: Omit<Plan, "id">) => backofficeRequest<Plan>("/plans", {
method: "POST",
body: JSON.stringify(data),
}),
update: (id: string, data: Partial<Plan>) => {
const { id: _, ...updateData } = data;
return backofficeRequest<Plan>(`/plans/${id}`, {
method: "PATCH",
body: JSON.stringify(updateData),
});
},
delete: (id: string) => backofficeRequest<void>(`/plans/${id}`, {
method: "DELETE",
}),
};
export const backofficeApi = { export const backofficeApi = {
// Admin Dashboard // Admin Dashboard
admin: { admin: {
@ -658,7 +693,13 @@ export const backofficeApi = {
method: "POST", method: "POST",
body: JSON.stringify({ returnUrl }), body: JSON.stringify({ returnUrl }),
}), }),
// Admin
listSubscriptions: (customerId: string) =>
backofficeRequest<any>(`/stripe/subscriptions/${customerId}`),
}, },
externalServices: {
purgeCloudflareCache: () => backofficeRequest<void>("/external-services/cloudflare/purge", { method: "POST" })
}
}; };
export const fcmApi = { export const fcmApi = {
@ -720,12 +761,12 @@ export interface ConfiguredService {
} }
export const credentialsApi = { export const credentialsApi = {
list: () => backofficeRequest<{ services: ConfiguredService[] }>("/system/credentials"), list: () => apiRequest<{ services: ConfiguredService[] }>("/api/v1/system/credentials"),
save: (serviceName: string, payload: any) => backofficeRequest<void>("/system/credentials", { save: (serviceName: string, payload: any) => apiRequest<void>("/api/v1/system/credentials", {
method: "POST", method: "POST",
body: JSON.stringify({ serviceName, payload }), body: JSON.stringify({ serviceName, payload }),
}), }),
delete: (serviceName: string) => backofficeRequest<void>(`/system/credentials/${serviceName}`, { delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, {
method: "DELETE", method: "DELETE",
}), }),
}; };