Login Magic-Link Multi-Cliente — Site Público Abracaf (Next.js Supabase)
Fluxo de login passwordless (magic-link) multi-cliente do SuperApp (TEC-193). É white-label por associação: o slug do cliente chega como segmento de path [team] (injetado pelo proxy a partir do subdomínio), define o logo e o acento de tema, e cada tela do protótipo Figma é renderizada em pt-BR. É um fluxo apenas de entrada (sign-in) — modelo por convite, sem cadastro.
Visão geral
O login não usa senha. O associado informa o e-mail, recebe um link de acesso por e-mail e, ao clicar, é autenticado e redirecionado para a área logada. Todo o fluxo é client-side por cima do signInWithOtp do Supabase, encapsulado no componente LoginFlow.
O fluxo é multi-cliente / white-label: o mesmo código serve todas as associações registradas (Abracaf, ABCN, Abrare, Belite, Fidelidade, Ganhaz, Vona). O que muda por cliente é o slug — primeiro segmento de path, equivalente ao [team] — que determina o logo exibido, o atributo data-theme (acento de cor) e o caminho de callback do magic-link.
Estrutura de arquivos
apps/web/
├── config/
│ └── clients.config.ts # Registry de clientes (getClient, isValidClient)
├── proxy.ts # DOMAIN_SLUG_MAP + GLOBAL_PATHS + redirect /home logado-out
├── app/[locale]/
│ ├── auth/confirm/route.ts # Verificação GLOBAL do token do magic-link
│ └── [team]/
│ ├── layout.tsx # Valida slug (404), provê contexto, aplica data-theme
│ ├── _lib/
│ │ ├── current-client-context.tsx # CurrentClientProvider / contexto React
│ │ └── use-current-client.ts # Hook useCurrentClient
│ ├── _components/
│ │ └── client-logo.tsx # Logo do cliente (imagem ou texto)
│ └── auth/
│ ├── layout.tsx # AuthLayoutShell + logo do cliente
│ ├── sign-in/page.tsx # callbackPath cliente-aware
│ ├── callback/error/page.tsx # Tela "Link expirado ou inválido" (temada)
│ └── _components/
│ ├── login-flow.tsx # Máquina de estados do fluxo
│ └── auth-message-screen.tsx # Card de estado (título + texto + ações)
├── i18n/messages/pt-BR/auth.json # magicLink.*, signInTermsNotice, errors.*
└── styles/theme.abracaf.css # Tokens --abc-* (:root) + acento [data-theme='abracaf']
Roteamento: subdomínio até a tela de login
A rota real do login é app/[locale]/[team]/auth/sign-in. O caminho até ela depende de como o usuário chega:
- Domínio de aplicação do cliente (ex.:
emplacamento.abracaf.com.br): oproxy.tsconsulta oDOMAIN_SLUG_MAP, descobre o slug (abracaf) e reescreve o pathname para/{defaultLocale}/{slug}/.... O usuário vêemplacamento.abracaf.com.br/..., mas internamente a rota resolve com o slug no segmento[team]. - Usuário deslogado em
/home: o handler de/home/*no proxy redireciona para o login. O caminho é cliente-aware: se o host estiver noDOMAIN_SLUG_MAP, monta/{slug}/auth/sign-in?next=...; caso contrário, cai nopathsConfig.auth.signInpadrão. - Acesso direto pelo path: navegar para
/{slug}/auth/sign-intambém funciona em qualquer ambiente.
Ao resolver [team], o layout.tsx do segmento valida o slug contra o registry. Slug desconhecido → notFound() (404).
O caminho /auth/confirm está listado em GLOBAL_PATHS no proxy e não recebe o prefixo do slug. É a landing do magic-link e precisa resolver igual em localhost e em domínio custom. Ver a seção Fim a fim.
Identidade do cliente (slug, contexto e tema)
O registry de clientes fica em config/clients.config.ts. Cada entrada é um ClientConfig:
export interface ClientConfig {
slug: string;
name: string;
dataTheme: string;
logoSrc?: string;
}
| Campo | Papel |
|---|---|
slug | Identificador na URL (segmento [team]) |
name | Nome exibido e fallback de logo em texto |
dataTheme | Valor de data-theme aplicado no wrapper do [team] — ativa o CSS de tema do cliente |
logoSrc | Caminho do logo; ausente → BrandLogo renderiza o name em texto |
Clientes registrados: abracaf, abcn, abrare, belite, fidelidade, ganhaz, vona. Hoje apenas abracaf, abcn e abrare têm logoSrc (imagem); os demais usam o nome em texto.
Funções expostas:
getClient(slug?)— retorna oClientConfigounullse o slug for desconhecido.isValidClient(slug?)—truese o slug existir no registry.
O layout.tsx do [team] valida o slug, envolve a árvore com o CurrentClientProvider e aplica o tema:
const client = getClient(team);
if (!client) notFound();
return (
<div data-theme={client.dataTheme}>
<CurrentClientProvider value={client}>{children}</CurrentClientProvider>
</div>
);
Componentes filhos leem o cliente com o hook useCurrentClient() (lança erro se usado fora do provider). O ClientLogo, usado no topo das telas de auth via AuthLayoutShell, é quem consome esse contexto para exibir logo ou texto.
A personalização por cliente neste fluxo se resume ao data-theme, que sobrescreve --primary, --primary-foreground e --ring (acento dos botões e do anel de foco). Os tokens --abc-* da Abracaf são declarados em :root (global) para que portais (popovers, dialogs) também os herdem. O plano completo de white-label por cliente está em Tema multi-cliente (white-label).
Telas do fluxo
Toda a interação vive em login-flow.tsx, uma máquina de estados (FlowState). Cada tela do protótipo mapeia para um estado ou variação:
| Tela | Estado / componente | Observações |
|---|---|---|
| Login | { name: 'form', mode: 'login' } → LoginForm | Campo de e-mail + botão Continuar + rodapé de termos |
| Verifique seu e-mail | { name: 'sent' } → AuthMessageScreen | Ações Reenviar link e Alterar e-mail |
| Alterar e-mail | { name: 'form', mode: 'change' } → LoginForm | Reaproveita o formulário com título/subtítulo de "Alterar e-mail" |
| Não foi possível enviar o link | { name: 'sendError' } → AuthMessageScreen | Ação Tentar novamente (volta ao form) |
| Limite de solicitações atingido | { name: 'rateLimit' } → AuthMessageScreen | Ação Ir para o login |
| Link expirado ou inválido | callback/error/page.tsx (rota) | Tela temada, fora da máquina de estados; ver Fim a fim |
| "Formato inválido" | validação Zod inline | Mensagem de erro do campo de e-mail |
O campo de e-mail valida com z.string().email(); e-mail malformado mostra "Formato inválido" (magicLink.invalidEmailFormat) sob o input, e o botão de envio fica desabilitado.
O AuthMessageScreen é o card genérico de estado: título centralizado, parágrafos de descrição e uma área de ações (botões/links).
A tela de Login traz um rodapé de termos (signInTermsNotice) com links para Termos de uso (/terms-of-service) e Política de Privacidade (/privacy-policy).
Modelo por convite e mascaramento de segurança
O envio do link usa signInWithOtp com shouldCreateUser: false:
await supabase.auth.signInWithOtp({
email: params.email,
options: {
emailRedirectTo: params.emailRedirectTo,
shouldCreateUser: false,
captchaToken: params.captchaToken,
},
});
Como shouldCreateUser é false, não há cadastro: somente usuários já provisionados (por convite) conseguem entrar. Um e-mail não cadastrado faz o Supabase retornar erro (otp_disabled / "signups not allowed").
Quando o e-mail não está provisionado, o erro é silenciado e a UI mostra a mesma tela "Verifique seu e-mail" de um envio bem-sucedido (função isUserNotProvisioned). Isso evita a enumeração de e-mails cadastrados. Por isso o texto da tela usa o condicional "Se o e-mail informado estiver cadastrado...".
Tratamento de erros restante:
- Rate limit (
status === 429oucode === 'over_email_send_rate_limit') → tela "Limite de solicitações atingido". A cópia indica até 3 links a cada 15 minutos. - Outros erros de envio → tela "Não foi possível enviar o link".
O fluxo também integra captcha (useCaptcha / @kit/auth/captcha): o token é incluído no envio e resetado a cada tentativa. O botão permanece desabilitado enquanto o captcha não está pronto.
Fim a fim: do envio à confirmação
- Envio: na tela de login o usuário informa o e-mail. O
sign-in/page.tsxmonta umcallbackPathcliente-aware (/{team}/auth/callback) e o passa aoLoginFlow, que o usa comoemailRedirectTo(URL absoluta com a origem atual). - E-mail: o link do e-mail aponta para
{SiteURL}/auth/confirm, carregando ocallbackcliente-aware como parâmetro. - Verificação (GLOBAL): a rota
app/[locale]/auth/confirm/route.tscria oAuthCallbackServicee chamaverifyTokenHash. Ela deriva o slug a partir docallback/next(ex.:/abracaf/auth/callback→abracaf), validando contra o registry, para definir oerrorPathtemado do cliente certo. - Sucesso: o usuário é redirecionado para a home logada (
pathsConfig.app.home). - Falha (link expirado, já usado ou inválido): redireciona para
/{team}/auth/callback/error— a tela temada "Link expirado ou inválido", com ações Solicitar novo link e Ir para o login (ambas levam a/{team}/auth/sign-in).
A rota /auth/confirm é global e não tem o slug no path. Para mostrar a tela de erro com o tema do cliente correto, o extractTeamSlug lê o slug do parâmetro callback (que é cliente-aware). Se nenhum slug válido for encontrado, errorPath fica undefined e o serviço usa seu fallback padrão.
Internacionalização (pt-BR obrigatório)
Todas as cópias do fluxo vivem em i18n/messages/pt-BR/auth.json, principalmente sob a chave magicLink.*, mais signInTermsNotice e errors.over_email_send_rate_limit.
As mensagens magicLink.* existem apenas no bundle pt-BR. Se o locale padrão não for pt-BR, o app cai em en, onde essas chaves não existem — e as telas exibem as chaves cruas (ex.: auth.magicLink.checkEmailHeading). A variável NEXT_PUBLIC_DEFAULT_LOCALE=pt-BR hoje está apenas no .env.local (gitignored). Quem for testar precisa garantir que ela esteja definida no seu ambiente local.
Como testar localmente
Pré-requisitos: Supabase local no ar e NEXT_PUBLIC_DEFAULT_LOCALE=pt-BR definido (ver alerta acima).
# Terminal 1 — Supabase local
pnpm supabase:web:start
# Terminal 2 — app web
pnpm dev
- Abra a tela de login de um cliente válido, por exemplo
http://localhost:3000/abracaf/auth/sign-in. Um slug inexistente (ex.:/inexistente/auth/sign-in) deve retornar 404. - Login com usuário provisionado: use um e-mail semeado, por exemplo
test@makerkit.dev. Após Continuar, a tela muda para "Verifique seu e-mail". - Abra o e-mail no Mailpit em
http://127.0.0.1:54324e clique no link de acesso. Você deve ser autenticado e redirecionado para a home logada. - E-mail não cadastrado: informe um e-mail qualquer não provisionado. A UI mostra a mesma tela "Verifique seu e-mail" (mascaramento) — nenhum e-mail é entregue.
- Formato inválido: digite um e-mail malformado (ex.:
abc@) para ver a mensagem "Formato inválido" e o botão desabilitado. - Link expirado ou inválido: reutilize um link de magic-link já consumido (ou abra-o em uma sessão anônima/deslogada). O fluxo redireciona para a tela temada "Link expirado ou inválido" em
/{team}/auth/callback/error.
O e-mail test@makerkit.dev vem do seed padrão do MakerKit. Confirme os usuários disponíveis no seed do Supabase do projeto antes de testar.