Skip to content

feat(auth): recuperação de senha, callback de e-mail e entrega de acesso por link - #56

Merged
BarryBits merged 5 commits into
mainfrom
feat/auth-acesso-self-service
Aug 12, 2026
Merged

feat(auth): recuperação de senha, callback de e-mail e entrega de acesso por link#56
BarryBits merged 5 commits into
mainfrom
feat/auth-acesso-self-service

Conversation

@BarryBits

Copy link
Copy Markdown
Owner

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.

⚠️ Merge é urgente

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

Buraco Conserto
Link de e-mail não tinha onde cair src/app/auth/callback/route.ts — trata ?code= (PKCE) e ?token_hash=&type=, valida next contra redirect aberto
Não existia recuperar senha /esqueci-senha, com resposta sempre igual (sem enumeração de usuário)
Middleware liberava 3 rotas inexistentes /register, /forgot-password, /reset-password fora; /esqueci-senha e /auth dentro
Senha fraca, sem confirmação visível password-policy.ts (módulo puro, 15 testes) + medidor de força + olho + validação ao vivo

Junto:

  • Acesso por link, não por senha em texto. sendWelcomeEmail mandava a senha temporária no corpo do e-mail; agora vai link de uso único via generateLink.
  • listUsers() sem paginação devolvia só os primeiros 50 — do 51º usuário em diante a busca respondia "não existe", 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 (indexada).
  • Consultor ia em CÓPIA do e-mail que carrega o link de acesso — que loga na conta do cliente. Removido.
  • Templates em supabase/templates/, versionados no git, com links no domínio próprio.
  • email_sent de 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.
  • tsconfig exclui _local e .claude/worktrees — o include arrastava diretórios gitignored para o type-check e quebrava o next build só na máquina local.

Validação

  • 1737/1737 testes (170 arquivos), 15 novos
  • tsc --noEmit em silêncio — primeira vez, por causa do tsconfig
  • eslint 0 erro nos arquivos novos; nos pré-existentes caiu de 21 → 19 (baseline medido em worktree no HEAD)
  • next build verde, com /auth/callback e /esqueci-senha no manifesto
  • Smoke real: recuperação pedida, e-mail recebido de ATR OS <acesso@atrgrowthbusiness.com.br>, link clicado, senha redefinida

Depois 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.
  • sendAcessoCompraEmail entra sem chamador: o webhook do gateway é a próxima trilha.

🤖 Generated with Claude Code

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>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
atr-os Ready Ready Preview Aug 12, 2026 9:46pm

@supabase

supabase Bot commented Aug 12, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/auth-acesso-self-service) ↗︎

Deployments Status Updated
Database Wed, 12 Aug 2026 21:43:43 UTC
Services Wed, 12 Aug 2026 21:43:43 UTC
APIs Wed, 12 Aug 2026 21:43:43 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations Wed, 12 Aug 2026 21:43:44 UTC
Migrations Wed, 12 Aug 2026 21:43:45 UTC
Seeding Wed, 12 Aug 2026 21:43:46 UTC
Edge Functions Wed, 12 Aug 2026 21:43:46 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

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>
@BarryBits
BarryBits merged commit a21c084 into main Aug 12, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant