@userkit/react
v0.9.0
Published
Drop-in UserKit components for React: <SignIn />, <SignUp />, <UserButton />, and the hooks under them.
Maintainers
Readme
@userkit/react
Os componentes prontos do UserKit para React, e os hooks embaixo deles.
npm install @userkit/reactVersão
0.3.0, acompanhando o@userkit/js. Os três pacotes são versionados juntos: um@userkit/react0.2.x sempre pede o@userkit/js0.2.x.
Uso
import { UserKitProvider, SignIn, SignedIn, SignedOut, UserButton } from "@userkit/react";
export default function App() {
return (
<UserKitProvider publishableKey="uk_pk_live_…">
<header>
<SignedIn><UserButton /></SignedIn>
</header>
<SignedOut><SignIn /></SignedOut>
</UserKitProvider>
);
}É isso. O <SignIn /> pergunta à API o que aquele ambiente suporta — então
ligar o Google no painel acende o botão sem ninguém fazer deploy — e a página de
callback do login social é a mesma página, porque o provider termina o fluxo
sozinho quando encontra um ?code= que ele mesmo iniciou.
Os guardas
<SessionLoading><Skeleton /></SessionLoading>
<SignedIn><App /></SignedIn>
<SignedOut><SignIn /></SignedOut>
<Verified><Faturas /></Verified>Nenhum dos dois primeiros renderiza enquanto a sessão está sendo restaurada. É para isso que existe o terceiro estado: uma página que mostra "deslogado" por um frame a cada recarga é o defeito mais visível que uma biblioteca de autenticação pode ter — e ele é invisível numa conexão rápida durante o desenvolvimento.
<Verified> é para sessão com identidade provada. Ela é cortesia; quem
impede de verdade é o requireVerifiedContact do outro lado.
Os hooks
const { client, state, config } = useUserKitContext();
const contact = useContact();
const session = useSession();Os componentes são conveniência sobre os hooks, não uma parede na frente deles.
No momento em que o <SignIn /> não é o formato que você quer, você escreve o
formulário e mantém todo o resto.
A conta e os times
<header>
<SignedIn>
<CustomerSwitcher onSwitch={(customer) => router.push(`/${customer.id}`)} />
<UserButton />
</SignedIn>
</header>
<SignedIn><AccountSettings /></SignedIn>O <CustomerSwitcher /> não escreve nada ao trocar de time: ele muda o
X-Customer-Id que vai nas chamadas seguintes, e a associação por trás do header
é quem autoriza. Criar um time é a única escrita que ele tem.
Sobre o nome: o que ele troca é um customer — a entidade que paga, um customer
com vários contatos — e o componente se chama pelo que troca, como tudo que ele
toca (o callback recebe um CustomerMembership). OrganizationSwitcher e
OrganizationProfile existem como alias do mesmo componente, porque é a
palavra que quem vem de outro SDK digita quando vai procurar por ele.
O <AccountSettings /> renderiza o que o ambiente e a sessão realmente podem
fazer: num ambiente federado não aparece senha nem verificação em duas etapas —
a API responde 409 nas duas —, e o que está atrás do requireVerifiedContact
(dispositivos, segundo fator, equipe) só aparece para sessão com identidade
provada.
Flags e engajamento
<Flag name="new_checkout" fallback={<CheckoutAntigo />}>
<CheckoutNovo />
</Flag>
const ligada = useFlag("new_checkout"); // boolean, nunca null, nunca promiseuseFlag devolve um booleano e ponto: um hook que começasse em undefined
faria todo recurso com flag renderizar sua ausência por um frame e aparecer no
seguinte — o mesmo defeito que o <SessionLoading> existe para evitar uma
camada acima, só que aqui não há terceiro estado para desenhar, porque "ainda
não sei" e "desligado" são idênticos para a pessoa e só um dos dois é seguro.
<Flag> é cortesia como o <Verified>: quem impede de verdade é o servidor.
<OnboardingChecklist />
<ChangelogBadge />
<Survey />
<FeedbackButton />Cada um renderiza o que o ambiente consegue responder e nada além. O checklist
não tem botão que marque um passo — não existe endpoint que faça isso, porque um
passo é satisfeito por um fato ter acontecido ou por um direito do plano estar
valendo. O <Survey /> não tem prop que peça uma pesquisa: quem decide quem é
perguntado é a API — o que ele tem é position: center (padrão), bottom-right,
bottom-left ou inline. Só o centro escurece a página e toma o foco; os cantos
flutuam sem backdrop, porque escurecer o produto para perguntar sobre o produto é o
que faz um NPS de cinco segundos parecer um pedágio. Esc e o clique fora são "agora
não" e não gastam a entrega.
O <FeedbackButton /> aceita as mesmas quatro posições, com inline de padrão —
e nas três flutuantes o que fica no seu layout é só o botão, porque um header tem
espaço para um botão e não para uma lista de ideias com um formulário embaixo. E abrir o <ChangelogBadge /> é o que marca como lidos os
posts que ele de fato mostrou — a API recusa um post que o leitor nunca viu, em
vez de deixar um badge ser zerado por uuid.
<WhatsNew />O mesmo changelog pelo outro lado. O badge espera ser clicado; este abre
sozinho — "Enquanto você esteve fora" — com o que aquele contato não viu, e
não desenha nada quando não há nada. É a ordem das duas perguntas que
garante isso: ele lê o contador primeiro e, num zero, acaba ali — a lista nem
chega a ser pedida. O conjunto que ele mostra é o do próprio contador: não lido
e publicado depois de since, que é quando aquele contato chegou. Quem
chegou hoje não esteve fora de nada, e vê o diálogo que isso merece, que é
nenhum.
Ser mostrado é o que marca esses posts como lidos, senão o mesmo diálogo
cumprimenta a mesma pessoa amanhã com os mesmos posts. E o corpo aqui é
markdown de verdade, via <HelpMarkdown /> — que constrói elementos e nunca
HTML, então a prosa do inquilino chegando pela rede não tem onde injetar.
Todas as frases passam pelo dicionário (localization), incluindo os rótulos
acessíveis: um checklist lido em voz alta precisa dizer quais passos estão
concluídos, e cor sozinha não é um estado.
As notificações dentro do produto
<NotificationBell />
const { notifications, unread, markRead, markAllRead } = useNotifications();O que aparece aqui é o que o seu backend endereçou a esta pessoa sobre o
trabalho dela — "seu export está pronto", "a fatura falhou" — escrito em
POST /v1/notifications com a chave uk_sk_. Não há metade que componha uma:
uma rota alcançável pelo navegador deixaria qualquer página dizer qualquer coisa
a qualquer usuário seu.
O sino segue o time ativo: ele mostra as mensagens daquele time mais as que
foram endereçadas à própria pessoa, e setActiveCustomer recomeça o feed em vez
de filtrar o que está na tela — as linhas de um time não são um superconjunto das
do outro. "Marcar todas" tem o mesmo escopo, para que zerar o badge num time não
apague o que a pessoa nunca viu no outro.
Abrir o sino não marca nada como lido, e essa é a diferença para o
<ChangelogBadge />. Um post de changelog é um anúncio que todo mundo recebe, e
abrir a lista é honestamente o ato de ler; uma notificação é endereçada a uma
pessoa sobre uma coisa, e zerar dez porque alguém olhou um dropdown apaga
exatamente o estado que ela veio conferir. Cada linha é marcada quando é
acionada, e "marcar todas" é um botão que se aperta de propósito.
O painel abre como uma gaveta lateral, num portal para fora da árvore do
componente: o sino costuma morar num header sticky, e um menu absoluto ali
dentro é recortado por um overflow que este componente não enxerga.
Uma notificação pode levar a algum lugar — url, escrito pelo seu backend junto
com o título. A linha vira link, e numa SPA o onNavigate entrega o caminho ao
seu roteador em vez de recarregar a página:
<NotificationBell onNavigate={(url) => router.push(url)} />Um endereço de outra origem — o seu segundo domínio — abre em aba nova com
rel="noopener noreferrer" e nunca passa pelo onNavigate. Um clique com
modificador fica com o navegador, então o botão do meio continua abrindo aba.
O poll roda só com a aba visível — um minuto por padrão, e nada enquanto a
página está escondida: /v1/contact/* dá 600 requisições por hora por sessão, e
uma aba de fundo perguntando para sempre é bateria de terceiros gasta num badge
que ninguém está olhando. Quando a aba volta, ele pergunta na hora em vez de
esperar o intervalo que dormiu.
Aparência
<SignIn
appearance={{
logoUrl: "/logo.svg",
variables: { colorPrimary: "#5b21b6", borderRadius: "0.75rem" },
}}
/>A lista de tokens é curta de propósito: estes componentes renderizam na página
do desenvolvedor, então o que importa é o punhado de variáveis que faz eles
pertencerem ali — não um segundo design system embarcado numa biblioteca de
auth. Além disso, appearance={{ unstyled: true }} não injeta folha de estilo
nenhuma e deixa os nomes de classe (uk-*) para o seu CSS.
O tema escuro segue o prefers-color-scheme por padrão.
O que não está aqui
Nada aqui edita nome, e-mail ou avatar: /v1/contact/* não tem rota que faça
isso, e um componente para uma superfície que a API não tem é promessa, não
funcionalidade. Trocar a senha, pela mesma razão, é um link enviado para o
e-mail — é o único caminho que existe, e é ele que prova o endereço.
Estilo, tema e CSP
A folha de estilo é injetada pelo pacote — não há .css para importar. Em
React 19 ela vai como <style href precedence>: o React a sobe para o <head>,
deduplica entre todos os componentes e a emite no servidor, então uma página
renderizada no Next chega com o formulário já estilizado. Em React 18 não existe
hoisting: a folha entra por JavaScript no primeiro render do cliente, e o primeiro
paint de uma página SSR é um formulário sem estilo até a hidratação.
Os tokens (--uk-color-primary, --uk-radius, …) têm o padrão claro na própria
folha e o escuro num bloco prefers-color-scheme: dark. O elemento só carrega os
tokens que alguém definiu — o appearance.variables ou a paleta do tenant —
e por isso o modo escuro funciona: um valor inline vence qualquer regra, e um
padrão inline seria um bloco escuro que nunca se aplica.
<SignIn appearance={{ theme: "dark" }} /> // decide aqui, ignora o sistema
<SignIn appearance={{ theme: "light" }} />
<SignIn appearance={{ variables: { colorPrimary: "#0b5cff" } }} />appearance.theme escreve data-uk-theme na raiz — é o que um app com o tema
numa classe do <html> (Tailwind, shadcn) precisa, porque o sistema pode dizer
uma coisa e o botão no cabeçalho outra.
unstyled é por página, não por componente: a folha é uma só, e as classes
são as mesmas. Um <SignIn appearance={{ unstyled: true }} /> ao lado de um
<UserButton /> estilizado continua recebendo as regras que o outro trouxe.
Content-Security-Policy
Uma style-src sem 'unsafe-inline' bloqueia a folha em silêncio. Passe o nonce
da página:
<UserKitProvider publishableKey="…" nonce={nonce}>No cliente ele vai para todo <style> que o pacote escreve. No servidor o React
descarta o atributo de um <style> hoisted e aplica a opção do próprio render —
nonce: { style } em renderToPipeableStream/renderToReadableStream (React
19.1+), que o Next preenche a partir do cabeçalho CSP da página. Fora do React,
setMountTarget({ nonce }) é a mesma configuração.
Avançado
<MountTargetProvider value={{ styles, portal }}>diz onde uma subárvore desenha o que não é filho: a folha de estilo e os portais dos flutuantes. É para quem monta estes componentes dentro de um shadow root — a folha não atravessa o limite e um portal paradocument.bodyescapa dele. É um contexto e não uma configuração do pacote porque a resposta é da subárvore: na mesma página o painel de ajuda vive no shadow root e um<SignIn />vive no documento.setMountTargetcontinua exportado para o nonce, que é da página;styleseportalpassados a ele avisam no console e não fazem nada.UserKitContexté exportado para um teste que quer entregar um valor pronto aos componentes. Em código de produto useuseUserKitContext(), que lança fora de um provider em vez de devolvernull.@userkit/react/localesentregaptBReenUScomo dados, sem a diretiva"use client"— para um Server Component que quer ler uma frase.useOnboardingGuide(),useHelpWidget(),useNotificationBell()eusePoints()são o estado dos drop-ins, para um botão seu.Positioné o vocabulário de posição dos flutuantes (center,bottom-right,bottom-center,bottom-left,inline);Position,PositionePositionsão o mesmo tipo com os nomes que já saíram.- O pacote é ESM-only: não há
require. Jest em CJS puro precisa detransformIgnorePatternspara@userkit/. - As props do
<UserKitProvider>são um tipo largo, não uma união das três formas (publishableKey,mode: "proxy",client): o@userkit/nextjsenvolve o componente e espalha as próprias props, e uma união discriminada não sobrevive a umOmitmais um spread. Faltar as três é um erro em runtime, na construção do client.
