diff --git a/TODO.md b/TODO.md
index 602985f..a551552 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1700,6 +1700,41 @@ secao 96-103, 124, 169) + achado real de autorização em `/ai/models`
aplicado (0 chamadas — não existe tráfego real neste tenant ainda,
resultado honesto)
+## PHASE 48 — "Itens sem permissão não aparecem" (agente.md secao 169)
+- [x] `getUserPermissionKeys(userId, tenantId?)` (novo, `packages/auth`) —
+ todas as permission keys do usuário no contexto atual, mesma query
+ de `userHasPermission` só que devolvendo o conjunto inteiro em vez
+ de checar uma. `GET /auth/me` agora devolve `permissionKeys` (só
+ leitura adicional, não é decisão de autorização — isso continua
+ sendo o `PermissionGuard` em cada endpoint, secao 31/146: esconder
+ um item de menu nunca substitui o check no backend)
+- [x] `NavLeaf`/`NavSection` ganham campo opcional `permission`; cada um
+ dos ~35 itens de menu (tenant e platform) anotado com a permission
+ key mínima que o endpoint GET correspondente já exige (ex.: Discador
+ usa `campaigns.view`, Administração usa `users.manage`,
+ Infraestrutura usa `freeswitch.view`). `filterNavByPermissions()`
+ (novo, `nav-types.ts`) esconde o item, ou a seção inteira se
+ nenhum filho sobrar
+- [x] achado de arquitetura ao implementar (não um bug, uma restrição já
+ documentada): `TenantSidebar`/`PlatformSidebar`/`*Topbar` são
+ client components que importam `TENANT_NAV`/`PLATFORM_NAV`
+ DIRETO (em vez de receber como prop do server layout) porque os
+ ícones (`LucideIcon`, funções) não são serializáveis através da
+ fronteira RSC — bug real já documentado desde a PHASE 23. Por isso
+ o filtro roda dentro desses wrappers client-side, sobre o array já
+ no bundle, recebendo só `permissionKeys: string[]` (serializável)
+ como prop
+- [x] Testado ponta a ponta com um supervisor de teste de verdade
+ (convidado, senha trocada, logado via UI): menu mostra só Discador/
+ Call Center/Telefonia (sem Dialplan, que exige `freeswitch.view` —
+ supervisor não tem)/Monitoramento/Gravações/IA/Relatórios — a seção
+ Administração inteira some (nenhum dos 3 filhos exige menos que
+ `users.manage`). Confirmado que a autorização de verdade continua
+ valendo mesmo sem o item no menu: acesso direto a
+ `/app/administracao/usuarios` continua batendo 403 no backend.
+ Regressão: tenant_admin e platform_super_admin continuam vendo
+ TODO item do próprio menu (sem perder nada)
+
---
## Riscos conhecidos
diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts
index 44dae3d..c9aeaef 100644
--- a/apps/api/src/auth/auth.controller.ts
+++ b/apps/api/src/auth/auth.controller.ts
@@ -3,6 +3,7 @@ import type { FastifyRequest } from "fastify";
import { getPrismaClient } from "@b2bcall/database";
import {
changePassword,
+ getUserPermissionKeys,
isPlatformUser,
listUserTenants,
login,
@@ -70,6 +71,10 @@ export class AuthController {
...dbUser,
isPlatformUser: await isPlatformUser(user.sub),
tenant: tenant ? { id: tenant.id, code: tenant.code, name: tenant.tradeName ?? tenant.legalName } : null,
+ // "Itens sem permissão não aparecem" (secao 169) — só UX de menu, o
+ // PermissionGuard em cada endpoint continua sendo a única fonte
+ // confiável de autorização.
+ permissionKeys: await getUserPermissionKeys(user.sub, user.tenantId),
};
}
diff --git a/apps/frontend/.impeccable/review/nav-filter-platform-admin-desktop.png b/apps/frontend/.impeccable/review/nav-filter-platform-admin-desktop.png
new file mode 100644
index 0000000..0baae1b
Binary files /dev/null and b/apps/frontend/.impeccable/review/nav-filter-platform-admin-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/nav-filter-supervisor-desktop.png b/apps/frontend/.impeccable/review/nav-filter-supervisor-desktop.png
new file mode 100644
index 0000000..6c3191a
Binary files /dev/null and b/apps/frontend/.impeccable/review/nav-filter-supervisor-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/nav-filter-supervisor-direct-access-desktop.png b/apps/frontend/.impeccable/review/nav-filter-supervisor-direct-access-desktop.png
new file mode 100644
index 0000000..b5403e2
Binary files /dev/null and b/apps/frontend/.impeccable/review/nav-filter-supervisor-direct-access-desktop.png differ
diff --git a/apps/frontend/src/app/app/layout.tsx b/apps/frontend/src/app/app/layout.tsx
index 0e5aaac..4c9a6de 100644
--- a/apps/frontend/src/app/app/layout.tsx
+++ b/apps/frontend/src/app/app/layout.tsx
@@ -10,6 +10,7 @@ interface Me {
name: string;
isPlatformUser: boolean;
tenant: { id: string; code: string; name: string } | null;
+ permissionKeys: string[];
}
export default async function TenantAppLayout({ children }: { children: React.ReactNode }) {
@@ -23,9 +24,9 @@ export default async function TenantAppLayout({ children }: { children: React.Re
return (
diff --git a/apps/frontend/src/app/platform/layout.tsx b/apps/frontend/src/app/platform/layout.tsx
index 5aebd1d..d91f169 100644
--- a/apps/frontend/src/app/platform/layout.tsx
+++ b/apps/frontend/src/app/platform/layout.tsx
@@ -9,6 +9,7 @@ interface Me {
email: string;
name: string;
isPlatformUser: boolean;
+ permissionKeys: string[];
}
export default async function PlatformLayout({ children }: { children: React.ReactNode }) {
@@ -18,9 +19,9 @@ export default async function PlatformLayout({ children }: { children: React.Rea
return (
diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts
index 5989a25..b57b737 100644
--- a/apps/frontend/src/components/platform-shell/nav-data.ts
+++ b/apps/frontend/src/components/platform-shell/nav-data.ts
@@ -1,20 +1,20 @@
import { LayoutDashboard, Building2, CreditCard, ServerCog, Sparkles, Settings2 } from "lucide-react";
import type { NavSection } from "../shell/nav-types";
-/** IA fixa do menu Platform (agente.md secao 168) — "Visão Geral" e
- * "Billing > Tarifas" têm página construída até agora, o resto existe pra
- * provar a arquitetura completa (Product Principle #5 em PRODUCT.md: esta
- * é a implementação de referência do design system pras próximas telas),
- * renderizado como indisponível em vez de virar link morto. Os outros 3
- * itens de Billing (Consumo/Fechamentos/Relatórios) esperam um seletor de
- * tenant real — não existe endpoint de listagem de tenants ainda (ver
- * docs/BILLING.md), então ficam "em breve" até essa peça existir. */
+/** IA fixa do menu Platform (agente.md secao 168). Todo item aqui tem uma
+ * tela real por trás (fechado na PHASE 46) — "permission" em cada um bate
+ * com o `@RequirePermission` do endpoint GET que a tela chama, usado por
+ * `filterNavByPermissions` (secao 169: "itens sem permissão não
+ * aparecem"). Só platform_super_admin chega neste menu (o layout redireciona
+ * quem não é platform user antes de renderizar), mas a filtragem continua
+ * valendo caso um dia exista uma role de plataforma mais restrita. */
export const PLATFORM_NAV: NavSection[] = [
{
label: "Visão Geral",
icon: LayoutDashboard,
href: "/platform",
description: "Estado agregado de todos os tenants, agora",
+ permission: "tenants.view",
},
{
label: "Clientes",
@@ -24,21 +24,25 @@ export const PLATFORM_NAV: NavSection[] = [
label: "Tenants",
href: "/platform/clientes/tenants",
description: "Clientes da plataforma — criar cria também o primeiro usuário",
+ permission: "tenants.view",
},
{
label: "Planos",
href: "/platform/clientes/planos",
description: "Catálogo de limites e recursos por plano",
+ permission: "pricing.manage",
},
{
label: "Assinaturas",
href: "/platform/clientes/assinaturas",
description: "Versão de preço assinada e ciclo de billing por tenant",
+ permission: "billing.manage",
},
{
label: "Quotas",
href: "/platform/clientes/quotas",
description: "Uso vs. limite do plano — todos os tenants, quem está perto do limite",
+ permission: "tenants.view",
},
],
},
@@ -50,21 +54,25 @@ export const PLATFORM_NAV: NavSection[] = [
label: "Consumo",
href: "/platform/billing/consumo",
description: "Uso bruto de todos os tenants no mês corrente",
+ permission: "billing.view",
},
{
label: "Tarifas",
href: "/platform/billing/tarifas",
description: "Price books e rate decks — catálogos globais de preço",
+ permission: "pricing.manage",
},
{
label: "Fechamentos",
href: "/platform/billing/fechamentos",
description: "Fechar/reabrir período de billing por tenant",
+ permission: "billing.manage",
},
{
label: "Relatórios",
href: "/platform/billing/relatorios",
description: "Statements gerados por tenant",
+ permission: "billing.view",
},
],
},
@@ -76,21 +84,25 @@ export const PLATFORM_NAV: NavSection[] = [
label: "FreeSWITCH",
href: "/platform/infraestrutura/freeswitch",
description: "Canais e chamadas ativas agora, direto do ESL",
+ permission: "freeswitch.view",
},
{
label: "SIP Profiles",
href: "/platform/infraestrutura/sip-profiles",
description: "sofia status e registros SIP ativos",
+ permission: "freeswitch.view",
},
{
label: "Nodes",
href: "/platform/infraestrutura/nodes",
description: "Nodes FreeSWITCH desta implantação (hoje: 1)",
+ permission: "freeswitch.view",
},
{
label: "Saúde",
href: "/platform/infraestrutura/saude",
description: "Postgres, Redis e FreeSWITCH — verificação ao vivo",
+ permission: "freeswitch.view",
},
],
},
@@ -102,21 +114,25 @@ export const PLATFORM_NAV: NavSection[] = [
label: "Providers",
href: "/platform/ia/providers",
description: "Providers de IA globais, visíveis por qualquer tenant",
+ permission: "ai.view",
},
{
label: "Modelos",
href: "/platform/ia/modelos",
description: "Modelos dos providers globais, com custo unitário",
+ permission: "ai.view",
},
{
label: "Uso",
href: "/platform/ia/uso",
description: "Uso bruto de IA de todos os tenants no mês corrente",
+ permission: "tenants.view",
},
{
label: "Custos",
href: "/platform/ia/custos",
description: "Uso × custo unitário — estimado, nunca um número fixo",
+ permission: "tenants.view",
},
],
},
@@ -128,21 +144,25 @@ export const PLATFORM_NAV: NavSection[] = [
label: "Usuários",
href: "/platform/sistema/usuarios",
description: "Todos os usuários da plataforma, cross-tenant",
+ permission: "users.manage",
},
{
label: "Permissões",
href: "/platform/sistema/permissoes",
description: "Catálogo de roles e permissions do sistema",
+ permission: "roles.manage",
},
{
label: "Auditoria",
href: "/platform/sistema/auditoria",
description: "Log de eventos de todos os tenants",
+ permission: "audit.view",
},
{
label: "Configurações",
href: "/platform/sistema/configuracoes",
description: "Flags de segurança/infra do .env — somente leitura",
+ permission: "tenants.view",
},
],
},
diff --git a/apps/frontend/src/components/platform-shell/platform-sidebar.tsx b/apps/frontend/src/components/platform-shell/platform-sidebar.tsx
index 2d0c227..14d7c01 100644
--- a/apps/frontend/src/components/platform-shell/platform-sidebar.tsx
+++ b/apps/frontend/src/components/platform-shell/platform-sidebar.tsx
@@ -1,6 +1,7 @@
"use client";
import { Sidebar } from "@/components/shell/sidebar";
+import { filterNavByPermissions } from "@/components/shell/nav-types";
import { PLATFORM_NAV } from "./nav-data";
/**
@@ -10,8 +11,10 @@ import { PLATFORM_NAV } from "./nav-data";
* (client) um valor que já nasceu no bundle client; importar `nav-data`
* aqui dentro (em vez de receber `items` como prop vindo do server)
* resolve isso. Bug real, achado testando o app do tenant: "Functions
- * cannot be passed directly to Client Components".
+ * cannot be passed directly to Client Components". Mesma razão pra
+ * filtrar por permission (secao 169) aqui dentro, sobre o array já no
+ * bundle client, em vez de receber os itens já filtrados como prop.
*/
-export function PlatformSidebar() {
- return ;
+export function PlatformSidebar({ permissionKeys }: { permissionKeys: string[] }) {
+ return ;
}
diff --git a/apps/frontend/src/components/platform-shell/platform-topbar.tsx b/apps/frontend/src/components/platform-shell/platform-topbar.tsx
index 0acba3c..05a7270 100644
--- a/apps/frontend/src/components/platform-shell/platform-topbar.tsx
+++ b/apps/frontend/src/components/platform-shell/platform-topbar.tsx
@@ -1,9 +1,10 @@
"use client";
import { Topbar } from "@/components/shell/topbar";
+import { filterNavByPermissions } from "@/components/shell/nav-types";
import { PLATFORM_NAV } from "./nav-data";
/** Mesma razão do `PlatformSidebar` — ver comentário lá. */
-export function PlatformTopbar({ userLabel }: { userLabel: string }) {
- return ;
+export function PlatformTopbar({ userLabel, permissionKeys }: { userLabel: string; permissionKeys: string[] }) {
+ return ;
}
diff --git a/apps/frontend/src/components/shell/nav-types.ts b/apps/frontend/src/components/shell/nav-types.ts
index 4a8b5e0..7626f9f 100644
--- a/apps/frontend/src/components/shell/nav-types.ts
+++ b/apps/frontend/src/components/shell/nav-types.ts
@@ -5,6 +5,11 @@ export interface NavLeaf {
href?: string;
/** Subtítulo mostrado na topbar quando esta rota está ativa. */
description?: string;
+ /** Permission key mínima pra ver este item (secao 169: "itens sem
+ * permissão não aparecem") — bate com o `@RequirePermission` do
+ * endpoint GET que a tela chama. Omitido = sempre visível (ex.:
+ * Dashboard, que todo usuário autenticado do tenant enxerga). */
+ permission?: string;
}
export interface NavSection {
@@ -12,9 +17,29 @@ export interface NavSection {
icon: LucideIcon;
href?: string;
description?: string;
+ permission?: string;
children?: NavLeaf[];
}
+/** Filtra o menu pelas permissions do usuário (secao 169) — só UX, nunca
+ * autorização de verdade (isso continua sendo o `PermissionGuard` em
+ * cada endpoint). Uma seção com `children` só aparece se sobrar pelo
+ * menos 1 filho visível; uma seção com `href` direto (sem children) é
+ * filtrada pela própria `permission`. */
+export function filterNavByPermissions(items: NavSection[], permissionKeys: string[]): NavSection[] {
+ const has = (permission?: string) => !permission || permissionKeys.includes(permission);
+
+ return items
+ .map((section) => {
+ if (section.children) {
+ const children = section.children.filter((child) => has(child.permission));
+ return children.length > 0 ? { ...section, children } : null;
+ }
+ return has(section.permission) ? section : null;
+ })
+ .filter((section): section is NavSection => section != null);
+}
+
/** Título+descrição da topbar pra rota atual — casa pelo `href` mais longo
* (uma subrota como `/price-books/123` ainda resolve pro item de menu
* "Tarifas"), nunca deixa a topbar com um título de outra página (bug
diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts
index bb1f10a..86d025b 100644
--- a/apps/frontend/src/components/tenant-shell/nav-data.ts
+++ b/apps/frontend/src/components/tenant-shell/nav-data.ts
@@ -11,16 +11,11 @@ import {
} from "lucide-react";
import type { NavSection } from "../shell/nav-types";
-/** IA fixa do menu Tenant (agente.md secao 169). Páginas construídas até
- * agora: Dashboard (PHASE 23), Telefonia > Ramais (PHASE 25), Discador >
- * Campanhas (PHASE 26), Call Center > Filas/Pausas/Disposições, Telefonia
- * > Troncos e Discador > Lista de Bloqueio (PHASE 27) — o resto existe pra
- * provar a arquitetura completa (Product Principle #5 em PRODUCT.md),
- * renderizado como indisponível em vez de virar link morto. "Itens sem
- * permissão não aparecem" (secao 169) ainda não é aplicado aqui — este
- * primeiro corte mostra a IA inteira pra qualquer usuário tenant
- * autenticado; filtrar por permission real fica pra quando mais telas
- * existirem pra testar contra. */
+/** IA fixa do menu Tenant (agente.md secao 169). Todo item aqui tem uma
+ * tela real por trás (fechado na PHASE 46) — "permission" em cada um bate
+ * com o `@RequirePermission` do endpoint GET que a tela chama, usado por
+ * `filterNavByPermissions` (secao 169: "itens sem permissão não
+ * aparecem"). Item sem `permission` = sempre visível (Dashboard). */
export const TENANT_NAV: NavSection[] = [
{
label: "Dashboard",
@@ -36,21 +31,25 @@ export const TENANT_NAV: NavSection[] = [
label: "Campanhas",
href: "/app/discador/campanhas",
description: "Discador preditivo — campanhas deste tenant",
+ permission: "campaigns.view",
},
{
label: "Leads",
href: "/app/discador/leads",
description: "Leads de uma campanha — buscar, adicionar, remover",
+ permission: "campaigns.view",
},
{
label: "Callbacks",
href: "/app/discador/callbacks",
description: "Leads que pediram retorno em outro horário — remarcar, tentar agora ou cancelar",
+ permission: "campaigns.view",
},
{
label: "Lista de Bloqueio",
href: "/app/discador/bloqueio",
description: "Números que o discador nunca chama",
+ permission: "campaigns.view",
},
],
},
@@ -62,21 +61,25 @@ export const TENANT_NAV: NavSection[] = [
label: "Agentes",
href: "/app/callcenter/agentes",
description: "Identidade de call center — liga um usuário a um agente e a um ramal",
+ permission: "agents.view",
},
{
label: "Filas",
href: "/app/callcenter/filas",
description: "Filas de atendimento deste tenant",
+ permission: "queues.view",
},
{
label: "Pausas",
href: "/app/callcenter/pausas",
description: "Motivos de pausa que o agente pode escolher",
+ permission: "agents.view",
},
{
label: "Disposições",
href: "/app/callcenter/disposicoes",
description: "Motivos de encerramento de chamada",
+ permission: "agents.view",
},
],
},
@@ -88,16 +91,19 @@ export const TENANT_NAV: NavSection[] = [
label: "Ramais",
href: "/app/telefonia/ramais",
description: "Ramais SIP deste tenant — números, senha, caller ID",
+ permission: "extensions.view",
},
{
label: "Troncos",
href: "/app/telefonia/troncos",
description: "Troncos SIP deste tenant",
+ permission: "trunks.view",
},
{
label: "Dialplan",
href: "/app/telefonia/dialplan",
description: "Editor de regras do contexto default — condição, ações, versões",
+ permission: "freeswitch.view",
},
],
},
@@ -106,12 +112,14 @@ export const TENANT_NAV: NavSection[] = [
icon: Radar,
href: "/app/monitoramento",
description: "Eventos ao vivo — filas, agentes e chamadas em tempo real",
+ permission: "monitoring.view",
},
{
label: "Gravações",
icon: Mic,
href: "/app/gravacoes",
description: "Chamadas gravadas deste tenant — player autenticado, sem URL direta pro storage",
+ permission: "recordings.view",
},
{
label: "IA",
@@ -121,21 +129,25 @@ export const TENANT_NAV: NavSection[] = [
label: "Análises",
href: "/app/relatorios/ia",
description: "Dashboard de IA — score, sentimento, assuntos, compliance",
+ permission: "reports.view",
},
{
label: "Scorecards",
href: "/app/ia/scorecards",
description: "Critérios ponderados que a IA avalia em toda chamada analisada",
+ permission: "ai.view",
},
{
label: "Prompts",
href: "/app/ia/prompts",
description: "Templates de prompt usados na análise e no scorecard",
+ permission: "ai.view",
},
{
label: "Configurações",
href: "/app/ia/configuracoes",
description: "Providers e modelos de IA — BYOK deste tenant",
+ permission: "ai.view",
},
],
},
@@ -147,26 +159,31 @@ export const TENANT_NAV: NavSection[] = [
label: "Chamadas",
href: "/app/relatorios/chamadas",
description: "Lista de chamadas — direção, fila, agente, duração, encerramento",
+ permission: "reports.view",
},
{
label: "Agentes",
href: "/app/relatorios/agentes",
description: "Tempo logado/pausado/em chamada e TMA por agente",
+ permission: "reports.view",
},
{
label: "Filas",
href: "/app/relatorios/filas",
description: "Recebidas/atendidas/abandonadas, TME/TMA, Service Level",
+ permission: "reports.view",
},
{
label: "Campanhas",
href: "/app/relatorios/campanhas",
description: "Leads, tentativas, taxas de atendimento e contato",
+ permission: "reports.view",
},
{
label: "Consumo",
href: "/app/relatorios/consumo",
description: "Uso bruto do mês — minutos, dias ativos, armazenamento, tokens de IA",
+ permission: "reports.view",
},
],
},
@@ -178,16 +195,19 @@ export const TENANT_NAV: NavSection[] = [
label: "Usuários",
href: "/app/administracao/usuarios",
description: "Quem tem acesso a este tenant — convidar e trocar papel",
+ permission: "users.manage",
},
{
label: "Perfis",
href: "/app/administracao/perfis",
description: "O que cada papel pode fazer neste tenant",
+ permission: "users.manage",
},
{
label: "Configurações",
href: "/app/administracao/configuracoes",
description: "Dados do tenant — nome fantasia, fuso, idioma, privacidade de IA",
+ permission: "users.manage",
},
],
},
diff --git a/apps/frontend/src/components/tenant-shell/tenant-sidebar.tsx b/apps/frontend/src/components/tenant-shell/tenant-sidebar.tsx
index 94ce534..094e3fb 100644
--- a/apps/frontend/src/components/tenant-shell/tenant-sidebar.tsx
+++ b/apps/frontend/src/components/tenant-shell/tenant-sidebar.tsx
@@ -1,10 +1,14 @@
"use client";
import { Sidebar } from "@/components/shell/sidebar";
+import { filterNavByPermissions } from "@/components/shell/nav-types";
import { TENANT_NAV } from "./nav-data";
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão
- * (ícones não são serializáveis através da fronteira RSC). */
-export function TenantSidebar({ railLabel }: { railLabel: string }) {
- return ;
+ * (ícones não são serializáveis através da fronteira RSC) — por isso o
+ * filtro por permission (secao 169) acontece aqui, client-side, sobre o
+ * `TENANT_NAV` importado no bundle, nunca recebendo os itens já
+ * filtrados como prop do server. */
+export function TenantSidebar({ railLabel, permissionKeys }: { railLabel: string; permissionKeys: string[] }) {
+ return ;
}
diff --git a/apps/frontend/src/components/tenant-shell/tenant-topbar.tsx b/apps/frontend/src/components/tenant-shell/tenant-topbar.tsx
index ee00c3d..9d05afe 100644
--- a/apps/frontend/src/components/tenant-shell/tenant-topbar.tsx
+++ b/apps/frontend/src/components/tenant-shell/tenant-topbar.tsx
@@ -1,9 +1,18 @@
"use client";
import { Topbar } from "@/components/shell/topbar";
+import { filterNavByPermissions } from "@/components/shell/nav-types";
import { TENANT_NAV } from "./nav-data";
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */
-export function TenantTopbar({ fallbackTitle, userLabel }: { fallbackTitle: string; userLabel: string }) {
- return ;
+export function TenantTopbar({
+ fallbackTitle,
+ userLabel,
+ permissionKeys,
+}: {
+ fallbackTitle: string;
+ userLabel: string;
+ permissionKeys: string[];
+}) {
+ return ;
}
diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts
index f5fa203..f277824 100644
--- a/packages/auth/src/permissions.ts
+++ b/packages/auth/src/permissions.ts
@@ -30,6 +30,33 @@ export async function userHasPermission(
);
}
+/**
+ * Todas as permission keys que `userId` tem no contexto de `tenantId`
+ * (ou só as de escopo PLATFORM, se `tenantId` for omitido) — usado pelo
+ * frontend pra filtrar o menu (agente.md secao 169: "itens sem permissão
+ * não aparecem"), nunca pra decidir autorização de escrita (isso continua
+ * sendo `userHasPermission`/`PermissionGuard` no backend, a única fonte
+ * confiável — o menu é só UX, esconder um item não substitui o check).
+ */
+export async function getUserPermissionKeys(userId: string, tenantId?: string): Promise {
+ const prisma = getPrismaClient();
+
+ const where = tenantId
+ ? { userId, OR: [{ tenantId }, { tenantId: null }] }
+ : { userId, tenantId: null };
+
+ const userRoles = await prisma.userRole.findMany({
+ where,
+ include: { role: { include: { rolePermissions: { include: { permission: true } } } } },
+ });
+
+ const keys = new Set();
+ for (const userRole of userRoles) {
+ for (const rp of userRole.role.rolePermissions) keys.add(rp.permission.key);
+ }
+ return Array.from(keys);
+}
+
/** Atalho: usuário tem QUALQUER role com scope PLATFORM (ex.: platform_super_admin). */
export async function isPlatformUser(userId: string): Promise {
const prisma = getPrismaClient();