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
122 lines
4.4 KiB
TypeScript
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 só
|
|
junto com um tenant (Clientes > Tenants) ou via a tela do próprio tenant; aqui só 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>
|
|
);
|
|
}
|