diff --git a/TODO.md b/TODO.md index eb9d6cc..0f465e8 100644 --- a/TODO.md +++ b/TODO.md @@ -1231,6 +1231,62 @@ novo) (agente.md secao 29, 56, 126, 141, 168) - [ ] `Plan.key` não pode ser editado depois de criado (só os limites) — não implementado, `key` normalmente não deveria mudar mesmo +## PHASE 34 — Platform: Sistema > Usuários/Auditoria, Infraestrutura > +Saúde (agente.md secao 148, 150-151, 168, 187) +- [x] `GET /platform/users` (novo) — lista TODOS os usuários da + plataforma, cross-tenant; `users` não tem RLS (identidade global, + secao 148), consulta direta sem `withTenantContext`. + `PATCH /platform/users/:id/status` desabilita/reativa (`login()` + já checava `status === "ACTIVE"` desde a PHASE 04 — o toggle tem + efeito real, não é só cosmético). Botão desabilitado na UI pra + qualquer usuário com role de plataforma, de propósito (evita se + trancar fora sem querer). +- [x] `GET /platform/audit-log` (novo) — últimos 200 eventos de + `audit_logs` (também sem RLS, linha imutável precisa sobreviver ao + tenant), nomes de usuário/tenant resolvidos numa segunda consulta + em lote. +- [x] `GET /platform/health` (novo) — Postgres/Redis (reusa a mesma + lógica de `/health/ready`) + FreeSWITCH via uma conexão ESL avulsa + (connect → espera até 2.5s → desconecta, sem manter estado — `apps/ + api` não tem uma conexão ESL permanente como fs-events/fs-config). +- [x] **Achado de arquitetura, não um bug**: o check de FreeSWITCH + **sempre falha** neste ambiente — `apps/api` roda no host, e a + porta 8021 (Event Socket) é deliberadamente não publicada pro host + (secao 184, decisão da PHASE 01/05). Confirmado com `nc -zv + localhost 8021` → connection refused, apesar do container + `b2bcall-freeswitch` estar `healthy`. Documentado explicitamente na + própria tela (`/platform/infraestrutura/saude`) em vez de deixar + parecer que algo está quebrado — os serviços que falam com o + FreeSWITCH de verdade (fs-events/fs-config/predictive-dialer) rodam + dentro da rede Docker e não têm esse problema. +- [x] Frontend: `/platform/sistema/usuarios` (lista+busca+toggle), + `/platform/sistema/auditoria` (lista+busca, somente leitura), + `/platform/infraestrutura/saude` (3 cards com latência + botão + "verificar de novo"). +- [x] Testado ponta a ponta contra a API real: os 3 usuários reais da + plataforma (Beta Corp, Acme, Platform Super Admin) listados + corretamente com tipo/status certos; audit log mostrando eventos + reais (LOGIN, LOGIN_FAILED, TENANT_CREATE) desta própria sessão de + testes, inclusive uma referência órfã a um usuário apagado numa + sessão anterior (fallback pro UUID cru confirmado, comportamento + correto de audit trail imutável); saúde mostrando Postgres/Redis OK + e FreeSWITCH "Falhou" com a explicação certa. Smoke test de + regressão nas 19 telas do tenant + tarifas, todas 200. +- [ ] "Sistema > Permissões" e "> Configurações" continuam "em breve" — + Permissões teria só leitura útil por ora (RBAC é system-defined, + sem UI de criar role customizada ainda); Configurações nunca teve + escopo definido na especificação além do nome +- [ ] "Infraestrutura > FreeSWITCH/SIP Profiles/Nodes" continuam "em + breve" — precisam de introspecção real via ESL + (`getChannels`/`getRegistrations`/`getGateways`/`getQueues` da + `TelephonyProvider`, todos tipados como `unknown` ainda) exposta + por endpoint; mais arriscado que o check de saúde (conexão avulsa + × dado estruturado de verdade), não tentado nesta fase +- [ ] "IA > Providers/Modelos/Uso/Custos" (visão de plataforma) continuam + "em breve" — o tenant já tem BYOK completo (PHASE 31); a versão + platform-wide (ver GLOBAL, agregar uso/custo entre tenants) não foi + construída ainda + --- ## Riscos conhecidos diff --git a/apps/api/src/platform/platform-audit.controller.ts b/apps/api/src/platform/platform-audit.controller.ts new file mode 100644 index 0000000..db4a63e --- /dev/null +++ b/apps/api/src/platform/platform-audit.controller.ts @@ -0,0 +1,56 @@ +import { Controller, ForbiddenException, Get, Query, UseGuards } from "@nestjs/common"; +import { getPrismaClient } from "@b2bcall/database"; +import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; + +/** + * "Sistema > Auditoria" (agente.md secao 150-151, 168) — `audit_logs` não + * tem RLS (linha imutável de auditoria, precisa sobreviver mesmo que o + * tenant seja apagado — decisão do schema desde a PHASE 04), então uma + * consulta cross-tenant direta é segura aqui; só platform admin acessa + * este endpoint (um tenant admin vê o próprio audit trail por outro + * caminho, se/quando existir). + */ +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("platform/audit-log") +export class PlatformAuditController { + @RequirePermission("audit.view") + @Get() + async list( + @CurrentUser() user: AccessTokenClaims, + @Query("action") action?: string, + @Query("tenantId") tenantId?: string, + ): Promise[]> { + if (!(await isPlatformUser(user.sub))) { + throw new ForbiddenException("So' um usuario com role de plataforma pode ver o audit log da plataforma"); + } + const prisma = getPrismaClient(); + + const entries = await prisma.auditLog.findMany({ + where: { + ...(action ? { action: { contains: action, mode: "insensitive" } } : {}), + ...(tenantId ? { tenantId } : {}), + }, + orderBy: { createdAt: "desc" }, + take: 200, + }); + + const userIds = Array.from(new Set(entries.map((e) => e.userId).filter((id): id is string => id != null))); + const tenantIds = Array.from(new Set(entries.map((e) => e.tenantId).filter((id): id is string => id != null))); + const [users, tenants] = await Promise.all([ + userIds.length ? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true } }) : [], + tenantIds.length ? prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, legalName: true } }) : [], + ]); + const userEmail = new Map(users.map((u) => [u.id, u.email])); + const tenantName = new Map(tenants.map((t) => [t.id, t.legalName])); + + return entries.map((e) => ({ + ...e, + userEmail: e.userId ? (userEmail.get(e.userId) ?? e.userId) : null, + tenantName: e.tenantId ? (tenantName.get(e.tenantId) ?? e.tenantId) : null, + })); + } +} diff --git a/apps/api/src/platform/platform-health.controller.ts b/apps/api/src/platform/platform-health.controller.ts new file mode 100644 index 0000000..36e4957 --- /dev/null +++ b/apps/api/src/platform/platform-health.controller.ts @@ -0,0 +1,65 @@ +import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common"; +import { getPrismaClient } from "@b2bcall/database"; +import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth"; +import { FreeSwitchTelephonyProvider } from "@b2bcall/telephony"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { getRedisClient } from "../common/redis"; + +type CheckStatus = "ok" | "fail"; + +async function timed(fn: () => Promise): Promise<{ status: CheckStatus; latencyMs: number }> { + const start = Date.now(); + try { + await fn(); + return { status: "ok", latencyMs: Date.now() - start }; + } catch { + return { status: "fail", latencyMs: Date.now() - start }; + } +} + +/** + * "Infraestrutura > Saúde" (agente.md secao 168, 187). `apps/api` não + * mantém uma conexão ESL permanente (isso é trabalho de fs-events/fs- + * config/predictive-dialer, secao 21) — pra não adicionar esse estado só + * pra um health check de tela de admin, abre uma conexão ESL avulsa, + * espera conectar, e derruba. Aceitável aqui: endpoint de baixo tráfego, + * chamado por um humano olhando a tela, não um hot path. + */ +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("platform/health") +export class PlatformHealthController { + @RequirePermission("freeswitch.view") + @Get() + async check(@CurrentUser() user: AccessTokenClaims) { + if (!(await isPlatformUser(user.sub))) { + throw new ForbiddenException("So' um usuario com role de plataforma pode ver a saude da infraestrutura"); + } + + const [postgres, redis, freeswitch] = await Promise.all([ + timed(() => getPrismaClient().$queryRaw`SELECT 1`), + timed(async () => { + await getRedisClient().ping(); + }), + timed(async () => { + const host = process.env.ESL_HOST; + const port = Number(process.env.ESL_PORT ?? 8021); + const password = process.env.ESL_PASSWORD; + if (!host || !password) throw new Error("ESL nao configurado"); + + const provider = new FreeSwitchTelephonyProvider({ host, port, password }); + try { + provider.connect(); + const connected = await provider.waitUntilConnected(2500); + if (!connected) throw new Error("timeout conectando no ESL"); + } finally { + await provider.disconnect(); + } + }), + ]); + + return { postgres, redis, freeswitch, checkedAt: new Date().toISOString() }; + } +} diff --git a/apps/api/src/platform/platform-users.controller.ts b/apps/api/src/platform/platform-users.controller.ts new file mode 100644 index 0000000..9aab83c --- /dev/null +++ b/apps/api/src/platform/platform-users.controller.ts @@ -0,0 +1,72 @@ +import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, UseGuards } from "@nestjs/common"; +import { IsIn } from "class-validator"; +import { getPrismaClient } from "@b2bcall/database"; +import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; + +class UpdateUserStatusDto { + @IsIn(["ACTIVE", "DISABLED"]) + status!: "ACTIVE" | "DISABLED"; +} + +/** + * "Sistema > Usuários" (agente.md secao 168) — visão cross-tenant, só + * platform admin. `users` não tem RLS (identidade global, secao 148), + * então lida direto com `getPrismaClient()`; as memberships de cada + * usuário (que tenants ele pertence) É que têm RLS — resolvidas uma por + * uma via `withTenantContext`, mesmo padrão já usado em + * `TenantsController.list` pro `memberCount`. + */ +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("platform/users") +export class PlatformUsersController { + @RequirePermission("users.manage") + @Get() + async list(@CurrentUser() user: AccessTokenClaims) { + if (!(await isPlatformUser(user.sub))) { + throw new ForbiddenException("So' um usuario com role de plataforma pode listar usuarios da plataforma"); + } + const prisma = getPrismaClient(); + + const [users, platformAdmins] = await Promise.all([ + prisma.user.findMany({ + where: { deletedAt: null }, + select: { id: true, email: true, name: true, status: true, mustChangePassword: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + }), + prisma.userRole.findMany({ where: { tenantId: null, role: { key: "platform_super_admin" } }, select: { userId: true } }), + ]); + + const platformAdminIds = new Set(platformAdmins.map((r) => r.userId)); + + return users.map((u) => ({ ...u, isPlatformUser: platformAdminIds.has(u.id) })); + } + + @RequirePermission("users.manage") + @Patch(":id/status") + async updateStatus(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateUserStatusDto) { + if (!(await isPlatformUser(user.sub))) { + throw new ForbiddenException("So' um usuario com role de plataforma pode desabilitar usuarios"); + } + const prisma = getPrismaClient(); + + const existing = await prisma.user.findFirst({ where: { id, deletedAt: null } }); + if (!existing) throw new NotFoundException(); + + const updated = await prisma.user.update({ where: { id }, data: { status: dto.status } }); + + await recordAuditEvent(prisma, { + action: "USER_STATUS_UPDATE", + tenantId: null, + userId: user.sub, + entityType: "user", + entityId: id, + after: { status: dto.status }, + }); + + return { id: updated.id, status: updated.status }; + } +} diff --git a/apps/api/src/platform/platform.module.ts b/apps/api/src/platform/platform.module.ts index bd1b709..7a438c1 100644 --- a/apps/api/src/platform/platform.module.ts +++ b/apps/api/src/platform/platform.module.ts @@ -1,7 +1,10 @@ import { Module } from "@nestjs/common"; import { PlatformOverviewController } from "./platform-overview.controller"; +import { PlatformUsersController } from "./platform-users.controller"; +import { PlatformAuditController } from "./platform-audit.controller"; +import { PlatformHealthController } from "./platform-health.controller"; @Module({ - controllers: [PlatformOverviewController], + controllers: [PlatformOverviewController, PlatformUsersController, PlatformAuditController, PlatformHealthController], }) export class PlatformModule {} diff --git a/apps/frontend/.impeccable/review/platform-auditoria-desktop.png b/apps/frontend/.impeccable/review/platform-auditoria-desktop.png new file mode 100644 index 0000000..9560edd Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-auditoria-desktop.png differ diff --git a/apps/frontend/.impeccable/review/platform-saude-desktop.png b/apps/frontend/.impeccable/review/platform-saude-desktop.png new file mode 100644 index 0000000..16c31eb Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-saude-desktop.png differ diff --git a/apps/frontend/.impeccable/review/platform-usuarios-desktop.png b/apps/frontend/.impeccable/review/platform-usuarios-desktop.png new file mode 100644 index 0000000..58f4c5a Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-usuarios-desktop.png differ diff --git a/apps/frontend/src/app/platform/infraestrutura/saude/page.tsx b/apps/frontend/src/app/platform/infraestrutura/saude/page.tsx new file mode 100644 index 0000000..5c00c27 --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/saude/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { PlatformHealth } from "@/lib/platform-types"; +import { SaudeView } from "./saude-view"; + +export default async function SaudePage() { + const session = await requireSession(); + const health = await apiFetch("/platform/health", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/infraestrutura/saude/saude-view.tsx b/apps/frontend/src/app/platform/infraestrutura/saude/saude-view.tsx new file mode 100644 index 0000000..faa250a --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/saude/saude-view.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { CircleCheck, CircleX, Database, Phone, RefreshCw, Server } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { formatDateTime } from "@/lib/format"; +import type { HealthCheck, PlatformHealth } from "@/lib/platform-types"; + +const CHECKS: { key: keyof Omit; label: string; icon: typeof Database }[] = [ + { key: "postgres", label: "PostgreSQL", icon: Database }, + { key: "redis", label: "Redis", icon: Server }, + { key: "freeswitch", label: "FreeSWITCH (ESL)", icon: Phone }, +]; + +export function SaudeView({ health }: { health: PlatformHealth }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + return ( +
+
+
+

