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:
@@ -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 />} />
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
75
apps/core-web/src/routes/ForgotPasswordPage.tsx
Normal file
75
apps/core-web/src/routes/ForgotPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
97
apps/core-web/src/routes/ResetPasswordPage.tsx
Normal file
97
apps/core-web/src/routes/ResetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user