Papéis, e o vocabulário é seu
owner e member são semeados em todo ambiente, não podem ser apagados nem
renomeados, e são os dois que a claim customer_role do JWT carrega desde
sempre. Ao lado deles, você define os seus — uma escola tem diretor,
secretaria, professor e aluno, e isso não cabe em um booleano.
Um papel carrega permissões de dois vocabulários na mesma lista:
Esse segundo vocabulário é o ponto: seu backend valida
grades:write offline,
com o JWT que já tem em mãos, e criar um papel novo nunca vira uma edição no
seu código. Um $ que não seja uma das quatro capabilities é recusado na
escrita — um papel que parece conceder algo e não concede é descoberto por quem
ele barrou.
O catálogo é por ambiente (um papel é atribuído a contatos, e contatos
pertencem a um ambiente) e se administra em
/v1/organization/customer-roles, com customers:read e customers:write.
Isto é diferente de papéis e permissões,
que são os do seu time dentro do painel.
Ler o catálogo de dentro do time
Escrever papéis é do painel. Ler também é do customer, porque quem convida alguém precisa poder escolher o papel que pretende dar:?environment= e
não poderia haver: a credencial do contato já diz em qual metade da organização
ele está.
Exige $team.manage no customer ativo e responde 403 sem ela. A lista serve
para convidar e reatribuir, então quem não pode fazer nem uma coisa nem outra
não tem o que fazer com ela — e ela é o seu vocabulário interno, não um fato
sobre este time. Também não vem contagem de membros: esse número atravessa
todos os times do ambiente.
Devolva a key ao convidar ou ao trocar um papel. No SDK é
userkit.listCustomerRoles(), e o <CustomerProfile /> já preenche os dois
selects de papel a partir dela.
Três invariantes vêm com os papéis:
- Um customer nunca fica sem quem o administre. Rebaixar, remover ou sair
responde
409 last_ownerquando a pessoa é a última cujo papel tem$team.manage— a conta é pela capability, não pelo nome: um “diretor” que administra o time conta, e um owner de quem você tirou a capability não. ownernão pode abrir mão de$team.manage. Qualquer outra edição no papel é sua — inclusive tirar o$billing.managedele. Essa uma responde409 owner_must_manage_the_team, porque nenhuma associação mudaria e mesmo assim todo time do ambiente ficaria sem administrador de uma vez.- Você não pode trocar o seu próprio papel. É assim que alguém se dá tudo.
key de um papel é imutável: é o que seu código compara e o que o JWT
carrega, então renomeá-la quebraria o seu gate sem erro nenhum do nosso lado.
Renomear é criar outro papel e mover as pessoas — duas chamadas que você vê
acontecendo. O name é só exibição e esse muda à vontade.
Editar não sobrescreve
As permissões de um papel são versionadas. Toda edição escreve uma versão nova e imutável, e o papel passa a apontar para ela — a versão anterior continua lá.GET /v1/organization/customer-roles/{id}/versions lista o histórico, e
POST …/versions/{id}/restore volta o ponteiro para uma delas.
Voltar não escreve nada: a versão que estava valendo continua guardada, então
restaurar também é reversível. É isso que torna seguro editar um papel de um
script, de um agente ou de uma tela com pressa — o conjunto que você acabou de
substituir está a uma chamada de distância.
O customer ativo
Um contato pode pertencer a vários customers, então toda chamada diz dentro de qual ela está agindo — no headerX-Customer-Id.
active_customer_id guardado na sessão.
Um id de customer que alguém cole não casa com nenhuma linha de vínculo e
responde
404 — o mesmo 404 de um customer que não existe. Revogar um
vínculo vale na próxima requisição, não no próximo login.Não existe um cursor compartilhado para uma aba mover debaixo da outra.
As claims seguem o que esta emissão pediu.
setActiveCustomer descarta o
JWT em cache junto com a escolha, então a emissão seguinte já nomeia o time
novo — e quem emite na mão precisa fazer o mesmo. Veja O time dentro do
JWT.GET /v1/contact/customers:
POST /v1/contact/customers e aceitar um convite com
POST /v1/contact/invitations/accept respondem uma dessas em customer, então o
cliente descobre o que o assento novo CONCEDE na mesma chamada que o criou.
Onde a escolha vive no seu app
A API não guarda customer ativo, então é o seu app que decide onde a escolha sobrevive. O SDK guarda em memória enquanto a página existir: um reload cai de volta no vínculo mais antigo, o que é correto e não é persistência. Coloque na URL. Ela sobrevive a um reload, faz o botão voltar significar alguma coisa, é um link que alguém pode mandar e — a parte que importa — duas abas em dois times continuam independentes, que é justamente a propriedade pela qual a API abriu mão de guardar umactive_customer_id. Um cursor em
localStorage devolve essa propriedade na hora, e ainda por cima com a barra de
endereço dizendo outra coisa.
href diz onde cada time mora e faz os itens virarem links de verdade;
navigate entrega um clique comum ao seu router. Sem navigate, o navegador
segue o link, o que é um carregamento de página inteira — o padrão honesto para
um app que não tem router próprio. navigate sozinho não compila: a URL que ele
navegaria é a que href constrói.
Depois leia o time da rota e diga ao cliente, uma vez, onde esta página está:
setActiveCustomer não grava nada em lugar nenhum — muda qual X-Customer-Id a
próxima requisição carrega. Um id que não casa com nenhum vínculo deste contato
responde 404 na próxima leitura em vez de conceder alguma coisa, então uma URL
editada na mão é recusada pela mesma regra que recusa um header colado.
Um cmd+clique em um time abre em outra aba e deixa a atual onde estava, de
propósito: aquela aba lê o time da URL que ela abriu, e trocar aqui moveria o
time de alguém debaixo da pessoa que ainda está olhando para esta. É isso que
uma âncora com
href de verdade compra e que um onClick não consegue imitar./ sem time na URL. Só não pode ser a resposta
para “de qual time é esta página”, ou a URL começa a mentir.
A tela do time
<CustomerSwitcher /> escolhe o time; <CustomerProfile /> administra o
que foi escolhido — quem está dentro, quem foi convidado, e os controles que só
o proprietário vê:
useRouter na segunda linha —
importe do @userkit/nextjs: são os mesmos dois componentes, e em
modo proxy toda a superfície do time é
encaminhada pelos handlers, com a sessão num cookie httpOnly da sua própria origem. O
X-Customer-Id atravessa junto e continua identificando sem nunca autorizar — um id
que não casa com nenhuma associação do contato do cookie responde 404 lá em cima.
onLeft existe porque sair do time é a única ação aqui cujo sucesso esvazia a
página inteira: a tela some e o app precisa levar a pessoa para algum lugar.
É a mesma seção que <AccountSettings /> desenha quando team está ligado —
um componente só, montado com a moldura da tela de conta ou com a própria. Um
produto com rota de time própria desliga lá e monta este aqui:
<CustomerProfile /> é o mesmo componente com o nome do domínio, do mesmo jeito
que <CustomerSwitcher />.
O time dentro do JWT
POST /v1/contact/token coloca o customer ativo no
JWT de sessão, para o seu backend
responder “qual time, e essa pessoa pode administrá-lo” offline:
customer_permissions é a claim que sustenta a frase lá de cima: o gate é
claims.customer_permissions.includes("grades:write"), não uma comparação com
o nome do papel — que muda quando o tenant cria mais um, e a mudança seria no
seu código. A referência completa das claims está em
Tokens de sessão.
Mande X-Customer-Id no mint e as claims seguem. Nomear um customer ao qual o
contato não pertence é recusado com 404 em vez de simplesmente omitir a claim
— um token sem o campo que o seu backend usa como porta é pior que um erro.
As três claims ficam ausentes quando o contato não pertence a customer nenhum.
Trate ausente como “sem time”, nunca como “sem necessidade de checar”.
Trocar de time é emitir de novo
Um JWT já emitido nomeia o time em que foi emitido, e nada o alcança depois — é a mesma propriedade que faz a revogação precisar de um segundo documento. Então quem troca de time precisa descartar o token em cache, e é o quesetActiveCustomer faz por você: o cache do
cliente vai junto com a escolha, e a próxima getToken() emite outro.
No servidor não há time ativo para o SDK guardar: getToken({ customerId }) do
@userkit/nextjs recebe o id que a sua rota já leu. Sem ele o token nomeia o
vínculo mais antigo, que é um time de verdade e raramente o da página.
Abrindo um time
identity_required).
O seu próprio servidor também pode criar customers direto, que é o formato usual
no modo federado — lá o seu produto já sabe
quem trabalha com quem.
Do lado do servidor, com a chave secreta
Comuk_sk_… o seu backend espelha e lê de volta a estrutura de contas:
O ambiente sai da chave e nenhum campo da requisição o move: uma chave live
perguntando por um time de teste recebe
404, e é o mesmo 404 de um id que
não existe. O GET /v1/organization/customers não serve aqui — ele é do
painel, exige sessão de staff e recebe o ambiente como parâmetro de
visualização, que é a postura oposta.
O quadro não traz convites pendentes: um assento oferecido é oferecido e
revogado de dentro do time, e GET /v1/contact/customer/members responde as
duas metades para quem está nele.
Convites
A resposta não diz nada sobre o endereço já ter conta com você. O único conflito
que ela reporta —
already_member — é participação no time de quem chamou, que
essa pessoa já lê na lista do time. Todo outro caso segue o caminho idêntico e
produz a resposta idêntica.POST /v1/contact/customer/invitations/{id}/resend rotaciona o hash e a
validade, então o link da mensagem anterior para de funcionar: um convite é uma
credencial, e nunca pode haver duas vivas para o mesmo assento. Revogar
(DELETE …/{id}) fecha o assento de vez.
Aceitar exige duas provas
POST /v1/contact/invitations/accept precisa de um contato autenticado e do
token.
O token prova o convite — que este assento foi oferecido a este endereço. A
sessão prova a pessoa. Nenhum dos dois basta sozinho: um link encaminhado
sentaria quem o abrisse.
Por isso o endereço convidado precisa ser uma identidade do contato que
aceita, ou a chamada responde 403 invitation_not_yours. É a mesma regra que
todo fluxo por endereço deste plano já segue: um e-mail é um atributo até algo
transformá-lo em aresta de identidade,
e um atributo é algo que qualquer um digita.
Um endereço vira aresta por um magic link, por um código de seis dígitos, ou
pelo seu próprio servidor chamando POST /v1/contacts com ele. Passar um
endereço para /v1/boot deliberadamente não faz isso.
Eventos
Toda mutação publica um fato que você consome pelos eventos:
Payloads carregam ids, nunca endereços — o e-mail fica fora de toda fila e todo
log por onde o envelope passa.