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:
56
TODO.md
56
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) —
|
- [ ] `Plan.key` não pode ser editado depois de criado (só os limites) —
|
||||||
não implementado, `key` normalmente não deveria mudar mesmo
|
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
|
## Riscos conhecidos
|
||||||
|
|||||||
56
apps/api/src/platform/platform-audit.controller.ts
Normal file
56
apps/api/src/platform/platform-audit.controller.ts
Normal file
@@ -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<Record<string, unknown>[]> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
65
apps/api/src/platform/platform-health.controller.ts
Normal file
65
apps/api/src/platform/platform-health.controller.ts
Normal file
@@ -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<T>(fn: () => Promise<T>): 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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/api/src/platform/platform-users.controller.ts
Normal file
72
apps/api/src/platform/platform-users.controller.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PlatformOverviewController } from "./platform-overview.controller";
|
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({
|
@Module({
|
||||||
controllers: [PlatformOverviewController],
|
controllers: [PlatformOverviewController, PlatformUsersController, PlatformAuditController, PlatformHealthController],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/platform-auditoria-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/platform-auditoria-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/frontend/.impeccable/review/platform-saude-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/platform-saude-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
apps/frontend/.impeccable/review/platform-usuarios-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/platform-usuarios-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
10
apps/frontend/src/app/platform/infraestrutura/saude/page.tsx
Normal file
10
apps/frontend/src/app/platform/infraestrutura/saude/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/frontend/src/app/platform/sistema/auditoria/page.tsx
Normal file
10
apps/frontend/src/app/platform/sistema/auditoria/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
29
apps/frontend/src/app/platform/sistema/usuarios/actions.ts
Normal file
29
apps/frontend/src/app/platform/sistema/usuarios/actions.ts
Normal 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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/frontend/src/app/platform/sistema/usuarios/page.tsx
Normal file
10
apps/frontend/src/app/platform/sistema/usuarios/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,7 +51,16 @@ export const PLATFORM_NAV: NavSection[] = [
|
|||||||
{
|
{
|
||||||
label: "Infraestrutura",
|
label: "Infraestrutura",
|
||||||
icon: ServerCog,
|
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",
|
label: "IA",
|
||||||
@@ -61,6 +70,19 @@ export const PLATFORM_NAV: NavSection[] = [
|
|||||||
{
|
{
|
||||||
label: "Sistema",
|
label: "Sistema",
|
||||||
icon: Settings2,
|
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" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -47,6 +47,42 @@ export const TENANT_STATUS_LABELS: Record<string, string> = {
|
|||||||
CANCELLED: "Cancelado",
|
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 }[] = [
|
export const PLAN_LIMIT_FIELDS: { key: keyof Plan; label: string }[] = [
|
||||||
{ key: "maxExtensions", label: "Ramais" },
|
{ key: "maxExtensions", label: "Ramais" },
|
||||||
{ key: "maxAgents", label: "Agentes" },
|
{ key: "maxAgents", label: "Agentes" },
|
||||||
|
|||||||
Reference in New Issue
Block a user