feat(auth): recuperação de senha, callback de e-mail e entrega de acesso por link - #56
Merged
Merged
Conversation
Fecha os quatro buracos que impediam um cliente self-service de entrar
sozinho no sistema. Todos achados lendo o código, e os três primeiros
provados ao vivo (smoke de "esqueci minha senha" ponta a ponta).
## 1. O link do e-mail não tinha onde cair
Não existia rota de retorno de autenticação — nenhum `exchangeCodeForSession`
nem `verifyOtp` no projeto inteiro. Link do Supabase chegava e o clique não
virava sessão.
`src/app/auth/callback/route.ts` trata os DOIS formatos (`?code=` PKCE e
`?token_hash=&type=`), porque qual deles chega depende de configuração do
projeto e do template — tratar só um faz uma troca de template derrubar o
acesso sem ninguém entender. Valida o `next` contra caminho externo: sem isso
`?next=https://site-falso` viraria redirecionador aberto com o domínio
legítimo da ATR, que é material de phishing convincente.
## 2. Não existia recuperar senha — e o middleware fingia que existia
Zero `resetPasswordForEmail` no código. E o allow-list de rotas públicas
liberava `/register`, `/forgot-password` e `/reset-password` — três páginas
que nunca foram construídas, apontando para 404.
Nova `/esqueci-senha`, com a resposta SEMPRE igual tenha o e-mail conta ou
não: dizer "e-mail não encontrado" entrega de graça quem é cliente da ATR.
No middleware, `/auth` precisou de exceção junto com `/convite`: quem clica
no link de recuperação já logado no mesmo navegador era devolvido para a home
ANTES do código virar sessão, e a troca falhava em silêncio.
## 3. Copy do login era de sistema fechado
"Acesso restrito a usuários autorizados" no rodapé — quem acabou de assinar
lê isso e se sente invasor. Virou "Assinou e não recebeu seu acesso?", entrou
"Esqueci minha senha", e os três cartões pararam de prometer Gestão de
Projetos (módulo desligado por flag) para falar do que o produto entrega:
resultado, caixa e endividamento.
## 4. Política de senha
`src/lib/auth/password-policy.ts` — módulo puro, 15 testes. Mínimo de 10,
letra + número, lista de senhas óbvias (incluindo as brasileiras), sem
sequência/repetição de 4+, e a senha não pode conter o próprio nome ou
e-mail — o primeiro palpite de qualquer atacante. Símbolo entra no cálculo
de força, não no de validade: frase longa precisa continuar sendo boa senha.
A tela ganhou olho para ver a senha (um botão controlando os dois campos, para
dar conferir), validação ao vivo da confirmação, botão travado até tudo estar
válido, e tradução dos erros de auth do Supabase, que vinham em inglês.
## Entrega de acesso por link, não por senha em texto
`sendWelcomeEmail` mandava a senha temporária em texto puro no corpo do
e-mail. E-mail não é canal seguro e a senha fica na caixa de entrada para
sempre. Agora vai link de uso único, gerado por `generateLink`.
Junto, dois defeitos que apareceram no caminho:
- a busca de usuário existente usava `auth.admin.listUsers()` SEM paginação,
que devolve só a primeira página (50). Passando de 50 usuários, respondia
"não existe" para todo mundo fora da primeira página, o `createUser`
recusava por duplicidade e o cliente ficava sem acesso — falha que só
começaria a acontecer quando as vendas funcionassem. Agora consulta
`profiles.email`, que é tabela nossa e indexada.
- o consultor ia em CÓPIA do e-mail que carrega o link. O link loga na conta
do cliente: mandar cópia para um terceiro é entregar a chave. O mesmo
vazamento existia com a senha temporária e passou despercebido porque
ninguém olha o CC.
`src/lib/auth/acesso-service.ts` concentra garantir-usuário + gerar-link,
porque o webhook do gateway vai precisar da mesma coisa e sem sessão nenhuma.
Marcado `server-only`: usa a service-role key, que ignora RLS — import a
partir de componente client quebra o build em vez de vazar a chave.
## E-mails no domínio próprio, em português
`supabase/templates/` versionados no git em vez de presos no editor do
dashboard. Só os dois fluxos que existem de verdade: `recovery` (/esqueci-senha)
e `email_change` (ConfigTab.tsx:96). Os links apontam para o nosso domínio via
`{{ .TokenHash }}` — assim o cliente vê um endereço que reconhece, e quando o
link falha quem mostra o erro é a nossa tela em português.
`config.toml` liga o SMTP do Resend e sobe `email_sent` de 2 para 100/hora. O
default de 2 era pensado para o serviço embutido de teste do Supabase; com
venda self-service isso é bloqueio, não proteção — três pessoas assinando na
mesma hora e a terceira não recebe o acesso.
Novo `sendAcessoCompraEmail`: o e-mail pós-pagamento, com recibo e os três
próximos passos. O bloco "o que vem depois" é a defesa contra cancelamento no
mês 1 — quem entra esperando painel preenchido e encontra tela vazia se sente
enganado. Ainda sem chamador: o webhook do gateway é a próxima trilha.
## tsconfig
`exclude` passou a cobrir `_local` e `.claude/worktrees`. O `include` varria
`**/*.ts` da raiz e arrastava diretórios gitignored para o type-check: o
`next build` compilava com sucesso e depois falhava apontando arquivo que nem
está no repositório. A Vercel nunca via, porque lá esses diretórios não
existem — quebra só local, que tornava o build inútil como gate.
## Validação
- 1737/1737 testes (170 arquivos), 15 novos da política de senha
- tsc --noEmit em silêncio (primeira vez, por causa do tsconfig)
- eslint 0 erro nos arquivos novos; nos pré-existentes caiu de 21 para 19
(baseline medido em worktree no HEAD)
- next build verde ponta a ponta, com /auth/callback e /esqueci-senha no
manifesto
- smoke real: pedido de recuperação, e-mail recebido de
ATR OS <acesso@atrgrowthbusiness.com.br>, link clicado, senha redefinida
## Ainda em aberto
- `client-users-service` (modal de convite do admin) segue mandando senha em
texto, porque ele TAMBÉM mostra a senha na tela para o admin copiar. Migrar
muda o jeito que o time trabalha — é decisão de produto.
- Deploy é pré-requisito: o Supabase de produção já está configurado
apontando para /auth/callback, que só existe a partir deste commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Updates to Preview Branch (feat/auth-acesso-self-service) ↗︎
Tasks are run on every commit but only new migration files are pushed.
View logs for this Workflow Run ↗︎. |
Dois gates vermelhos na PR #56, causas independentes. ## db/ESTRUTURA.md desatualizado O gerador lê o dump E o codigo vivo em src/ — o novo .from("profiles") do acesso-service mudou a contagem de usos (76 -> 77). Regenerado. ## Supabase Preview: 401 Missing SMTP_PASS [auth.email.smtp] com pass = env(RESEND_API_KEY) nao resolve no ambiente de preview: a chave so existe no .env.local e na Vercel. Sem SMTP_PASS o Supabase recusa tambem RATE_LIMIT_EMAIL_SENT, e a branch inteira falha. Bloco vira comentario (registro do que esta no dashboard de producao) e email_sent volta a 2 neste arquivo, que governa local e preview. Em producao continua 100, configurado no dashboard. Preview mandando e-mail de verdade tambem nao seria desejavel: gastaria cota do Resend e poderia disparar mensagem real de um deploy de teste. Os templates seguem ativos — nao dependem de segredo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…free tier A Preview seguiu vermelha depois do commit anterior: 400: Email template modification is not available for free tier projects using the default email provider. Com [auth.email.smtp] ligado, o preview falha com 401 (env(RESEND_API_KEY) nao existe naquele ambiente). Sem ele, o projeto cai no provedor padrao do Supabase — e no plano gratuito o provedor padrao nao aceita template customizado. Os dois caminhos falham. Conclusao: no free tier, nenhuma configuracao de e-mail pode morar neste arquivo. O diff de config.toml contra a main agora e 100% comentario — o arquivo se comporta identico ao que ja passava. O que nao se perde: os HTMLs continuam em supabase/templates/, versionados e revisaveis em PR. O config.toml so os aplicaria automaticamente, e isso nunca valeu para producao — producao e configurada colando no dashboard. Fica registrado no arquivo que mexer no HTML exige colar no dashboard: o arquivo sozinho nao muda o e-mail que o cliente recebe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nao muda comportamento — registra o que a sessao descobriu na marra, pra
ninguem re-descobrir.
O ponto que mais custa esquecer: config.toml NAO e documentacao da
producao, e configuracao executavel de local e preview. Producao e
configurada pelo dashboard. Consequencia pratica que fica escrita em
dois lugares: mexeu num HTML de supabase/templates/, tem que colar no
dashboard — o arquivo sozinho nao muda o e-mail do cliente.
O checklist do Pro fica em ordem de importancia pro produto, nao de
facilidade:
1. backup e recuperacao — o sistema guarda o historico financeiro
inteiro dos clientes; perder isso nao e incidente, e fim de relacao
2. projeto que nao pausa — free tier pausa por inatividade, e cliente
que entra depois de 10 dias e acha o sistema fora do ar e churn
3. retencao de log — e onde se descobre por que 'o cliente pagou e nao
recebeu acesso'
4. templates voltam pro config.toml — o unico item que essa sessao
esbarrou, e o menos importante dos quatro
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…m abertos
Achado fucando as env vars de producao pelo CLI da Vercel: CRON_SECRET nao
existia nem la nem no .env.local. E o guard dos tres crons era:
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) return 401
Com a variavel ausente, `cronSecret` e undefined, a condicao inteira vira
falsa e a checagem NAO ACONTECE. Segredo ausente significava 'deixa todo
mundo entrar'. Os tres estao agendados no vercel.json e o middleware nao
cobre /api (o matcher exclui), entao nao havia segunda barreira.
O pior era /api/cron/monthly-update-reminder: ele DISPARA E-MAIL. Quem
descobrisse a URL rodava uma remessa nos clientes e queimava a cota do
Resend.
Conserto em duas partes:
1. CRON_SECRET criada na Vercel (production), 32 bytes aleatorios. Com a
variavel definida, a Vercel passa a mandar Authorization: Bearer <valor>
nas invocacoes agendadas — o cron legitimo continua passando.
2. Guard unico em @/lib/auth/cron-auth, fail-CLOSED: sem segredo
configurado, ninguem entra. Um cron que para de rodar e um incidente que
aparece; um endpoint aberto e um incidente que nao aparece.
Comparacao em tempo constante junto, porque comparar segredo com === vaza
por tempo quantos caracteres iniciais bateram. O ganho real e o
fail-closed; isso e so fazer certo o que custa nada.
5 testes cobrindo o caso que importa: RECUSA quando CRON_SECRET nao existe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fecha os quatro buracos que impediam um cliente self-service de entrar sozinho. Todos achados lendo o código; os três primeiros provados ao vivo com smoke ponta a ponta.
O Supabase de produção já está configurado (SMTP do Resend, templates em português, Site URL, rate limit) e os e-mails que ele manda apontam para
/auth/callback— rota que só existe a partir deste PR. Hoje, quem pedir recuperação de senha em produção recebe o e-mail e cai num 404.O que entra
src/app/auth/callback/route.ts— trata?code=(PKCE) e?token_hash=&type=, validanextcontra redirect aberto/esqueci-senha, com resposta sempre igual (sem enumeração de usuário)/register,/forgot-password,/reset-passwordfora;/esqueci-senhae/authdentropassword-policy.ts(módulo puro, 15 testes) + medidor de força + olho + validação ao vivoJunto:
sendWelcomeEmailmandava a senha temporária no corpo do e-mail; agora vai link de uso único viagenerateLink.listUsers()sem paginação devolvia só os primeiros 50 — do 51º usuário em diante a busca respondia "não existe",createUserrecusava por duplicidade e o cliente ficava sem acesso. Falha que só começaria a acontecer quando as vendas funcionassem. Agora consultaprofiles.email(indexada).supabase/templates/, versionados no git, com links no domínio próprio.email_sentde 2 → 100/hora. O default de 2 é do serviço embutido de teste; com venda self-service, três assinaturas na mesma hora e a terceira não recebe acesso.tsconfigexclui_locale.claude/worktrees— oincludearrastava diretórios gitignored para o type-check e quebrava onext buildsó na máquina local.Validação
tsc --noEmitem silêncio — primeira vez, por causa do tsconfignext buildverde, com/auth/callbacke/esqueci-senhano manifestoATR OS <acesso@atrgrowthbusiness.com.br>, link clicado, senha redefinidaDepois do merge
Confirmar em produção: pedir recuperação e clicar no link direto do e-mail (sem trocar o host na mão, como foi feito no smoke local).
Fora de escopo, de propósito
client-users-service(modal de convite do admin) segue com senha em texto — ele também mostra a senha na tela para o admin copiar, e migrar muda o jeito que o time trabalha. Decisão de produto.sendAcessoCompraEmailentra sem chamador: o webhook do gateway é a próxima trilha.🤖 Generated with Claude Code