feat(platform): Sistema > Permissões (catálogo RBAC, só leitura)

GET /platform/roles novo — lista as 4 roles do sistema com as permissions
de cada uma, mais o catálogo completo de permissions. roles/permissions
não têm RLS (catálogo global do seed); só leitura, RBAC é system-defined,
sem UI de criar role customizada ainda.

Frontend: /platform/sistema/permissoes, um card por role + tabela do
catálogo completo. Testado ponta a ponta: as 4 roles reais (platform_super_admin
33 permissions, tenant_admin 30, supervisor 17, agent 2) corretas. Smoke
test nas 19 telas do tenant + 9 telas platform, 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:41:19 -03:00
parent c95c6805fb
commit 03ec0d556b
7 changed files with 182 additions and 2 deletions

View File

@@ -0,0 +1,46 @@
import { Controller, ForbiddenException, Get, 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 > Permissões" (agente.md secao 142-145, 168) — só leitura por
* enquanto: RBAC é system-defined (roles/permissions vêm do seed,
* `packages/auth/src/seed.ts`), sem UI de criar role customizada ainda.
* `roles`/`permissions` não têm RLS (catálogo global do sistema).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/roles")
export class PlatformRolesController {
@RequirePermission("roles.manage")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver o catalogo de roles/permissoes");
}
const prisma = getPrismaClient();
const [roles, permissions] = await Promise.all([
prisma.role.findMany({
include: { rolePermissions: { include: { permission: true } } },
orderBy: { name: "asc" },
}),
prisma.permission.findMany({ orderBy: { key: "asc" } }),
]);
return {
permissions,
roles: roles.map((r) => ({
id: r.id,
key: r.key,
name: r.name,
scope: r.scope,
isSystem: r.isSystem,
permissionKeys: r.rolePermissions.map((rp) => rp.permission.key),
})),
};
}
}

View File

@@ -3,8 +3,15 @@ import { PlatformOverviewController } from "./platform-overview.controller";
import { PlatformUsersController } from "./platform-users.controller";
import { PlatformAuditController } from "./platform-audit.controller";
import { PlatformHealthController } from "./platform-health.controller";
import { PlatformRolesController } from "./platform-roles.controller";
@Module({
controllers: [PlatformOverviewController, PlatformUsersController, PlatformAuditController, PlatformHealthController],
controllers: [
PlatformOverviewController,
PlatformUsersController,
PlatformAuditController,
PlatformHealthController,
PlatformRolesController,
],
})
export class PlatformModule {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View File

@@ -0,0 +1,24 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import { PermissoesView } from "./permissoes-view";
interface RoleWithPermissions {
id: string;
key: string;
name: string;
scope: "PLATFORM" | "TENANT";
isSystem: boolean;
permissionKeys: string[];
}
interface Permission {
id: string;
key: string;
description: string | null;
}
export default async function PermissoesPage() {
const session = await requireSession();
const data = await apiFetch<{ roles: RoleWithPermissions[]; permissions: Permission[] }>("/platform/roles", session.accessToken);
return <PermissoesView roles={data.roles} permissions={data.permissions} />;
}

View File

@@ -0,0 +1,80 @@
import { ShieldCheck } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill";
import { TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
interface RoleWithPermissions {
id: string;
key: string;
name: string;
scope: "PLATFORM" | "TENANT";
isSystem: boolean;
permissionKeys: string[];
}
interface Permission {
id: string;
key: string;
description: string | null;
}
export function PermissoesView({ roles, permissions }: { roles: RoleWithPermissions[]; permissions: Permission[] }) {
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Permissões</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
RBAC do sistema (agente.md secao 142-145) roles e o catálogo de permissions são definidos no seed, sem
UI de criar role customizada ainda; esta tela é leitura.
</p>
</div>
<div className="space-y-4">
{roles.map((role) => (
<Panel key={role.id}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-5 py-4">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground" aria-hidden />
<h2 className="text-sm font-semibold text-foreground">{role.name}</h2>
<Pill>{role.key}</Pill>
<Pill tone={role.scope === "PLATFORM" ? "accent" : "neutral"}>{role.scope === "PLATFORM" ? "Plataforma" : "Tenant"}</Pill>
</div>
<span className="text-xs text-muted-foreground">{role.permissionKeys.length} permission(ões)</span>
</div>
<div className="flex flex-wrap gap-1.5 p-5">
{role.permissionKeys.length === 0 ? (
<span className="text-sm text-muted-foreground">Nenhuma permission atribuída.</span>
) : (
role.permissionKeys.map((key) => (
<span key={key} className="rounded-full border border-border bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground">
{key}
</span>
))
)}
</div>
</Panel>
))}
</div>
<Panel>
<PanelHeader title="Catálogo de permissions" description={`${permissions.length} permission(ões) no sistema`} />
<Table>
<THead>
<TR>
<TH>Chave</TH>
<TH>Descrição</TH>
</TR>
</THead>
<TBody>
{permissions.map((p) => (
<TR key={p.id}>
<TD className="font-mono text-xs text-foreground">{p.key}</TD>
<TD className="text-muted-foreground">{p.description ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
</Panel>
</div>
);
}

View File

@@ -84,7 +84,11 @@ export const PLATFORM_NAV: NavSection[] = [
href: "/platform/sistema/usuarios",
description: "Todos os usuários da plataforma, cross-tenant",
},
{ label: "Permissões" },
{
label: "Permissões",
href: "/platform/sistema/permissoes",
description: "Catálogo de roles e permissions do sistema",
},
{
label: "Auditoria",
href: "/platform/sistema/auditoria",