feat(auth): e-mail transacional real + recuperação de senha

Liga o adapter SMTP existente (packages/integrations, já era fail-soft)
a um consumidor de verdade e adiciona o fluxo completo de recuperação
de senha, faltante desde a Fase 1.

- UsersService.invite() agora envia e-mail com a senha temporária
  (mantém o retorno na resposta também — dupla entrega, eden.md §4.8).
- POST /v1/auth/forgot-password + POST /v1/auth/reset-password: token
  de 256 bits, só o hash SHA-256 persistido (users.reset_token_hash/
  reset_token_expires_at — colunas que já existiam desde a baseline da
  Fase 1, nunca usadas até agora), validade de 1h, uso único, revoga
  todas as sessões ativas do usuário ao concluir. Mesma disciplina
  anti-enumeração do login: resposta idêntica para e-mail cadastrado
  ou não.
- Frontend: link "Esqueci minha senha" no LoginPage, novas
  ForgotPasswordPage/ResetPasswordPage.

Teste de integração novo (password-reset.spec.ts, 3 testes) usa
vi.mock para interceptar sendMail e recuperar o token real do corpo do
e-mail — única forma de testar o caminho feliz de ponta a ponta, já
que o token nunca é devolvido na resposta da API. Suíte completa:
33/33 (30→33). pnpm turbo run typecheck build (28/28). Testado ao vivo
através do proxy same-origin: anti-enumeração confirmada com curl
real, token gravado no Postgres, token bogus rejeitado (400).

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 08:02:33 -03:00
parent 012c00fa4d
commit 4d23af3b23
16 changed files with 525 additions and 8 deletions

View File

@@ -164,3 +164,33 @@ export async function disableMfa(userId: string): Promise<void> {
userId,
]);
}
/**
* Stages a password-reset token — only the SHA-256 hash is stored (same
* pattern as sessions.refresh_token_hash), never the plaintext. A new
* request overwrites any previous token for the user (only one active
* reset token at a time — no separate token table needed for this MVP).
*/
export async function setResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
await query("UPDATE users SET reset_token_hash = $2, reset_token_expires_at = $3 WHERE id = $1", [
userId,
tokenHash,
expiresAt,
]);
}
/** Only matches a token that hasn't expired — an expired-but-still-stored hash is treated as no match. */
export async function findUserByResetTokenHash(tokenHash: string): Promise<User | null> {
const { rows } = await query<User>(
"SELECT * FROM users WHERE reset_token_hash = $1 AND reset_token_expires_at > now() AND is_active = true",
[tokenHash],
);
return rows[0] ?? null;
}
/** Called after a successful reset (or to invalidate a stale request) — a used/expired token must never be consumable twice. */
export async function clearResetToken(userId: string): Promise<void> {
await query("UPDATE users SET reset_token_hash = null, reset_token_expires_at = null WHERE id = $1", [
userId,
]);
}