diff --git a/apps/backend/prisma/migrations/20260412231443_add_password_reset_requests/migration.sql b/apps/backend/prisma/migrations/20260412231443_add_password_reset_requests/migration.sql new file mode 100644 index 0000000..bc3b2dd --- /dev/null +++ b/apps/backend/prisma/migrations/20260412231443_add_password_reset_requests/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "password_reset_requests" ( + "id" UUID NOT NULL, + "email" TEXT NOT NULL, + "otp_hash" TEXT NOT NULL, + "otp_expires_at" TIMESTAMP(3) NOT NULL, + "otp_attempts" INTEGER NOT NULL DEFAULT 0, + "otp_last_sent_at" TIMESTAMP(3) NOT NULL, + "otp_resend_count" INTEGER NOT NULL DEFAULT 0, + "used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "password_reset_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "password_reset_requests_email_idx" ON "password_reset_requests"("email"); + +-- CreateIndex +CREATE INDEX "password_reset_requests_otp_expires_at_idx" ON "password_reset_requests"("otp_expires_at"); diff --git a/apps/backend/prisma/password-reset.prisma b/apps/backend/prisma/password-reset.prisma new file mode 100644 index 0000000..4027f82 --- /dev/null +++ b/apps/backend/prisma/password-reset.prisma @@ -0,0 +1,16 @@ +model PasswordResetRequest { + id String @id @db.Uuid + email String + otpHash String @map("otp_hash") + otpExpiresAt DateTime @map("otp_expires_at") + otpAttempts Int @default(0) @map("otp_attempts") + otpLastSentAt DateTime @map("otp_last_sent_at") + otpResendCount Int @default(0) @map("otp_resend_count") + usedAt DateTime? @map("used_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([email]) + @@index([otpExpiresAt]) + @@map("password_reset_requests") +} \ No newline at end of file diff --git a/apps/backend/src/modules/password-reset/handlers/resend-password-reset.handler.ts b/apps/backend/src/modules/password-reset/handlers/resend-password-reset.handler.ts new file mode 100644 index 0000000..4a9b071 --- /dev/null +++ b/apps/backend/src/modules/password-reset/handlers/resend-password-reset.handler.ts @@ -0,0 +1,10 @@ +import { resendPasswordResetOtp } from '@/modules/password-reset/services/password-reset.service'; +import type { AppContext } from '@/types/hono'; +import type { PasswordResetResendInput } from '@repo/api-contract'; + +export async function resendPasswordResetHandler(c: AppContext) { + const payload = c.req.valid('json' as never) as PasswordResetResendInput; + + const result = await resendPasswordResetOtp(payload); + return c.json(result, 202); +} diff --git a/apps/backend/src/modules/password-reset/handlers/start-password-reset.handler.ts b/apps/backend/src/modules/password-reset/handlers/start-password-reset.handler.ts new file mode 100644 index 0000000..08569af --- /dev/null +++ b/apps/backend/src/modules/password-reset/handlers/start-password-reset.handler.ts @@ -0,0 +1,10 @@ +import { startPasswordReset } from '@/modules/password-reset/services/password-reset.service'; +import type { AppContext } from '@/types/hono'; +import type { PasswordResetStartInput } from '@repo/api-contract'; + +export async function startPasswordResetHandler(c: AppContext) { + const payload = c.req.valid('json' as never) as PasswordResetStartInput; + + const result = await startPasswordReset(payload); + return c.json(result, 202); +} diff --git a/apps/backend/src/modules/password-reset/handlers/verify-password-reset.handler.ts b/apps/backend/src/modules/password-reset/handlers/verify-password-reset.handler.ts new file mode 100644 index 0000000..31a73b8 --- /dev/null +++ b/apps/backend/src/modules/password-reset/handlers/verify-password-reset.handler.ts @@ -0,0 +1,17 @@ +import { verifyOtpAndResetPassword } from '@/modules/password-reset/services/password-reset.service'; +import type { AppContext } from '@/types/hono'; +import type { PasswordResetVerifyInput } from '@repo/api-contract'; + +export async function verifyPasswordResetHandler(c: AppContext) { + const payload = c.req.valid('json' as never) as PasswordResetVerifyInput; + + const ip = + c.req.header('x-forwarded-for') || + c.req.header('cf-connecting-ip') || + c.env?.REMOTE_ADDR || + 'unknown'; + const userAgent = c.req.header('user-agent'); + + const result = await verifyOtpAndResetPassword(payload, ip, userAgent); + return c.json(result, 200); +} diff --git a/apps/backend/src/modules/password-reset/password-reset.routes.ts b/apps/backend/src/modules/password-reset/password-reset.routes.ts new file mode 100644 index 0000000..6aea6e2 --- /dev/null +++ b/apps/backend/src/modules/password-reset/password-reset.routes.ts @@ -0,0 +1,31 @@ +import { resendPasswordResetHandler } from '@/modules/password-reset/handlers/resend-password-reset.handler'; +import { startPasswordResetHandler } from '@/modules/password-reset/handlers/start-password-reset.handler'; +import { verifyPasswordResetHandler } from '@/modules/password-reset/handlers/verify-password-reset.handler'; +import type { AppEnv } from '@/types/hono'; +import { zValidator } from '@hono/zod-validator'; +import { + passwordResetResendSchema, + passwordResetStartSchema, + passwordResetVerifySchema, +} from '@repo/api-contract'; +import { Hono } from 'hono'; + +export const passwordResetRoutes = new Hono(); + +passwordResetRoutes.post( + '/start', + zValidator('json', passwordResetStartSchema), + startPasswordResetHandler +); + +passwordResetRoutes.post( + '/verify', + zValidator('json', passwordResetVerifySchema), + verifyPasswordResetHandler +); + +passwordResetRoutes.post( + '/resend', + zValidator('json', passwordResetResendSchema), + resendPasswordResetHandler +); diff --git a/apps/backend/src/modules/password-reset/services/password-reset.service.ts b/apps/backend/src/modules/password-reset/services/password-reset.service.ts new file mode 100644 index 0000000..be62314 --- /dev/null +++ b/apps/backend/src/modules/password-reset/services/password-reset.service.ts @@ -0,0 +1,443 @@ +import { createHash, randomInt } from 'node:crypto'; +import { sendMail } from '@/lib/mailer'; +import { db } from '@/lib/prisma'; +import { getSupabaseAdminClient } from '@/lib/supabase-admin'; +import type { + PasswordResetResendInput, + PasswordResetStartInput, + PasswordResetVerifyInput, +} from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; + +const OTP_LENGTH = 6; +const OTP_TTL_MINUTES = Number(Bun.env.PASSWORD_RESET_OTP_TTL_MINUTES ?? 10); +const OTP_MAX_ATTEMPTS = Number(Bun.env.PASSWORD_RESET_OTP_MAX_ATTEMPTS ?? 5); +const OTP_RESEND_COOLDOWN_SECONDS = Number( + Bun.env.PASSWORD_RESET_OTP_RESEND_COOLDOWN_SECONDS ?? 30 +); + +export type PasswordResetStartResult = { + message: string; + requestId: string; + email: string; + expiresAt: string; + cooldownSeconds: number; +}; + +export type PasswordResetVerifyResult = { + message: string; +}; + +export type PasswordResetResendResult = { + message: string; + requestId: string; + email: string; + expiresAt: string; + cooldownSeconds: number; +}; + +export class PasswordResetError extends Error { + status: 400 | 404 | 409 | 429; + + constructor(message: string, status: 400 | 404 | 409 | 429 = 400) { + super(message); + this.name = 'PasswordResetError'; + this.status = status; + } +} + +function nowPlusMinutes(minutes: number): Date { + const date = new Date(); + date.setMinutes(date.getMinutes() + minutes); + return date; +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function createOtpCode(): string { + return randomInt(0, 10 ** OTP_LENGTH) + .toString() + .padStart(OTP_LENGTH, '0'); +} + +function hashValue(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function getRemainingAttempts(otpAttempts: number): number { + return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts); +} + +function getCooldownSeconds(otpLastSentAt: Date): number { + const elapsedMs = Date.now() - otpLastSentAt.getTime(); + const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs; + return Math.max(0, Math.ceil(remainingMs / 1000)); +} + +type UserAgentInfo = { + browser: string; + os: string; + device: string; +}; + +type IpGeoInfo = { + ip: string; + city: string; + country: string; + countryCode: string; +}; + +function parseUserAgent(ua: string | undefined): UserAgentInfo { + if (!ua) { + return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' }; + } + + const uaLower = ua.toLowerCase(); + + let browser = 'Desconocido'; + if (uaLower.includes('chrome')) browser = 'Chrome'; + else if (uaLower.includes('firefox')) browser = 'Firefox'; + else if (uaLower.includes('safari')) browser = 'Safari'; + else if (uaLower.includes('edge')) browser = 'Edge'; + else if (uaLower.includes('opera')) browser = 'Opera'; + + let os = 'Desconocido'; + if (uaLower.includes('windows')) os = 'Windows'; + else if (uaLower.includes('mac')) os = 'macOS'; + else if (uaLower.includes('linux')) os = 'Linux'; + else if (uaLower.includes('android')) os = 'Android'; + else if (uaLower.includes('ios') || uaLower.includes('iphone') || uaLower.includes('ipad')) + os = 'iOS'; + + let device = 'Desktop'; + if (uaLower.includes('mobile') || uaLower.includes('android')) device = 'Móvil'; + else if (uaLower.includes('tablet') || uaLower.includes('ipad')) device = 'Tablet'; + + return { browser, os, device }; +} + +async function fetchGeoInfo(ip: string): Promise { + if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) { + return { ip, city: 'Red local', country: 'Red local', countryCode: 'LOCAL' }; + } + + try { + const response = await fetch( + `http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,query` + ); + if (!response.ok) return null; + + const data = await response.json(); + if (data.status !== 'success') return null; + + return { + ip: data.query, + city: data.city || 'Desconocida', + country: data.country || 'Desconocido', + countryCode: data.countryCode || '', + }; + } catch { + return null; + } +} + +async function sendResetOtpEmail(email: string, otpCode: string) { + await sendMail({ + to: email, + subject: 'Código para restablecer tu contraseña', + text: `Tu código OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`, + html: `

Tu código OTP es ${otpCode}.

Vence en ${OTP_TTL_MINUTES} minutos.

`, + }); +} + +async function sendPasswordChangedEmail( + email: string, + ipInfo: IpGeoInfo | null, + userAgent: string | undefined +) { + const deviceInfo = parseUserAgent(userAgent); + const now = new Date(); + const formattedDate = now.toLocaleString('es-AR', { + timeZone: 'America/Argentina/Buenos_Aires', + dateStyle: 'full', + timeStyle: 'short', + }); + + const location = ipInfo + ? `${ipInfo.city}${ipInfo.city !== 'Red local' ? `, ${ipInfo.country}` : ''}` + : 'No disponible'; + const ip = ipInfo?.ip || 'No disponible'; + + await sendMail({ + to: email, + subject: 'Tu contraseña ha sido cambiada', + text: `Tu contraseña fue cambiada el ${formattedDate}. Si no fuiste vos, contactanos inmediatamente.`, + html: ` +

Tu contraseña ha sido cambiada

+

Se ha modificado la contraseña de tu cuenta.

+

Detalles del cambio:

+ +

Si no fuiste vos, contactanos inmediatamente.

+ `, + }); +} + +async function findSupabaseUserByEmail(email: string): Promise<{ id: string } | null> { + let page = 1; + const perPage = 100; + + while (page <= 10) { + const { data, error } = await getSupabaseAdminClient().auth.admin.listUsers({ + page, + perPage, + }); + + if (error) { + throw new Error(`No se pudo consultar usuarios en Supabase: ${error.message}`); + } + + const found = data.users.find((user) => user.email?.toLowerCase() === email.toLowerCase()); + + if (found) { + return { id: found.id }; + } + + if (data.users.length < perPage) { + return null; + } + + page += 1; + } + + return null; +} + +export async function startPasswordReset( + input: PasswordResetStartInput +): Promise { + const email = normalizeEmail(input.email); + + const existingUser = await findSupabaseUserByEmail(email); + if (!existingUser) { + return { + message: 'Si el email existe, recibirás un código de verificación.', + requestId: uuidv7(), + email, + expiresAt: new Date().toISOString(), + cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, + }; + } + + const existingRequest = await db.passwordResetRequest.findFirst({ + where: { email, usedAt: null }, + orderBy: { createdAt: 'desc' }, + }); + + let requestId: string; + let otpCode: string; + let otpHash: string; + let otpExpiresAt: Date; + let cooldownSeconds = 0; + + if (existingRequest) { + requestId = existingRequest.id; + otpExpiresAt = new Date(existingRequest.otpExpiresAt); + + if (new Date() > otpExpiresAt) { + otpCode = createOtpCode(); + otpHash = hashValue(otpCode); + otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { + otpHash, + otpExpiresAt, + otpAttempts: 0, + otpLastSentAt: new Date(), + otpResendCount: { increment: 1 }, + }, + }); + } else { + cooldownSeconds = getCooldownSeconds(new Date(existingRequest.otpLastSentAt)); + + if (cooldownSeconds > 0) { + return { + message: `Debes esperar ${cooldownSeconds} segundos antes de reenviar.`, + requestId, + email, + expiresAt: otpExpiresAt.toISOString(), + cooldownSeconds, + }; + } + + otpCode = createOtpCode(); + otpHash = hashValue(otpCode); + otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { + otpHash, + otpExpiresAt, + otpAttempts: 0, + otpLastSentAt: new Date(), + otpResendCount: { increment: 1 }, + }, + }); + + await sendResetOtpEmail(email, otpCode); + + return { + message: 'Código reenviado.', + requestId, + email, + expiresAt: otpExpiresAt.toISOString(), + cooldownSeconds: 0, + }; + } + } else { + requestId = uuidv7(); + otpCode = createOtpCode(); + otpHash = hashValue(otpCode); + otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.passwordResetRequest.create({ + data: { + id: requestId, + email, + otpHash, + otpExpiresAt, + otpLastSentAt: new Date(), + }, + }); + } + + await sendResetOtpEmail(email, otpCode); + + return { + message: 'Código enviado successfully.', + requestId, + email, + expiresAt: otpExpiresAt.toISOString(), + cooldownSeconds: 0, + }; +} + +export async function verifyOtpAndResetPassword( + input: PasswordResetVerifyInput, + ip: string | undefined, + userAgent: string | undefined +): Promise { + const { requestId, otp, newPassword } = input; + + const request = await db.passwordResetRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request) { + throw new PasswordResetError('Solicitud inválida.', 404); + } + + if (request.usedAt) { + throw new PasswordResetError('Esta solicitud ya fue utilizada.', 400); + } + + if (new Date() > new Date(request.otpExpiresAt)) { + throw new PasswordResetError('El código ha vencido.', 400); + } + + const remainingAttempts = getRemainingAttempts(request.otpAttempts); + if (remainingAttempts <= 0) { + throw new PasswordResetError('Superaste la cantidad maxima de intentos.', 429); + } + + const otpHash = hashValue(otp); + if (otpHash !== request.otpHash) { + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { otpAttempts: { increment: 1 } }, + }); + + throw new PasswordResetError('Código incorrecto.', 400); + } + + const existingUser = await findSupabaseUserByEmail(request.email); + if (!existingUser) { + throw new PasswordResetError('Usuario no encontrado.', 404); + } + + await getSupabaseAdminClient().auth.admin.updateUserById(existingUser.id, { + password: newPassword, + }); + + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { usedAt: new Date() }, + }); + + const geoInfo = await fetchGeoInfo(ip || 'unknown'); + await sendPasswordChangedEmail(request.email, geoInfo, userAgent); + + return { + message: 'Contraseña restablecida correctamente.', + }; +} + +export async function resendPasswordResetOtp( + input: PasswordResetResendInput +): Promise { + const { requestId } = input; + + const request = await db.passwordResetRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request) { + throw new PasswordResetError('Solicitud inválida.', 404); + } + + if (request.usedAt) { + throw new PasswordResetError('Esta solicitud ya fue utilizada.', 400); + } + + const cooldownSeconds = getCooldownSeconds(new Date(request.otpLastSentAt)); + if (cooldownSeconds > 0) { + throw new PasswordResetError( + `Debes esperar ${cooldownSeconds} segundos antes de reenviar.`, + 429 + ); + } + + const otpCode = createOtpCode(); + const otpHash = hashValue(otpCode); + const otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + + await db.passwordResetRequest.update({ + where: { id: requestId }, + data: { + otpHash, + otpExpiresAt, + otpAttempts: 0, + otpLastSentAt: new Date(), + otpResendCount: { increment: 1 }, + }, + }); + + await sendResetOtpEmail(request.email, otpCode); + + return { + message: 'Código reenviado.', + requestId, + email: request.email, + expiresAt: otpExpiresAt.toISOString(), + cooldownSeconds: 0, + }; +} diff --git a/apps/backend/src/register-routes.ts b/apps/backend/src/register-routes.ts index 2500289..53721df 100644 --- a/apps/backend/src/register-routes.ts +++ b/apps/backend/src/register-routes.ts @@ -3,6 +3,7 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes import { complexRoutes } from '@/modules/complex/complex.routes'; import { courtRoutes } from '@/modules/court/court.routes'; import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes'; +import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes'; import { planRoutes } from '@/modules/plan/plan.routes'; import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes'; import { sportRoutes } from '@/modules/sport/sport.routes'; @@ -17,6 +18,7 @@ export function registerRoutes(app: Hono) { .route('/api/user', userRoutes) .route('/api/plans', planRoutes) .route('/api/onboarding', onboardingRoutes) + .route('/api/password-reset', passwordResetRoutes) .route('/api/complexes', complexRoutes) .route('/api/sports', sportRoutes) .route('/api/courts', courtRoutes) diff --git a/apps/frontend/src/features/login/login-page.tsx b/apps/frontend/src/features/login/login-page.tsx index f0a3af2..5b4c7b5 100644 --- a/apps/frontend/src/features/login/login-page.tsx +++ b/apps/frontend/src/features/login/login-page.tsx @@ -49,8 +49,8 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) { }; return ( -
-
+
+

Iniciar sesión

Ingresá con tu cuenta para acceder a Home. @@ -89,9 +89,8 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {

- ¿Solo querés explorar?{' '} - - Ir a About + + ¿Olvidaste tu contraseña?

diff --git a/apps/frontend/src/features/password-reset/reset-password-page.tsx b/apps/frontend/src/features/password-reset/reset-password-page.tsx new file mode 100644 index 0000000..2b005b2 --- /dev/null +++ b/apps/frontend/src/features/password-reset/reset-password-page.tsx @@ -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; +type OtpFormData = z.infer; + +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(''); + const [submitError, setSubmitError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const { + register: registerEmail, + handleSubmit: handleEmailSubmit, + formState: { errors: emailErrors, isSubmitting: isEmailSubmitting, isValid: isEmailValid }, + } = useForm({ + resolver: zodResolver(emailSchema), + mode: 'onChange', + defaultValues: { email: '' }, + }); + + const { + register: registerOtp, + handleSubmit: handleOtpSubmit, + formState: { errors: otpErrors, isSubmitting: isOtpSubmitting, isValid: isOtpValid }, + setValue, + watch, + } = useForm({ + 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 ( +
+
+
+
+

Listo

+

{successMessage}

+
+
+
+ ); + } + + return ( +
+
+

+ {step === 'email' ? 'Restablecer contraseña' : 'Ingresá el código'} +

+

+ {step === 'email' + ? 'Ingresá tu email para recibir el código de verificación.' + : 'Revisá tu email e ingresá el código que te enviamos.'} +

+ + {step === 'email' && ( +
+ + Email + + + + + {submitError &&

{submitError}

} + + +
+ )} + + {step === 'otp' && ( +
+ + Código OTP + { + setValue('otp', value, { shouldValidate: true }); + }} + > + + + + + + + + + + + + + + + + + Nueva contraseña + + + + + + Confirmar contraseña + + + + + {submitError &&

{submitError}

} + + + +
+ + +
+
+ )} + +

+ + Volver a iniciar sesión + +

+
+
+ ); +} diff --git a/apps/frontend/src/routes/reset-password.tsx b/apps/frontend/src/routes/reset-password.tsx new file mode 100644 index 0000000..5fa233b --- /dev/null +++ b/apps/frontend/src/routes/reset-password.tsx @@ -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 ; +} diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index eed4c0b..8f4d750 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -94,5 +94,19 @@ export type { } from './public-booking' export { sportSchema, createSportSchema, updateSportSchema } from './sport' export type { Sport, CreateSportInput, UpdateSportInput } from './sport' -export { userProfileSchema } from './user' -export type { UserProfile, UserProfileResponse } from './user' +export { + userProfileSchema, + passwordResetStartSchema, + passwordResetVerifySchema, + passwordResetResendSchema, +} from './user' +export type { + UserProfile, + UserProfileResponse, + PasswordResetStartInput, + PasswordResetVerifyInput, + PasswordResetResendInput, + PasswordResetStartResponse, + PasswordResetVerifyResponse, + PasswordResetResendResponse, +} from './user' diff --git a/packages/api-contract/src/user.ts b/packages/api-contract/src/user.ts index 718ab15..02b0767 100644 --- a/packages/api-contract/src/user.ts +++ b/packages/api-contract/src/user.ts @@ -11,3 +11,43 @@ export const userProfileSchema = z.object({ export type UserProfile = z.infer export type UserProfileResponse = UserProfile + +export const passwordResetStartSchema = z.object({ + email: z.string().email('Ingresá un email válido.'), +}) + +export type PasswordResetStartInput = z.infer + +export const passwordResetVerifySchema = z.object({ + requestId: z.string().uuid('Request ID inválido.'), + 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.'), +}) + +export type PasswordResetVerifyInput = z.infer + +export const passwordResetResendSchema = z.object({ + requestId: z.string().uuid('Request ID inválido.'), +}) + +export type PasswordResetResendInput = z.infer + +export type PasswordResetStartResponse = { + message: string; + requestId: string; + email: string; + expiresAt: string; + cooldownSeconds: number; +} + +export type PasswordResetVerifyResponse = { + message: string; +} + +export type PasswordResetResendResponse = { + message: string; + requestId: string; + email: string; + expiresAt: string; + cooldownSeconds: number; +}