Pular para o conteúdo principal

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:

  1. Domínio de aplicação do cliente (ex.: emplacamento.abracaf.com.br): o proxy.ts consulta o DOMAIN_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].
  2. Usuário deslogado em /home: o handler de /home/* no proxy redireciona para o login. O caminho é cliente-aware: se o host estiver no DOMAIN_SLUG_MAP, monta /{slug}/auth/sign-in?next=...; caso contrário, cai no pathsConfig.auth.signIn padrão.
  3. Acesso direto pelo path: navegar para /{slug}/auth/sign-in também funciona em qualquer ambiente.

Ao resolver [team], o layout.tsx do segmento valida o slug contra o registry. Slug desconhecido → notFound() (404).

A rota /auth/confirm é GLOBAL

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;
}
CampoPapel
slugIdentificador na URL (segmento [team])
nameNome exibido e fallback de logo em texto
dataThemeValor de data-theme aplicado no wrapper do [team] — ativa o CSS de tema do cliente
logoSrcCaminho 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 o ClientConfig ou null se o slug for desconhecido.
  • isValidClient(slug?)true se 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.

Tema hoje = apenas o acento do login

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:

TelaEstado / componenteObservações
Login{ name: 'form', mode: 'login' }LoginFormCampo de e-mail + botão Continuar + rodapé de termos
Verifique seu e-mail{ name: 'sent' }AuthMessageScreenAções Reenviar link e Alterar e-mail
Alterar e-mail{ name: 'form', mode: 'change' }LoginFormReaproveita o formulário com título/subtítulo de "Alterar e-mail"
Não foi possível enviar o link{ name: 'sendError' }AuthMessageScreenAção Tentar novamente (volta ao form)
Limite de solicitações atingido{ name: 'rateLimit' }AuthMessageScreenAção Ir para o login
Link expirado ou inválidocallback/error/page.tsx (rota)Tela temada, fora da máquina de estados; ver Fim a fim
"Formato inválido"validação Zod inlineMensagem 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").

E-mail não cadastrado é mascarado por segurança

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 === 429 ou code === '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

  1. Envio: na tela de login o usuário informa o e-mail. O sign-in/page.tsx monta um callbackPath cliente-aware (/{team}/auth/callback) e o passa ao LoginFlow, que o usa como emailRedirectTo (URL absoluta com a origem atual).
  2. E-mail: o link do e-mail aponta para {SiteURL}/auth/confirm, carregando o callback cliente-aware como parâmetro.
  3. Verificação (GLOBAL): a rota app/[locale]/auth/confirm/route.ts cria o AuthCallbackService e chama verifyTokenHash. Ela deriva o slug a partir do callback/next (ex.: /abracaf/auth/callbackabracaf), validando contra o registry, para definir o errorPath temado do cliente certo.
  4. Sucesso: o usuário é redirecionado para a home logada (pathsConfig.app.home).
  5. 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).
Por que o slug é derivado do callback

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.

Defina NEXT_PUBLIC_DEFAULT_LOCALE=pt-BR

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
  1. 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.
  2. 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".
  3. Abra o e-mail no Mailpit em http://127.0.0.1:54324 e clique no link de acesso. Você deve ser autenticado e redirecionado para a home logada.
  4. 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.
  5. Formato inválido: digite um e-mail malformado (ex.: abc@) para ver a mensagem "Formato inválido" e o botão desabilitado.
  6. 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.
Usuários semeados

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.