Appearance
Guia frontend — O modelo de escopo do Gryd (plataforma · tenant · grupo)
Para quem: time de frontend do Gryd.UI, para ajustar o plano do
GrydScopeIndicatore 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 emapplication/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.
| Escopo | Conjunto de dados | Quem opera | Rotas | Existe para |
|---|---|---|---|---|
tenant | o tenant do token | admin do tenant | /users, /tenants, /<dominio> | operação do dia a dia de uma empresa |
group | o grupo do token: tenant do grupo + filhos ativos | gestor do grupo | /<dominio>?scope=group | consolidar/comparar as empresas de um grupo |
global | toda a plataforma, todos os grupos | operador 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=groupdevolve o grupo do token mesmo para umadmin:system. Visão de plataforma não é um "grupo maior" — ela mora exclusivamente em/admin/*. Nunca escrevaif (isSuperAdmin) → mostra tudonuma 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 / tenants | domínios de negócio | |
|---|---|---|
| Como muda de escopo | troca de rota (/users ↔ /admin/users) | mesma rota + ?scope=tenant|group |
| Escopos possíveis | tenant e global | tenant e group |
| Payload muda? | sim (UserDto ↔ AdminUserEditDto) | não — mesmo item, mais tenantId/tenantName |
| Existe modo grupo? | não (hoje) | sim |
| Existe modo plataforma? | sim | nã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:
| Antes | Agora | Por quê |
|---|---|---|
users.scope: "global"|"tenant"|"none" | users.scopes: [...] + default, e sem acesso ⇒ domínio ausente | um vocabulário só, uma grafia por fato |
tenants.scope: "global"|"own" | tenants.scopes: [...] — own deixou de existir | own era outro nome para tenant |
proposta do handoff: "consolidated" | "group" | consolidated não dizia de que |
scope singular (o máximo) | scopes[] + default | anunciar 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
| Valor | Significa | O que a UI faz |
|---|---|---|
| (domínio ausente do mapa) | não tem acesso ao domínio | esconder a área inteira |
tenant | opera dentro do tenant do token | rota escopada / ?scope=tenant |
group | pode consolidar o grupo do token | oferece o modo grupo no indicador |
global | opera toda a plataforma | rota /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=groupUma 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".
| Valor | Resultado |
|---|---|
| ausente | tenant |
tenant | tenant do token |
group | grupo do token, se autorizado |
| outro | 400 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, nuncafalse— leitura completa omite o campo, então trate a presença como o sinal; tenantCountpassa 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/pageSizecomo 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ão | Libera | Exemplo |
|---|---|---|
read:group-analytics | agregados: totais, contagens, byTenant | "Joinville tem 300 atrasadas, SP tem 12" |
read:group-data | listagem de registros de outros tenants do grupo | abrir 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 rota | scopes do domínio | Escopo ativo | Slot central renderiza | Camada de dados |
|---|---|---|---|---|
tenant | qualquer | — | GrydTenantSwitcher (sem opção de grupo) | ?scope=tenant (ou omitido) |
both | ["tenant","group"] | tenant | GrydTenantSwitcher com "Todo o grupo" no topo | ?scope=tenant |
both | ["tenant","group"] | group | GrydScopeIndicator alternável (sólido + chevron) | ?scope=group |
both | ["tenant"] | qualquer | GrydTenantSwitcher puro (degrada) | ?scope=tenant |
group | ["group"] | qualquer | GrydScopeIndicator fixo (tracejado + selo) | ?scope=group |
group | ["tenant"] | qualquer | rota não deve estar no menu → GrydAccessDeniedPage em deep-link | — |
| — (users/tenants) | ["global"] | — | sem indicador de grupo | base /admin/* |
| — (users/tenants) | ["tenant"] | — | GrydTenantSwitcher | base /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ção | HTTP | code | O que a UI faz |
|---|---|---|---|
scope inválido | 400 | SCOPE_INVALID | bug de cliente — logar/reportar |
| tenant do token sem grupo | 422 | SCOPE_GROUP_UNAVAILABLE | não deveria ocorrer (capability não teria anunciado group): degradar para tenant + reportar |
| sem a permissão do modo | 403 | SCOPE_NOT_AUTHORISED | capability desatualizada: degradar para tenant, re-GET /auth/session |
| registro fora do escopo | 404 | anti-disclosure | "não existe" — nunca "sem acesso" |
| token invalidado | 401 | — | 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:
- O token é sempre tenant token. Escopo não é claim, não é header, não é re-autenticação. O
api-clientnão muda. - Sempre existe tenant ativo.
TenantGuardintocado. Rota de grupo não redireciona para seleção de tenant. - Selecionar "Todo o grupo" não chama
switch-tenant, não troca token, não re-autentica — só mudaactiveScope(persistido). - Escopo é apresentação e navegação. Autorização é do backend, por gate de endpoint.
- Escopo nunca fica invisível: no mobile o indicador colapsa, não desaparece.
- Resolução do escopo efetivo é síncrona (função pura de rota + estado em memória) → zero flicker no primeiro paint.
- Trocar de escopo não desloca o header (mesma métrica 300×42 nas duas peças).
- 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 item | Situação | Ação |
|---|---|---|
| §1-2 modelo de dois eixos | ✅ mantido | nenhuma |
RouteScopeCapability = 'tenant'|'global'|'both' | ⚠️ renomear | 'global' → 'group' |
ActiveScope = {type:'global'} | ⚠️ renomear | {type:'group'} |
capabilities.<dom>.scope: 'consolidated'|… | ❌ mudou | scopes: string[] + default (§2) |
§10.1 endpoint /pendencias/consolidado | ❌ mudou | mesma 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 | ❌ mudou | vem de scope.tenantCount da resposta (§4.1) |
Rótulos scope.global.* | ❌ mudou | scope.group.*, com {groupName} (§7) |
| §10.2 query keys com escopo | ✅ mantido | trocar 'consolidated' por 'group' + groupId |
| §10.3 coluna de organização | ✅ mantido | agora vem pronta em tenantId/tenantName |
§11 ShellContextProps.effectiveScope | ✅ mantido | tipo passa a {mode:'tenant'|'group'} |
§11 activeTenantId sempre presente | ✅ mantido | nenhuma |
| §12 dependências de backend | ✅ resolvido | ADR 0006 + backlog; fatias 1-2 do backend desbloqueiam suas fatias 1-3 |
| §16 alternativas descartadas | ✅ mantido | acrescentar: rota gêmea e header foram avaliados e descartados no ADR 0006 |
| Fatia 3 (UI) | ⚠️ ajuste | um estado novo: domínio com resumo em grupo e tabela em tenant (§5) |
| Fatia 4 (integração) | ⚠️ ajuste | pílula do breadcrumb e document.title usam nome do grupo, não "Global" |
Sugestão de sequência (casa com as fatias do backend)
- Suas fatias 1-2 (contratos, resolver, provider, estado) podem começar agora, com
capabilitiesmockada no formato do §2 — não dependem de backend. - Fatia 3 (componente, tokens,
HeaderScopeSlot) também é independente; só precisa dos rótulos do §7 e do estado extra do §5. - 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:
- Indicador mostra nome do grupo e contagem vindos de
scope, não calculados no cliente. - Grupo com 1 organização: "1 ORGANIZAÇÃO", sem plural quebrado.
- Nome de grupo longo: trunca o nome, preserva a contagem; compacto usa
GRUPO · N. scopes: ["tenant"]num domínio: indicador ausente, seletor sem opção de grupo, nenhuma chamada com?scope=group.- Domínio com resumo em grupo e tabela em tenant renderiza os dois estados juntos, sem erro.
- Tabela em modo grupo sempre com coluna/legenda de organização.
- Trocar tenant → grupo → tenant sem "pulo" de layout no header.
- Trocar de escopo invalida o cache: nenhuma linha do modo anterior na tela.
- 403
SCOPE_NOT_AUTHORISED/ 422SCOPE_GROUP_UNAVAILABLE→ degrada paratenantem silêncio + re-/session; sem modal, sem redirect. - Nenhuma ação de escrita visível em modo grupo.
admin:systemnuma tela de negócio em modo grupo vê o grupo, não a plataforma (teste com um operador global logado num tenant de grupo).- MFE em rota de grupo recebe
effectiveScope.mode === 'group'e não quebra.