feat(platform): Sistema > Usuários/Auditoria, Infraestrutura > Saúde

Três endpoints novos, todos platform-only: GET /platform/users (cross-
tenant, users não tem RLS) + PATCH .../status (desabilitar tem efeito
real — login() já checava status ACTIVE desde a PHASE 04); GET
/platform/audit-log (últimos 200 eventos, audit_logs também sem RLS,
linha imutável); GET /platform/health (Postgres/Redis + FreeSWITCH via
conexão ESL avulsa, sem manter estado).

Achado de arquitetura documentado explicitamente na própria tela: o check
de FreeSWITCH sempre falha neste ambiente porque apps/api roda no host e
a porta 8021 é deliberadamente não publicada (decisão da PHASE 01/05) —
não é um bug, é a rede isolada do jeito certo.

Frontend: /platform/sistema/usuarios, /auditoria, /platform/
infraestrutura/saude. Testado ponta a ponta contra dados reais (3
usuários da plataforma, audit log com eventos reais desta sessão,
inclusive uma referência órfã tratada corretamente). Smoke test nas 19
telas anteriores, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-29 20:04:05 -03:00
parent 2c1269a83a
commit a23e68b011
17 changed files with 654 additions and 3 deletions

View File

@@ -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<PlatformHealth>("/platform/health", session.accessToken);
return <SaudeView health={health} />;
}

View File

@@ -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<PlatformHealth, "checkedAt">; 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 (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">Saúde da infraestrutura</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Verificação ao vivo (agente.md secao 187) checada agora, não um monitoramento contínuo. Última
checagem: {formatDateTime(health.checkedAt)}.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => startTransition(() => router.refresh())} disabled={pending}>
<RefreshCw className={`h-3.5 w-3.5 ${pending ? "animate-spin" : ""}`} aria-hidden />
{pending ? "Verificando…" : "Verificar de novo"}
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{CHECKS.map(({ key, label, icon: Icon }) => (
<CheckCard key={key} label={label} icon={Icon} check={health[key]} />
))}
</div>
<Panel>
<PanelHeader title="Sobre o check de FreeSWITCH" />
<p className="px-5 py-4 text-sm text-muted-foreground">
<code className="font-mono text-xs">apps/api</code> roda direto no host desta VM, fora do Docker; a porta
do Event Socket (8021) do FreeSWITCH é deliberadamente <strong>não publicada no host</strong> (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
(<code className="font-mono text-xs">fs-events</code>, <code className="font-mono text-xs">fs-config</code>
, <code className="font-mono text-xs">predictive-dialer</code>) rodam dentro da mesma rede Docker e não
têm esse problema.
</p>
</Panel>
</div>
);
}
function CheckCard({ label, icon: Icon, check }: { label: string; icon: typeof Database; check: HealthCheck }) {
const ok = check.status === "ok";
return (
<div className="flex flex-col justify-between rounded-lg border border-border bg-surface p-5 shadow-panel">
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-medium text-foreground">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
{label}
</span>
{ok ? <CircleCheck className="h-5 w-5 text-status-green" aria-hidden /> : <CircleX className="h-5 w-5 text-status-red" aria-hidden />}
</div>
<div className="mt-3">
<span className={`text-2xl font-semibold ${ok ? "text-status-green" : "text-status-red"}`}>{ok ? "OK" : "Falhou"}</span>
<p className="mt-1 font-mono text-xs text-muted-foreground">{check.latencyMs}ms</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Auditoria</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Ú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.
</p>
</div>
<Panel>
<PanelHeader title="Eventos" description={`${entries.length} evento(s)`} />
<div className="border-b border-border px-5 py-3">
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar ação, usuário, tenant…"
className="pl-8"
aria-label="Buscar no audit log"
/>
</div>
</div>
{rows.length === 0 ? (
<EmptyState title="Nenhum evento bate com essa busca" description="Tente outro termo." />
) : (
<Table>
<THead>
<TR>
<TH>Quando</TH>
<TH>Ação</TH>
<TH>Usuário</TH>
<TH>Tenant</TH>
<TH>Entidade</TH>
</TR>
</THead>
<TBody>
{rows.map((e) => (
<TR key={e.id}>
<TD className="text-muted-foreground">{formatDateTime(e.createdAt)}</TD>
<TD>
<span className="flex items-center gap-2 font-mono text-xs font-medium text-foreground">
<ScrollText className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{e.action}
</span>
</TD>
<TD className="text-muted-foreground">{e.userEmail ?? "—"}</TD>
<TD className="text-muted-foreground">{e.tenantName ? <Pill>{e.tenantName}</Pill> : "—"}</TD>
<TD className="font-mono text-xs text-muted-foreground">{e.entityType ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}

View File

@@ -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<AuditLogEntry[]>("/platform/audit-log", session.accessToken);
return <AuditoriaView entries={entries} />;
}

View File

@@ -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<void>(`/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) };
}
}

View File

@@ -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<PlatformUser[]>("/platform/users", session.accessToken);
return <UsuariosView users={users} />;
}

View File

@@ -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 (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Usuários</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Todos os usuários da plataforma, cross-tenant (agente.md secao 148, 168) criar usuário novo continua
junto com um tenant (Clientes &gt; Tenants) ou via a tela do próprio tenant; aqui visão + desabilitar.
</p>
</div>
<Panel>
<PanelHeader title="Usuários cadastrados" description={`${users.length} usuário(s)`} />
<div className="border-b border-border px-5 py-3">
<div className="relative w-full max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Buscar usuário…" className="pl-8" aria-label="Buscar usuário" />
</div>
</div>
{rows.length === 0 ? (
<EmptyState title="Nenhum usuário bate com essa busca" description="Tente outro termo." />
) : (
<Table>
<THead>
<TR>
<TH>Nome</TH>
<TH>E-mail</TH>
<TH>Tipo</TH>
<TH>Status</TH>
<TH>Criado</TH>
<TH>
<span className="sr-only">Ações</span>
</TH>
</TR>
</THead>
<TBody>
{rows.map((u) => (
<UserRow key={u.id} user={u} />
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}
function UserRow({ user }: { user: PlatformUser }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(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 (
<TR>
<TD>
<span className="flex items-center gap-2 font-medium text-foreground">
<UserIcon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{user.name}
</span>
</TD>
<TD className="text-muted-foreground">{user.email}</TD>
<TD>
{user.isPlatformUser ? (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" aria-hidden />
Platform admin
</span>
) : (
<span className="text-xs text-muted-foreground">Tenant</span>
)}
</TD>
<TD>
<Pill tone={user.status === "ACTIVE" ? "accent" : "neutral"}>{user.status === "ACTIVE" ? "Ativo" : "Desabilitado"}</Pill>
</TD>
<TD className="text-muted-foreground">{formatDateTime(user.createdAt)}</TD>
<TD>
<div className="flex items-center justify-end gap-2">
{error && <span className="text-xs text-destructive">{error}</span>}
<Button type="button" variant="outline" size="sm" onClick={toggle} disabled={pending || user.isPlatformUser}>
{pending ? "…" : user.status === "ACTIVE" ? "Desabilitar" : "Reativar"}
</Button>
</div>
</TD>
</TR>
);
}

View File

@@ -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" },
],
},
];

View File

@@ -47,6 +47,42 @@ export const TENANT_STATUS_LABELS: Record<string, string> = {
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" },