Skip to content

Guia frontend — O modelo de escopo do Gryd (plataforma · tenant · grupo) ​

Para quem: time de frontend do Gryd.UI, para ajustar o plano do GrydScopeIndicator e da camada de dados. Origem: Épico 889 (administração global vs tenant, implementado) + ADR 0006 (docs/adr/0006-group-scoped-reads.md, escopo de grupo em telas de negócio). Complementa (não substitui) o admin-scoping-guide, que tem os payloads endpoint por endpoint de users e tenants, e o group-scope-contract. Base URL /api/v1. Erros em application/problem+json.


1. Existem TRÊS escopos, não dois ​

O ponto que o plano atual do componente ainda não separa: global e group são coisas diferentes, e a diferença não é de tamanho — é de natureza.

EscopoConjunto de dadosQuem operaRotasExiste para
tenanto tenant do tokenadmin do tenant/users, /tenants, /<dominio>operação do dia a dia de uma empresa
groupo grupo do token: tenant do grupo + filhos ativosgestor do grupo/<dominio>?scope=groupconsolidar/comparar as empresas de um grupo
globaltoda a plataforma, todos os gruposoperador da plataforma (nós)/admin/*administrar o produto, suporte, auditoria

Regra mental de uma linha: global é "a plataforma"; group é "o meu grupo". Um gestor do Grupo Acme nunca terá global. Quem tem global é o nosso operador.

E uma consequência que precisa entrar no código do shell (decisão G5 do ADR 0006):

Em tela de negócio, scope=group devolve o grupo do token mesmo para um admin:system. Visão de plataforma não é um "grupo maior" — ela mora exclusivamente em /admin/*. Nunca escreva if (isSuperAdmin) → mostra tudo numa tela de negócio.

1.1 Por que users e tenants são diferentes de tudo o mais ​

User e Tenant são as duas únicas entidades do backend sem TenantId próprio — um usuário pertence a vários tenants, um tenant não pertence a um tenant. Elas são inerentemente globais, e é por isso que precisaram de duas rotas (/users + /admin/users): não existe filtro por tenant na entidade, então o trilho tinha que ser explícito na URL.

Entidades de negócio (pedidos, pendências, contratos, faturas…) têm TenantId. Para elas o filtro existe no dado, então não precisam de rota gêmea — precisam apenas dizer qual conjunto de tenants ler. É daí que sai a diferença de contrato:

users / tenantsdomínios de negócio
Como muda de escopotroca de rota (/users ↔ /admin/users)mesma rota + ?scope=tenant|group
Escopos possíveistenant e globaltenant e group
Payload muda?sim (UserDto ↔ AdminUserEditDto)não — mesmo item, mais tenantId/tenantName
Existe modo grupo?não (hoje)sim
Existe modo plataforma?simnão

Isso é a correção mais importante ao plano atual: o componente não pode assumir um único mecanismo de troca de escopo. Em users/tenants o escopo é qual base de endpoint; em negócio é qual valor de query param. O indicador é o mesmo; a camada de dados por baixo não é.


2. GET /auth/session — a única fonte de verdade ​

Um vocabulário só, quatro valores, e por domínio o conjunto permitido mais o default:

jsonc
{
  "userId": "3f1c…",
  "email": "user@acme.com",
  "roles": ["GroupAdmin"],
  "permissions": ["read:users", "read:tenants", "read:group-analytics"],
  "capabilities": {
    // entidades inerentemente globais → trilho por ROTA
    "users":      { "scopes": ["tenant"],           "default": "tenant" },
    "tenants":    { "scopes": ["tenant"],           "default": "tenant" },
    // domínios de negócio → trilho por QUERY PARAM
    "pendencias": { "scopes": ["tenant", "group"],  "default": "tenant" },
    "faturas":    { "scopes": ["tenant"],           "default": "tenant" }
  }
}

Mudanças em relação ao que está escrito hoje nos dois lados:

AntesAgoraPor quê
users.scope: "global"|"tenant"|"none"users.scopes: [...] + default, e sem acesso ⇒ domínio ausenteum vocabulário só, uma grafia por fato
tenants.scope: "global"|"own"tenants.scopes: [...] — own deixou de existirown era outro nome para tenant
proposta do handoff: "consolidated""group"consolidated não dizia de que
scope singular (o máximo)scopes[] + defaultanunciar o conjunto é o que faz a rota both degradar sozinha

Nunca derive escopo de permissions. O array continua na resposta (útil para depurar e para esconder botões finos), mas quem decide trilho é capabilities — é decisão de arquitetura, não preferência de estilo: reimplementar autorização em TypeScript garante divergência.

2.1 Semântica exata de cada valor ​

ValorSignificaO que a UI faz
(domínio ausente do mapa)não tem acesso ao domínioesconder a área inteira
tenantopera dentro do tenant do tokenrota escopada / ?scope=tenant
grouppode consolidar o grupo do tokenoferece o modo grupo no indicador
globalopera toda a plataformarota /admin/*

Sem acesso = domínio ausente, decidido na US 4.3. O valor none existe no vocabulário do servidor, mas não chega ao payload: ausente, scopes: [] e "none" seriam três grafias do mesmo fato, e ausente é a única que o cliente trata sem caso especial. Leia sempre com encadeamento opcional:

ts
const scopes = capabilities[domain]?.scopes ?? [];   // domínio ausente → nenhum escopo
const canGroup = scopes.includes("group");

default está sempre contido em scopes — o servidor recusa a combinação contrária na construção, então a UI não precisa validar.

Se scopes contém tenant e group → é o caso "alternável" do design (indicador sólido com chevron). Se contém só group → caso "fixo" (tracejado + selo). Se contém só tenant → o indicador não aparece; o TenantSwitcher atual segue como está.

2.2 Capability não garante 200 ​

Isso já valia no 889 e continua valendo: o trilho escopado mantém o gate do endpoint. Um caller com capabilities.pendencias?.scopes = ["tenant","group"] ainda recebe 403 em GET /pendencias se não tiver read:pendencias. Roteie pelo scope; trate o gate de cada endpoint normalmente.


3. Como a tela pede escopo de grupo ​

GET /api/v1/pendencias?scope=tenant       # default, pode omitir
GET /api/v1/pendencias?scope=group
GET /api/v1/pendencias/resumo?scope=group

Uma rota por domínio serve os dois modos. Não existe /pendencias/consolidado — isso era a premissa do handoff e mudou (ADR 0006, decisão G2). O motivo é direto: rota gêmea faz cada tela nova re-pagar rota + query + handler + testes, e é justamente o oposto de "telas nascem com a opção".

ValorResultado
ausentetenant
tenanttenant do token
groupgrupo do token, se autorizado
outro400 SCOPE_INVALID

O frontend nunca envia ids de tenant. Manda a palavra; o servidor resolve o conjunto a partir do token. Isso elimina por construção a classe de bug "cliente pede tenant que não é dele".


4. O que vem na resposta em modo grupo ​

4.1 Bloco scope (em toda resposta com scope=group) ​

jsonc
"scope": {
  "mode": "group",
  "groupId": "8f2a…",
  "groupName": "Grupo Acme",
  "tenantCount": 7
}

É daqui que o indicador tira o nome do grupo e a contagem — não invente a contagem somando o que veio na página, e não use o número de vínculos do TenantSelector (são conjuntos diferentes: vínculos do caller ≠ membros do grupo).

Existe um quinto campo, truncated, que só aparece quando a leitura foi cortada por exceder o teto de tenants por escopo (default 200, folgado de propósito — na prática você nunca vai vê-lo). Quando ele vem:

  • vem true, nunca false — leitura completa omite o campo, então trate a presença como o sinal;
  • tenantCount passa a ser o limite aplicado, não o tamanho do grupo;
  • o resultado é parcial e a UI precisa dizer isso. Um consolidado que não cobriu o grupo inteiro, exibido como se tivesse coberto, é a pior forma de errar um número que vai alimentar decisão de gestão.

Semântica completa em group-scope-contract.md §6.

4.2 Agregado / sumário ​

Uma chamada devolve o total e o detalhamento por organização:

jsonc
{
  "scope": { "mode": "group", "groupId": "…", "groupName": "Grupo Acme", "tenantCount": 7 },
  "total":    { "pending": 4812, "overdue": 311 },
  "byTenant": [
    { "tenantId": "…", "tenantName": "Acme SP",        "pending": 1204, "overdue": 88 },
    { "tenantId": "…", "tenantName": "Acme Joinville", "pending":  902, "overdue": 41 }
  ]
}

Não faça N chamadas, uma por tenant — o backend resolve em uma query. byTenant existe exatamente para alimentar o requisito do design ("o dado precisa dizer de onde veio") em cards, gráficos e legendas.

4.3 Listagem de registros ​

Mesmo envelope PagedResult<T> do modo tenant — mesmos filtros, ordenação e paginação — com dois acréscimos: cada item carrega tenantId e tenantName, e a resposta carrega o bloco scope. A coluna/legenda de organização é obrigatória na tabela em modo grupo.

⚠️ Paginação profunda em modo grupo pode migrar para cursor em domínios de volume alto. Se isso acontecer no seu domínio, o contrato de paginação daquele endpoint muda e será documentado nele — não trate page/pageSize como eterno em modo grupo.


5. Quem enxerga o modo grupo (e por que são duas permissões) ​

Server-side, e duas permissões, porque somar não é o mesmo ato que ler as linhas das empresas irmãs:

PermissãoLiberaExemplo
read:group-analyticsagregados: totais, contagens, byTenant"Joinville tem 300 atrasadas, SP tem 12"
read:group-datalistagem de registros de outros tenants do grupoabrir as 300 linhas e ver cliente, valor, contrato

Ambas são avaliadas no tenant do grupo (quem lê o grupo tem papel no grupo), e a role GroupAdmin as recebe no provisionamento. Um Admin de uma filha não passa a ver as irmãs.

Consequência prática para o componente: um mesmo domínio pode ter o card de resumo com scopes: ["tenant","group"] e a tabela só com tenant. Se sua tela tem as duas coisas, o backend do domínio vai expor duas capabilities (ex. pendencias.summary e pendencias) e a tela precisa tratá-las separadamente: resumo consolidado com tabela escopada ao tenant é um estado válido, não um bug.


6. Matriz de decisão do componente ​

Dois eixos (capacidade da rota × escopo ativo) mais o gate da capability — o que o plano do frontend já modelou. O que muda são os valores e o que cada célula dispara na camada de dados.

Capacidade da rotascopes do domínioEscopo ativoSlot central renderizaCamada de dados
tenantqualquer—GrydTenantSwitcher (sem opção de grupo)?scope=tenant (ou omitido)
both["tenant","group"]tenantGrydTenantSwitcher com "Todo o grupo" no topo?scope=tenant
both["tenant","group"]groupGrydScopeIndicator alternável (sólido + chevron)?scope=group
both["tenant"]qualquerGrydTenantSwitcher puro (degrada)?scope=tenant
group["group"]qualquerGrydScopeIndicator fixo (tracejado + selo)?scope=group
group["tenant"]qualquerrota não deve estar no menu → GrydAccessDeniedPage em deep-link—
— (users/tenants)["global"]—sem indicador de grupobase /admin/*
— (users/tenants)["tenant"]—GrydTenantSwitcherbase /users, /tenants

Renomeação necessária: o valor global do RouteScopeCapability do shell passa a se chamar group, para não colidir com global = plataforma no vocabulário de capabilities. RouteScopeCapability = 'tenant' | 'group' | 'both'.


7. Texto e i18n — o rótulo muda ​

Consolidado agora é o grupo do token, não "todas as organizações do usuário". Os vínculos do caller podem cruzar grupos distintos, e nesse caso "todas as organizações" não descreve o que a API devolve. Então o rótulo tem de falar de grupo, e o nome do grupo está disponível (§4.1) — use.

scope.group.label    pt "Todo o grupo"            en "Entire group"          es "Todo el grupo"
scope.group.sub      pt "{groupName} · {count} ORGANIZAÇÕES"
                     en "{groupName} · {count} ORGANIZATIONS"
                     es "{groupName} · {count} ORGANIZACIONES"
scope.group.short    pt "GRUPO · {count}"          en "GROUP · {count}"       es "GRUPO · {count}"
scope.group.fixed    pt "FIXO"                     en "FIXED"                 es "FIJO"
scope.group.loading  pt "CARREGANDO ESCOPO…"       en "LOADING SCOPE…"        es "CARGANDO ÁMBITO…"
scope.group.hint     pt "Esta tela consolida os dados de todas as organizações do grupo {groupName}."

Regras de plural (count === 1 → "1 ORGANIZAÇÃO") seguem valendo nas três línguas. Aviso de layout: {groupName} é texto do cliente e pode ser longo — a sublinha precisa de ellipsis e o nome deve ser truncado antes da contagem (a contagem é a informação que não pode desaparecer). No modo compacto, scope.group.short já omite o nome.

As chaves scope.global.* do plano atual devem ser removidas, não renomeadas — não haverá modo "global" em tela de negócio.


8. Query keys ​

ts
// tenant: ['pendencias', 'tenant', tenantId, filters]
// grupo:  ['pendencias', 'group',  groupId,  filters]

groupId na key (e não uma constante) evita cache cruzado quando o usuário faz switch-tenant para um tenant de outro grupo. Isso vale para hooks novos, para @gryd-ui/admin-api e para os adapters do @gryd-ui/crud em telas both.

Obrigatório: trocar de escopo invalida/refaz as queries. Nenhum dado do modo anterior pode ser servido de cache — é o item de QA mais fácil de esquecer e o mais visível quando falha.


9. Erros e degradação ​

SituaçãoHTTPcodeO que a UI faz
scope inválido400SCOPE_INVALIDbug de cliente — logar/reportar
tenant do token sem grupo422SCOPE_GROUP_UNAVAILABLEnão deveria ocorrer (capability não teria anunciado group): degradar para tenant + reportar
sem a permissão do modo403SCOPE_NOT_AUTHORISEDcapability desatualizada: degradar para tenant, re-GET /auth/session
registro fora do escopo404anti-disclosure"não existe" — nunca "sem acesso"
token invalidado401—refresh → re-/session → re-derivar capabilities

Fluxo de degradação (mantém o que o plano já previa, agora com gatilho explícito): perdeu group em scopes → activeScope volta a {type:'tenant', tenantId} silenciosamente, sem modal, sem navegação. O mesmo caminho serve para o 403/422 acima.

Depois de todo switchTenant: re-chamar GET /auth/session e re-derivar. O grupo pode ter mudado (ou deixado de existir) no novo tenant.


10. Invariantes que não mudam ​

Escrito para ninguém reabrir:

  1. O token é sempre tenant token. Escopo não é claim, não é header, não é re-autenticação. O api-client não muda.
  2. Sempre existe tenant ativo. TenantGuard intocado. Rota de grupo não redireciona para seleção de tenant.
  3. Selecionar "Todo o grupo" não chama switch-tenant, não troca token, não re-autentica — só muda activeScope (persistido).
  4. Escopo é apresentação e navegação. Autorização é do backend, por gate de endpoint.
  5. Escopo nunca fica invisível: no mobile o indicador colapsa, não desaparece.
  6. Resolução do escopo efetivo é síncrona (função pura de rota + estado em memória) → zero flicker no primeiro paint.
  7. Trocar de escopo não desloca o header (mesma métrica 300×42 nas duas peças).
  8. Escrita nunca acontece em escopo de grupo. O backend recusa estruturalmente; a UI não deve nem oferecer ação de escrita em modo grupo (esconda, não desabilite com tooltip).

11. O que muda no plano do frontend, item por item ​

Seu itemSituaçãoAção
§1-2 modelo de dois eixos✅ mantidonenhuma
RouteScopeCapability = 'tenant'|'global'|'both'⚠️ renomear'global' → 'group'
ActiveScope = {type:'global'}⚠️ renomear{type:'group'}
capabilities.<dom>.scope: 'consolidated'|…❌ mudouscopes: string[] + default (§2)
§10.1 endpoint /pendencias/consolidado❌ mudoumesma rota + ?scope=group (§3)
"Todas as organizações" = vínculos do caller❌ mudou= grupo do token (§1)
orgCount vindo da lista de tenants do auth❌ mudouvem de scope.tenantCount da resposta (§4.1)
Rótulos scope.global.*❌ mudouscope.group.*, com {groupName} (§7)
§10.2 query keys com escopo✅ mantidotrocar 'consolidated' por 'group' + groupId
§10.3 coluna de organização✅ mantidoagora vem pronta em tenantId/tenantName
§11 ShellContextProps.effectiveScope✅ mantidotipo passa a {mode:'tenant'|'group'}
§11 activeTenantId sempre presente✅ mantidonenhuma
§12 dependências de backend✅ resolvidoADR 0006 + backlog; fatias 1-2 do backend desbloqueiam suas fatias 1-3
§16 alternativas descartadas✅ mantidoacrescentar: rota gêmea e header foram avaliados e descartados no ADR 0006
Fatia 3 (UI)⚠️ ajusteum estado novo: domínio com resumo em grupo e tabela em tenant (§5)
Fatia 4 (integração)⚠️ ajustepílula do breadcrumb e document.title usam nome do grupo, não "Global"

Sugestão de sequência (casa com as fatias do backend) ​

  1. Suas fatias 1-2 (contratos, resolver, provider, estado) podem começar agora, com capabilities mockada no formato do §2 — não dependem de backend.
  2. Fatia 3 (componente, tokens, HeaderScopeSlot) também é independente; só precisa dos rótulos do §7 e do estado extra do §5.
  3. Fatia 4 e a primeira tela real dependem das fatias 1-3 do backend (mecanismo, entitlement, contrato de capability) e da fatia 6 (domínio de referência) para haver dado de verdade.

12. Checklist de QA (delta sobre o que já existe) ​

Mantém os 10 itens do plano atual, com estes ajustes e acréscimos:

  1. Indicador mostra nome do grupo e contagem vindos de scope, não calculados no cliente.
  2. Grupo com 1 organização: "1 ORGANIZAÇÃO", sem plural quebrado.
  3. Nome de grupo longo: trunca o nome, preserva a contagem; compacto usa GRUPO · N.
  4. scopes: ["tenant"] num domínio: indicador ausente, seletor sem opção de grupo, nenhuma chamada com ?scope=group.
  5. Domínio com resumo em grupo e tabela em tenant renderiza os dois estados juntos, sem erro.
  6. Tabela em modo grupo sempre com coluna/legenda de organização.
  7. Trocar tenant → grupo → tenant sem "pulo" de layout no header.
  8. Trocar de escopo invalida o cache: nenhuma linha do modo anterior na tela.
  9. 403 SCOPE_NOT_AUTHORISED / 422 SCOPE_GROUP_UNAVAILABLE → degrada para tenant em silêncio + re-/session; sem modal, sem redirect.
  10. Nenhuma ação de escrita visível em modo grupo.
  11. admin:system numa tela de negócio em modo grupo vê o grupo, não a plataforma (teste com um operador global logado num tenant de grupo).
  12. MFE em rota de grupo recebe effectiveScope.mode === 'group' e não quebra.

Released under the MIT License.