feat(auth): add password reset flow with OTP

- Add password-reset Prisma schema for OTP tracking
- Add API contract schemas (start, verify, resend)
- Add password-reset service with OTP validation and email sending
- Add frontend reset-password page with 2-step flow
- Add InputOTP component usage for OTP input
- Add link to reset-password in login page
- Remove link to About in login
- Center and resize login/reset password forms
This commit is contained in:
Jose Selesan
2026-04-13 09:10:06 -03:00
parent 566291fb43
commit 70273f6760
13 changed files with 906 additions and 7 deletions

View File

@@ -49,8 +49,8 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
};
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<main className="flex min-h-screen w-full items-center justify-center px-6">
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
<p className="mb-4 text-sm text-muted-foreground">
Ingresá con tu cuenta para acceder a Home.
@@ -89,9 +89,8 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
¿Solo querés explorar?{' '}
<Link to="/about" className="text-primary hover:underline">
Ir a About
<Link to="/reset-password" className="text-primary hover:underline">
¿Olvidaste tu contraseña?
</Link>
</p>
</section>

View File

@@ -0,0 +1,281 @@
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { zodResolver } from '@hookform/resolvers/zod';
import { Link, useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const emailSchema = z.object({
email: z.string().email('Ingresá un email válido.'),
});
const otpSchema = z
.object({
otp: z.string().length(6, 'El código debe tener 6 dígitos.'),
newPassword: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Las contraseñas no coinciden.',
path: ['confirmPassword'],
});
type EmailForm = z.infer<typeof emailSchema>;
type OtpFormData = z.infer<typeof otpSchema>;
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
export function ResetPasswordPage() {
const navigate = useNavigate();
const [step, setStep] = useState<'email' | 'otp'>('email');
const [requestId, setRequestId] = useState<string>('');
const [submitError, setSubmitError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const {
register: registerEmail,
handleSubmit: handleEmailSubmit,
formState: { errors: emailErrors, isSubmitting: isEmailSubmitting, isValid: isEmailValid },
} = useForm<EmailForm>({
resolver: zodResolver(emailSchema),
mode: 'onChange',
defaultValues: { email: '' },
});
const {
register: registerOtp,
handleSubmit: handleOtpSubmit,
formState: { errors: otpErrors, isSubmitting: isOtpSubmitting, isValid: isOtpValid },
setValue,
watch,
} = useForm<OtpFormData>({
resolver: zodResolver(otpSchema),
mode: 'onChange',
defaultValues: { otp: '', newPassword: '', confirmPassword: '' },
});
const otpValue = watch('otp');
useEffect(() => {
if (requestId && step === 'otp') {
console.log('Request ID set:', requestId);
}
}, [requestId, step]);
const onSubmitEmail = async (values: EmailForm) => {
setSubmitError(null);
try {
const res = await fetch(`${apiBaseUrl}/api/password-reset/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
const data = await res.json();
if (!res.ok) {
setSubmitError(data.message || 'Error al iniciar el proceso.');
return;
}
setRequestId(data.requestId);
setStep('otp');
} catch {
setSubmitError('Error de conexión. Intentá de nuevo.');
}
};
const onSubmitOtp = async (values: OtpFormData) => {
setSubmitError(null);
try {
const res = await fetch(`${apiBaseUrl}/api/password-reset/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId,
otp: values.otp,
newPassword: values.newPassword,
}),
});
const data = await res.json();
if (!res.ok) {
setSubmitError(data.message || 'Error al restablecer la contraseña.');
return;
}
setSuccessMessage('Tu contraseña ha sido restablecida. Ya podés iniciar sesión.');
setTimeout(() => {
navigate({ to: '/login' });
}, 3000);
} catch {
setSubmitError('Error de conexión. Intentá de nuevo.');
}
};
const resendOtp = async () => {
if (!requestId) return;
setSubmitError(null);
try {
const res = await fetch(`${apiBaseUrl}/api/password-reset/resend`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId }),
});
const data = await res.json();
if (!res.ok) {
setSubmitError(data.message || 'Error al reenviar el código.');
return;
}
} catch {
setSubmitError('Error de conexión. Intentá de nuevo.');
}
};
if (successMessage) {
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<div className="text-center">
<div className="mb-4 text-2xl"></div>
<h1 className="mb-2 text-2xl font-semibold">Listo</h1>
<p className="text-sm text-muted-foreground">{successMessage}</p>
</div>
</section>
</main>
);
}
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">
{step === 'email' ? 'Restablecer contraseña' : 'Ingresá el código'}
</h1>
<p className="mb-4 text-sm text-muted-foreground">
{step === 'email'
? 'Ingresá tu email para recibir el código de verificación.'
: 'Revisá tu email e ingresá el código que te enviamos.'}
</p>
{step === 'email' && (
<form className="space-y-3" onSubmit={handleEmailSubmit(onSubmitEmail)}>
<Field data-invalid={Boolean(emailErrors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(emailErrors.email)}
{...registerEmail('email')}
/>
<FieldError errors={[emailErrors.email]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" className="w-full" disabled={!isEmailValid || isEmailSubmitting}>
{isEmailSubmitting ? 'Enviando...' : 'Enviar código'}
</Button>
</form>
)}
{step === 'otp' && (
<form className="space-y-3" onSubmit={handleOtpSubmit(onSubmitOtp)}>
<Field data-invalid={Boolean(otpErrors.otp)}>
<FieldLabel htmlFor="reset-otp">Código OTP</FieldLabel>
<InputOTP
id="reset-otp"
maxLength={6}
value={otpValue}
onChange={(value) => {
setValue('otp', value, { shouldValidate: true });
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<FieldError errors={[otpErrors.otp]} />
</Field>
<Field data-invalid={Boolean(otpErrors.newPassword)}>
<FieldLabel htmlFor="newPassword">Nueva contraseña</FieldLabel>
<Input
id="newPassword"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(otpErrors.newPassword)}
{...registerOtp('newPassword')}
/>
<FieldError errors={[otpErrors.newPassword]} />
</Field>
<Field data-invalid={Boolean(otpErrors.confirmPassword)}>
<FieldLabel htmlFor="confirmPassword">Confirmar contraseña</FieldLabel>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(otpErrors.confirmPassword)}
{...registerOtp('confirmPassword')}
/>
<FieldError errors={[otpErrors.confirmPassword]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" className="w-full" disabled={!isOtpValid || isOtpSubmitting}>
{isOtpSubmitting ? 'Restableciendo...' : 'Restablecer contraseña'}
</Button>
<div className="flex justify-between text-sm">
<button type="button" onClick={resendOtp} className="text-primary hover:underline">
Reenviar código
</button>
<button
type="button"
onClick={() => {
setStep('email');
setSubmitError(null);
}}
className="text-primary hover:underline"
>
Cambiar email
</button>
</div>
</form>
)}
<p className="mt-4 text-center text-sm text-muted-foreground">
<Link to="/login" className="text-primary hover:underline">
Volver a iniciar sesión
</Link>
</p>
</section>
</main>
);
}

View File

@@ -0,0 +1,15 @@
import { ResetPasswordPage } from '@/features/password-reset/reset-password-page';
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/reset-password')({
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/' });
}
},
component: ResetPasswordRouteComponent,
});
function ResetPasswordRouteComponent() {
return <ResetPasswordPage />;
}