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:
parent
c339c3fbaf
commit
8f331c97d3
6 changed files with 612 additions and 299 deletions
61
backend/generate_keys.go
Normal file
61
backend/generate_keys.go
Normal 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")
|
||||
}
|
||||
95
backend/internal/api/handlers/credentials_handler.go
Normal file
95
backend/internal/api/handlers/credentials_handler.go
Normal 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"})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
|
|
@ -73,16 +86,26 @@ export default function BackofficePage() {
|
|||
|
||||
// ... 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) => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
|
|
@ -186,19 +251,29 @@ export default function BackofficePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
|
||||
<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">
|
||||
|
|
@ -245,69 +320,8 @@ export default function BackofficePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de usuários & acesso</CardTitle>
|
||||
<CardDescription>Perfis, permissões e ações disponíveis no RBAC.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Perfil</TableHead>
|
||||
<TableHead>Descrição</TableHead>
|
||||
<TableHead>Ações principais</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.role}>
|
||||
<TableCell className="font-medium">{role.role}</TableCell>
|
||||
<TableCell>{role.description}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{role.actions.map((action) => (
|
||||
<Badge key={action} variant="secondary">{action}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Auditoria de login</CardTitle>
|
||||
<CardDescription>Histórico recente de acessos ao painel administrativo.</CardDescription>
|
||||
</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>
|
||||
<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>
|
||||
|
|
@ -317,38 +331,20 @@ export default function BackofficePage() {
|
|||
<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) => (
|
||||
{companies.slice(0, 5).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>
|
||||
)}
|
||||
{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
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -357,120 +353,58 @@ export default function BackofficePage() {
|
|||
</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>Moderação de vagas</CardTitle>
|
||||
<CardDescription>Fluxo: rascunho → revisão → publicada → expirada/arquivada.</CardDescription>
|
||||
<CardTitle>Plans Management</CardTitle>
|
||||
<CardDescription>Configure subscription plans.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Título</TableHead>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Monthly</TableHead>
|
||||
<TableHead>Yearly</TableHead>
|
||||
<TableHead className="text-right">Actions</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>
|
||||
{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={() => 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>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Categoria</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tags.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell className="font-medium">{tag.name}</TableCell>
|
||||
<TableCell>{tag.category}</TableCell>
|
||||
<TableCell>
|
||||
{tag.active ? (
|
||||
<Badge className="bg-green-500">Ativa</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Inativa</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => handleToggleTag(tag)}>
|
||||
{tag.active ? "Desativar" : "Ativar"}
|
||||
</Button>
|
||||
<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>
|
||||
))}
|
||||
|
|
@ -478,6 +412,128 @@ export default function BackofficePage() {
|
|||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,14 @@ async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promi
|
|||
await initConfig();
|
||||
|
||||
// Token is now in httpOnly cookie, sent automatically via credentials: include
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
if (options.body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(`${getApiUrl()}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
|
|
@ -588,11 +591,14 @@ async function backofficeRequest<T>(endpoint: string, options: RequestInit = {})
|
|||
await initConfig();
|
||||
|
||||
// Token is now in httpOnly cookie, sent automatically via credentials: include
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
if (options.body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(`${getBackofficeUrl()}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
|
|
@ -639,6 +645,35 @@ export interface CheckoutSessionResponse {
|
|||
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 = {
|
||||
// Admin Dashboard
|
||||
admin: {
|
||||
|
|
@ -658,7 +693,13 @@ export const backofficeApi = {
|
|||
method: "POST",
|
||||
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 = {
|
||||
|
|
@ -720,12 +761,12 @@ export interface ConfiguredService {
|
|||
}
|
||||
|
||||
export const credentialsApi = {
|
||||
list: () => backofficeRequest<{ services: ConfiguredService[] }>("/system/credentials"),
|
||||
save: (serviceName: string, payload: any) => backofficeRequest<void>("/system/credentials", {
|
||||
list: () => apiRequest<{ services: ConfiguredService[] }>("/api/v1/system/credentials"),
|
||||
save: (serviceName: string, payload: any) => apiRequest<void>("/api/v1/system/credentials", {
|
||||
method: "POST",
|
||||
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",
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue