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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user