Saúde da infraestrutura

+

+ Verificação ao vivo (agente.md secao 187) — checada agora, não um monitoramento contínuo. Última + checagem: {formatDateTime(health.checkedAt)}. +

+
+ +
+ +
+ {CHECKS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ + + +

+ apps/api roda direto no host desta VM, fora do Docker; a porta + do Event Socket (8021) do FreeSWITCH é deliberadamente não publicada no host (agente.md + secao 184: porta sensível, nunca exposta). Por isso este check falha mesmo com o FreeSWITCH saudável — o + container está isolado do jeito certo. Os serviços que realmente falam com o FreeSWITCH + (fs-events, fs-config + , predictive-dialer) rodam dentro da mesma rede Docker e não + têm esse problema. +

+
+
+ ); +} + +function CheckCard({ label, icon: Icon, check }: { label: string; icon: typeof Database; check: HealthCheck }) { + const ok = check.status === "ok"; + return ( +
+
+ + + {label} + + {ok ? : } +
+
+ {ok ? "OK" : "Falhou"} +

{check.latencyMs}ms

+
+
+ ); +} diff --git a/apps/frontend/src/app/platform/sistema/auditoria/auditoria-view.tsx b/apps/frontend/src/app/platform/sistema/auditoria/auditoria-view.tsx new file mode 100644 index 0000000..284afaf --- /dev/null +++ b/apps/frontend/src/app/platform/sistema/auditoria/auditoria-view.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ScrollText, Search } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Input } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatDateTime } from "@/lib/format"; +import type { AuditLogEntry } from "@/lib/platform-types"; + +export function AuditoriaView({ entries }: { entries: AuditLogEntry[] }) { + const [query, setQuery] = useState(""); + + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return entries; + return entries.filter( + (e) => + e.action.toLowerCase().includes(q) || + (e.userEmail ?? "").toLowerCase().includes(q) || + (e.tenantName ?? "").toLowerCase().includes(q) || + (e.entityType ?? "").toLowerCase().includes(q), + ); + }, [entries, query]); + + return ( +
+
+

Auditoria

+

+ Últimos 200 eventos de todos os tenants (agente.md secao 150-151) — toda ação de escrita relevante grava + uma linha aqui, nunca editada nem apagada. +

+
+ + + +
+
+ + setQuery(e.target.value)} + placeholder="Buscar ação, usuário, tenant…" + className="pl-8" + aria-label="Buscar no audit log" + /> +
+
+ {rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {rows.map((e) => ( + + + + + + + + ))} + +
QuandoAçãoUsuárioTenantEntidade
{formatDateTime(e.createdAt)} + + + {e.action} + + {e.userEmail ?? "—"}{e.tenantName ? {e.tenantName} : "—"}{e.entityType ?? "—"}
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/app/platform/sistema/auditoria/page.tsx b/apps/frontend/src/app/platform/sistema/auditoria/page.tsx new file mode 100644 index 0000000..bd5b278 --- /dev/null +++ b/apps/frontend/src/app/platform/sistema/auditoria/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { AuditLogEntry } from "@/lib/platform-types"; +import { AuditoriaView } from "./auditoria-view"; + +export default async function AuditoriaPage() { + const session = await requireSession(); + const entries = await apiFetch("/platform/audit-log", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/sistema/usuarios/actions.ts b/apps/frontend/src/app/platform/sistema/usuarios/actions.ts new file mode 100644 index 0000000..5b33115 --- /dev/null +++ b/apps/frontend/src/app/platform/sistema/usuarios/actions.ts @@ -0,0 +1,29 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED"): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/platform/users/${id}/status`, session.accessToken, { method: "PATCH", body: JSON.stringify({ status }) }); + revalidatePath("/platform/sistema/usuarios"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/platform/sistema/usuarios/page.tsx b/apps/frontend/src/app/platform/sistema/usuarios/page.tsx new file mode 100644 index 0000000..210c919 --- /dev/null +++ b/apps/frontend/src/app/platform/sistema/usuarios/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { PlatformUser } from "@/lib/platform-types"; +import { UsuariosView } from "./usuarios-view"; + +export default async function UsuariosPage() { + const session = await requireSession(); + const users = await apiFetch("/platform/users", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/sistema/usuarios/usuarios-view.tsx b/apps/frontend/src/app/platform/sistema/usuarios/usuarios-view.tsx new file mode 100644 index 0000000..8927135 --- /dev/null +++ b/apps/frontend/src/app/platform/sistema/usuarios/usuarios-view.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Search, ShieldCheck, User as UserIcon } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatDateTime } from "@/lib/format"; +import type { PlatformUser } from "@/lib/platform-types"; +import { updateUserStatus } from "./actions"; + +export function UsuariosView({ users }: { users: PlatformUser[] }) { + const [query, setQuery] = useState(""); + + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return users; + return users.filter((u) => u.email.toLowerCase().includes(q) || u.name.toLowerCase().includes(q)); + }, [users, query]); + + return ( +
+
+

Usuários

+

+ Todos os usuários da plataforma, cross-tenant (agente.md secao 148, 168) — criar usuário novo continua só + junto com um tenant (Clientes > Tenants) ou via a tela do próprio tenant; aqui só visão + desabilitar. +

+
+ + + +
+
+ + setQuery(e.target.value)} placeholder="Buscar usuário…" className="pl-8" aria-label="Buscar usuário" /> +
+
+ {rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {rows.map((u) => ( + + ))} + +
NomeE-mailTipoStatusCriado + Ações +
+ )} +
+
+ ); +} + +function UserRow({ user }: { user: PlatformUser }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function toggle() { + setError(null); + const next = user.status === "ACTIVE" ? "DISABLED" : "ACTIVE"; + startTransition(async () => { + const result = await updateUserStatus(user.id, next); + if (!result.ok) { + setError(result.error); + return; + } + router.refresh(); + }); + } + + return ( + + + + + {user.name} + + + {user.email} + + {user.isPlatformUser ? ( + + + Platform admin + + ) : ( + Tenant + )} + + + {user.status === "ACTIVE" ? "Ativo" : "Desabilitado"} + + {formatDateTime(user.createdAt)} + +
+ {error && {error}} + +
+ + + ); +} diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts index 3984009..e765138 100644 --- a/apps/frontend/src/components/platform-shell/nav-data.ts +++ b/apps/frontend/src/components/platform-shell/nav-data.ts @@ -51,7 +51,16 @@ export const PLATFORM_NAV: NavSection[] = [ { label: "Infraestrutura", icon: ServerCog, - children: [{ label: "FreeSWITCH" }, { label: "SIP Profiles" }, { label: "Nodes" }, { label: "Saúde" }], + children: [ + { label: "FreeSWITCH" }, + { label: "SIP Profiles" }, + { label: "Nodes" }, + { + label: "Saúde", + href: "/platform/infraestrutura/saude", + description: "Postgres, Redis e FreeSWITCH — verificação ao vivo", + }, + ], }, { label: "IA", @@ -61,6 +70,19 @@ export const PLATFORM_NAV: NavSection[] = [ { label: "Sistema", icon: Settings2, - children: [{ label: "Usuários" }, { label: "Permissões" }, { label: "Auditoria" }, { label: "Configurações" }], + children: [ + { + label: "Usuários", + href: "/platform/sistema/usuarios", + description: "Todos os usuários da plataforma, cross-tenant", + }, + { label: "Permissões" }, + { + label: "Auditoria", + href: "/platform/sistema/auditoria", + description: "Log de eventos de todos os tenants", + }, + { label: "Configurações" }, + ], }, ]; diff --git a/apps/frontend/src/lib/platform-types.ts b/apps/frontend/src/lib/platform-types.ts index ad7e035..c9fe453 100644 --- a/apps/frontend/src/lib/platform-types.ts +++ b/apps/frontend/src/lib/platform-types.ts @@ -47,6 +47,42 @@ export const TENANT_STATUS_LABELS: Record = { CANCELLED: "Cancelado", }; +export interface PlatformUser { + id: string; + email: string; + name: string; + status: "ACTIVE" | "DISABLED"; + mustChangePassword: boolean; + createdAt: string; + isPlatformUser: boolean; +} + +export interface AuditLogEntry { + id: string; + action: string; + tenantId: string | null; + tenantName: string | null; + userId: string | null; + userEmail: string | null; + entityType: string | null; + entityId: string | null; + before: unknown; + after: unknown; + createdAt: string; +} + +export interface HealthCheck { + status: "ok" | "fail"; + latencyMs: number; +} + +export interface PlatformHealth { + postgres: HealthCheck; + redis: HealthCheck; + freeswitch: HealthCheck; + checkedAt: string; +} + export const PLAN_LIMIT_FIELDS: { key: keyof Plan; label: string }[] = [ { key: "maxExtensions", label: "Ramais" }, { key: "maxAgents", label: "Agentes" },