feat: "itens sem permissão não aparecem" — filtro de menu por permission (secao 169)

getUserPermissionKeys(userId, tenantId?) (novo, packages/auth) devolve todas as
permission keys do usuário no contexto atual, mesma query de userHasPermission só
que retornando o conjunto inteiro. GET /auth/me agora inclui permissionKeys — leitura
adicional só pra UX, nunca decisão de autorização (isso continua sendo o
PermissionGuard em cada endpoint).

NavLeaf/NavSection ganham campo opcional permission; todos os ~35 itens de menu
(tenant e platform) anotados com a permission key mínima que o endpoint GET
correspondente já exige. filterNavByPermissions() (novo, nav-types.ts) esconde o
item, ou a seção inteira se nenhum filho sobrar.

TenantSidebar/PlatformSidebar/*Topbar são client components que importam
TENANT_NAV/PLATFORM_NAV direto (não recebem como prop do server) porque os ícones
(LucideIcon, funções) não são serializáveis através da fronteira RSC — restrição já
documentada desde a PHASE 23. O filtro roda dentro desses wrappers, client-side,
recebendo só permissionKeys: string[] (serializável) como prop.

Testado ponta a ponta com um supervisor de teste de verdade (convidado, senha
trocada, logado via UI): menu perde Telefonia > Dialplan e a seção Administração
inteira (nenhum permission bate). Confirmado que a autorização real continua valendo
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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 09:17:05 -03:00
parent 6f18b3a297
commit 798aa7b579
15 changed files with 183 additions and 32 deletions

View File

@@ -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),
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -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 (
<div className="flex h-dvh overflow-hidden bg-background">
<TenantSidebar railLabel={me.tenant.name} />
<TenantSidebar railLabel={me.tenant.name} permissionKeys={me.permissionKeys} />
<div className="flex flex-1 flex-col overflow-hidden">
<TenantTopbar fallbackTitle={me.tenant.name} userLabel={me.email} />
<TenantTopbar fallbackTitle={me.tenant.name} userLabel={me.email} permissionKeys={me.permissionKeys} />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div>
</div>

View File

@@ -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 (
<div className="flex h-dvh overflow-hidden bg-background">
<PlatformSidebar />
<PlatformSidebar permissionKeys={me.permissionKeys} />
<div className="flex flex-1 flex-col overflow-hidden">
<PlatformTopbar userLabel={me.email} />
<PlatformTopbar userLabel={me.email} permissionKeys={me.permissionKeys} />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div>
</div>

View File

@@ -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",
},
],
},

View File

@@ -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 <Sidebar items={PLATFORM_NAV} railLabel="PLATFORM" />;
export function PlatformSidebar({ permissionKeys }: { permissionKeys: string[] }) {
return <Sidebar items={filterNavByPermissions(PLATFORM_NAV, permissionKeys)} railLabel="PLATFORM" />;
}

View File

@@ -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 <Topbar items={PLATFORM_NAV} fallbackTitle="Platform" userLabel={userLabel} />;
export function PlatformTopbar({ userLabel, permissionKeys }: { userLabel: string; permissionKeys: string[] }) {
return <Topbar items={filterNavByPermissions(PLATFORM_NAV, permissionKeys)} fallbackTitle="Platform" userLabel={userLabel} />;
}

View File

@@ -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

View File

@@ -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",
},
],
},

View File

@@ -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 <Sidebar items={TENANT_NAV} railLabel={railLabel} />;
* (í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 <Sidebar items={filterNavByPermissions(TENANT_NAV, permissionKeys)} railLabel={railLabel} />;
}

View File

@@ -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 <Topbar items={TENANT_NAV} fallbackTitle={fallbackTitle} userLabel={userLabel} />;
export function TenantTopbar({
fallbackTitle,
userLabel,
permissionKeys,
}: {
fallbackTitle: string;
userLabel: string;
permissionKeys: string[];
}) {
return <Topbar items={filterNavByPermissions(TENANT_NAV, permissionKeys)} fallbackTitle={fallbackTitle} userLabel={userLabel} />;
}