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:
parent
f22bd51c5d
commit
754acd9d3c
6 changed files with 635 additions and 577 deletions
|
|
@ -35,8 +35,6 @@ export class CredentialsService {
|
||||||
let cred = await this.credentialsRepo.findOne({ where: { serviceName } });
|
let cred = await this.credentialsRepo.findOne({ where: { serviceName } });
|
||||||
if (!cred) {
|
if (!cred) {
|
||||||
cred = this.credentialsRepo.create({ serviceName });
|
cred = this.credentialsRepo.create({ serviceName });
|
||||||
} else {
|
|
||||||
throw new BadRequestException('Credentials already configured for this service.');
|
|
||||||
}
|
}
|
||||||
cred.encryptedPayload = encrypted;
|
cred.encryptedPayload = encrypted;
|
||||||
cred.updatedBy = updatedBy;
|
cred.updatedBy = updatedBy;
|
||||||
|
|
|
||||||
94
docs/API.md
94
docs/API.md
|
|
@ -397,6 +397,100 @@ GET /docs/
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🔑 Module: System Credentials
|
||||||
|
|
||||||
|
Manage encrypted credentials for external services (Stripe, SMTP, Storage, etc.).
|
||||||
|
|
||||||
|
### List Configured Services
|
||||||
|
```http
|
||||||
|
GET /api/v1/system/credentials
|
||||||
|
```
|
||||||
|
| Field | Auth | Roles | Description |
|
||||||
|
|-------|------|-------|-------------|
|
||||||
|
| Protected | ✅ | superadmin, admin | List all configured services (metadata only, no secrets) |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"service_name": "stripe",
|
||||||
|
"updated_at": "2026-02-23T10:30:00Z",
|
||||||
|
"updated_by": "admin-uuid",
|
||||||
|
"is_configured": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_name": "smtp",
|
||||||
|
"updated_at": "2026-02-22T14:00:00Z",
|
||||||
|
"updated_by": "superadmin-uuid",
|
||||||
|
"is_configured": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Credentials
|
||||||
|
```http
|
||||||
|
POST /api/v1/system/credentials
|
||||||
|
```
|
||||||
|
| Field | Auth | Roles | Description |
|
||||||
|
|-------|------|-------|-------------|
|
||||||
|
| Protected | ✅ | superadmin, admin | Create or update service credentials |
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"serviceName": "stripe",
|
||||||
|
"payload": {
|
||||||
|
"secretKey": "sk_live_xxx",
|
||||||
|
"webhookSecret": "whsec_xxx",
|
||||||
|
"publishableKey": "pk_live_xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Credentials saved successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supported Services:**
|
||||||
|
| Service Name | Fields |
|
||||||
|
|--------------|--------|
|
||||||
|
| `stripe` | `secretKey`, `webhookSecret`, `publishableKey` |
|
||||||
|
| `smtp` | `host`, `port`, `username`, `password`, `from_email`, `from_name`, `secure` |
|
||||||
|
| `storage` | `endpoint`, `region`, `bucket`, `accessKey`, `secretKey` |
|
||||||
|
| `cloudflare_config` | `apiToken`, `zoneId` |
|
||||||
|
| `firebase` | `serviceAccountJson` (JSON string) |
|
||||||
|
| `appwrite` | `endpoint`, `projectId`, `apiKey` |
|
||||||
|
| `lavinmq` | `amqpUrl` |
|
||||||
|
| `cpanel` | `host`, `username`, `apiToken` |
|
||||||
|
|
||||||
|
### Delete Credentials
|
||||||
|
```http
|
||||||
|
DELETE /api/v1/system/credentials/{serviceName}
|
||||||
|
```
|
||||||
|
| Field | Auth | Roles | Description |
|
||||||
|
|-------|------|-------|-------------|
|
||||||
|
| Protected | ✅ | superadmin, admin | Delete service credentials |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Notes:**
|
||||||
|
- Credentials are encrypted with RSA-OAEP-SHA256 before storage
|
||||||
|
- Only metadata (service name, timestamps) is returned on list operations
|
||||||
|
- Actual secret values cannot be retrieved after saving
|
||||||
|
- Updating credentials overwrites the existing configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔑 Permission Matrix
|
## 🔑 Permission Matrix
|
||||||
|
|
||||||
| Route | Guest | JobSeeker | Recruiter | CompanyAdmin | Admin | SuperAdmin |
|
| Route | Guest | JobSeeker | Recruiter | CompanyAdmin | Admin | SuperAdmin |
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
|
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
|
||||||
import { toast } from "sonner"
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -21,6 +21,33 @@ import {
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
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 {
|
interface ThemeConfig {
|
||||||
logoUrl: string
|
logoUrl: string
|
||||||
|
|
@ -87,10 +114,6 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([fetchSettings(), fetchCredentials()]).finally(() => setLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSaveTheme = async () => {
|
const handleSaveTheme = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -222,12 +245,169 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... existing imports ...
|
const handleDeleteCredential = async (serviceName: string) => {
|
||||||
// Note: State definition needs update to object payload
|
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 [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||||
|
|
||||||
const isSelectedConfigured = selectedService ? configuredMap.has(selectedService) : false
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header ... */}
|
{/* Header ... */}
|
||||||
|
|
@ -237,10 +417,11 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Tabs defaultValue="integrations" className="space-y-4"> {/* Default to integrations for visibility */}
|
<Tabs defaultValue="integrations" className="space-y-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="theme">Branding & Theme</TabsTrigger>
|
<TabsTrigger value="theme">Branding & Theme</TabsTrigger>
|
||||||
<TabsTrigger value="integrations">Integrations & Credentials</TabsTrigger>
|
<TabsTrigger value="integrations">Integrations & Credentials</TabsTrigger>
|
||||||
|
<TabsTrigger value="backoffice">Backoffice</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Theme Tab Content (unchanged) */}
|
{/* Theme Tab Content (unchanged) */}
|
||||||
|
|
@ -306,7 +487,7 @@ export default function SettingsPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>External Services</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
|
@ -329,23 +510,31 @@ export default function SettingsPage() {
|
||||||
: 'Not configured'}
|
: 'Not configured'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{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"}
|
{testingConnection ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="flex-1"
|
||||||
onClick={() => handleOpenCredentialDialog(svc.service_name)}
|
onClick={() => handleOpenCredentialDialog(svc.service_name)}
|
||||||
disabled={svc.is_configured}
|
|
||||||
>
|
>
|
||||||
<Key className="w-3 h-3 mr-2" />
|
<Key className="w-3 h-3 mr-2" />
|
||||||
{svc.is_configured ? "Configured" : "Setup"}
|
{svc.is_configured ? "Edit" : "Setup"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{svc.is_configured && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteCredential(svc.service_name)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -354,7 +543,286 @@ export default function SettingsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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>
|
</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}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|
|
||||||
58
frontend/src/components/confirm-modal.tsx
Normal file
58
frontend/src/components/confirm-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -40,11 +40,6 @@ const Sidebar = () => {
|
||||||
href: "/dashboard/companies",
|
href: "/dashboard/companies",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('sidebar.backoffice'),
|
|
||||||
href: "/dashboard/backoffice",
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Seeder",
|
title: "Seeder",
|
||||||
href: "/dashboard/seeder",
|
href: "/dashboard/seeder",
|
||||||
|
|
@ -124,7 +119,6 @@ const Sidebar = () => {
|
||||||
items = isSuperadmin
|
items = isSuperadmin
|
||||||
? adminItems
|
? adminItems
|
||||||
: adminItems.filter(item =>
|
: adminItems.filter(item =>
|
||||||
item.href !== "/dashboard/backoffice" &&
|
|
||||||
item.href !== "/dashboard/companies" &&
|
item.href !== "/dashboard/companies" &&
|
||||||
!('superadminOnly' in item && item.superadminOnly)
|
!('superadminOnly' in item && item.superadminOnly)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue