Files
B2BCall-dialer/apps/frontend/src/app/platform/sistema/usuarios/usuarios-view.tsx
Matheus a23e68b011 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
2026-08-29 20:04:05 -03:00

122 lines
4.4 KiB
TypeScript

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