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

@@ -53,6 +53,8 @@ import { DevicesListPage } from "./routes/hr-timeclock/DevicesListPage.js";
import { DeviceFormPage } from "./routes/hr-timeclock/DeviceFormPage.js";
import { AfdImportPage } from "./routes/hr-timeclock/AfdImportPage.js";
import { AfdRecordsListPage } from "./routes/hr-timeclock/AfdRecordsListPage.js";
import { ForgotPasswordPage } from "./routes/ForgotPasswordPage.js";
import { ResetPasswordPage } from "./routes/ResetPasswordPage.js";
export function App() {
return (
@@ -60,6 +62,8 @@ export function App() {
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AuthenticatedLayout />}>
<Route path="/" element={<DashboardPage />} />

View File

@@ -65,3 +65,23 @@ export async function logout(refreshToken: string): Promise<void> {
body: JSON.stringify({ refreshToken }),
});
}
/**
* Always resolves the same way whether or not the email is registered —
* the API returns the same generic message either way (anti-enumeration,
* see auth.service.ts::forgotPassword) — never treat this as confirmation
* the account exists.
*/
export async function forgotPassword(email: string): Promise<{ message: string }> {
return apiRequest<{ message: string }>("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function resetPassword(token: string, newPassword: string): Promise<void> {
await apiRequest<void>("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, newPassword }),
});
}

View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Link } from "react-router-dom";
import { Alert, Button, Card, Input } from "@eden/ui";
import { ApiError } from "../lib/api/http.js";
import { forgotPassword } from "../lib/api/auth.js";
export function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
// The API always returns the same generic message whether or not the
// email is registered (anti-enumeration) — the UI must not add any
// hint of its own (e.g. never say "e-mail não encontrado").
await forgotPassword(email);
setSent(true);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível processar o pedido. Tente novamente.");
} finally {
setSubmitting(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-light p-4">
<Card className="w-full max-w-md">
<div className="mb-6 text-center">
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto mb-4 h-10" />
<h1 className="!text-xl font-bold text-title">Esqueci minha senha</h1>
<p className="mt-1 text-sm text-default">
Informe seu e-mail — se estiver cadastrado, você receberá um link para redefinir sua senha.
</p>
</div>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{sent ? (
<Alert variant="success" className="mb-4">
Se este e-mail estiver cadastrado, você receberá instruções para redefinir sua senha em instantes.
</Alert>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<Input
label="E-mail"
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
<Button type="submit" className="w-full" loading={submitting}>
Enviar
</Button>
</form>
)}
<Link to="/login" className="mt-4 block text-center text-xs text-default hover:text-primary">
Voltar para o login
</Link>
</Card>
</main>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Input } from "@eden/ui";
import { ApiError } from "../lib/api/http.js";
import { useAuth } from "../lib/auth/AuthContext.js";
@@ -17,6 +17,8 @@ export function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const passwordResetDone = (location.state as { passwordResetDone?: boolean } | null)?.passwordResetDone ?? false;
if (status === "authenticated") {
const redirectTo = (location.state as { from?: string } | null)?.from ?? "/";
return <Navigate to={redirectTo} replace />;
@@ -74,6 +76,12 @@ export function LoginPage() {
</Alert>
) : null}
{passwordResetDone && !error ? (
<Alert variant="success" className="mb-4">
Senha redefinida com sucesso. Entre com sua nova senha.
</Alert>
) : null}
{mfaChallenge ? (
<form className="space-y-4" onSubmit={handleMfaSubmit}>
<Input
@@ -124,6 +132,12 @@ export function LoginPage() {
<Button type="submit" className="w-full" loading={submitting}>
Entrar
</Button>
<Link
to="/forgot-password"
className="block text-center text-xs text-default hover:text-primary"
>
Esqueci minha senha
</Link>
</form>
)}
</Card>

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Alert, Button, Card, Input } from "@eden/ui";
import { ApiError } from "../lib/api/http.js";
import { resetPassword } from "../lib/api/auth.js";
export function ResetPasswordPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!token) return;
setError(null);
if (newPassword !== confirmPassword) {
setError("As senhas não coincidem.");
return;
}
setSubmitting(true);
try {
await resetPassword(token, newPassword);
navigate("/login", { replace: true, state: { passwordResetDone: true } });
} catch (err) {
setError(
err instanceof ApiError
? err.message
: "Não foi possível redefinir sua senha. O link pode ter expirado — solicite um novo.",
);
} finally {
setSubmitting(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-light p-4">
<Card className="w-full max-w-md">
<div className="mb-6 text-center">
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto mb-4 h-10" />
<h1 className="!text-xl font-bold text-title">Redefinir senha</h1>
<p className="mt-1 text-sm text-default">Escolha uma nova senha para sua conta.</p>
</div>
{!token ? (
<Alert variant="error" className="mb-4">
Link inválido — falta o token de redefinição. Solicite um novo link.
</Alert>
) : (
<>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<form className="space-y-4" onSubmit={handleSubmit}>
<Input
label="Nova senha"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
hint="Mínimo de 8 caracteres"
minLength={8}
required
autoFocus
/>
<Input
label="Confirmar nova senha"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
minLength={8}
required
/>
<Button type="submit" className="w-full" loading={submitting}>
Redefinir senha
</Button>
</form>
</>
)}
<Link to="/login" className="mt-4 block text-center text-xs text-default hover:text-primary">
Voltar para o login
</Link>
</Card>
</main>
);
}