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:
@@ -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");
|
||||||
16
apps/backend/prisma/password-reset.prisma
Normal file
16
apps/backend/prisma/password-reset.prisma
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<AppEnv>();
|
||||||
|
|
||||||
|
passwordResetRoutes.post(
|
||||||
|
'/start',
|
||||||
|
zValidator('json', passwordResetStartSchema),
|
||||||
|
startPasswordResetHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
passwordResetRoutes.post(
|
||||||
|
'/verify',
|
||||||
|
zValidator('json', passwordResetVerifySchema),
|
||||||
|
verifyPasswordResetHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
passwordResetRoutes.post(
|
||||||
|
'/resend',
|
||||||
|
zValidator('json', passwordResetResendSchema),
|
||||||
|
resendPasswordResetHandler
|
||||||
|
);
|
||||||
@@ -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<IpGeoInfo | null> {
|
||||||
|
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: `<p>Tu código OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: `
|
||||||
|
<h2>Tu contraseña ha sido cambiada</h2>
|
||||||
|
<p>Se ha modificado la contraseña de tu cuenta.</p>
|
||||||
|
<h3>Detalles del cambio:</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Fecha y hora:</strong> ${formattedDate}</li>
|
||||||
|
<li><strong>Dirección IP:</strong> ${ip}</li>
|
||||||
|
<li><strong>Ubicación:</strong> ${location}</li>
|
||||||
|
<li><strong>Dispositivo:</strong> ${deviceInfo.device} (${deviceInfo.os} - ${deviceInfo.browser})</li>
|
||||||
|
</ul>
|
||||||
|
<p>Si no fuiste vos, contactanos inmediatamente.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PasswordResetStartResult> {
|
||||||
|
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<PasswordResetVerifyResult> {
|
||||||
|
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<PasswordResetResendResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes
|
|||||||
import { complexRoutes } from '@/modules/complex/complex.routes';
|
import { complexRoutes } from '@/modules/complex/complex.routes';
|
||||||
import { courtRoutes } from '@/modules/court/court.routes';
|
import { courtRoutes } from '@/modules/court/court.routes';
|
||||||
import { onboardingRoutes } from '@/modules/onboarding/onboarding.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 { planRoutes } from '@/modules/plan/plan.routes';
|
||||||
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
|
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
|
||||||
import { sportRoutes } from '@/modules/sport/sport.routes';
|
import { sportRoutes } from '@/modules/sport/sport.routes';
|
||||||
@@ -17,6 +18,7 @@ export function registerRoutes(app: Hono<AppEnv>) {
|
|||||||
.route('/api/user', userRoutes)
|
.route('/api/user', userRoutes)
|
||||||
.route('/api/plans', planRoutes)
|
.route('/api/plans', planRoutes)
|
||||||
.route('/api/onboarding', onboardingRoutes)
|
.route('/api/onboarding', onboardingRoutes)
|
||||||
|
.route('/api/password-reset', passwordResetRoutes)
|
||||||
.route('/api/complexes', complexRoutes)
|
.route('/api/complexes', complexRoutes)
|
||||||
.route('/api/sports', sportRoutes)
|
.route('/api/sports', sportRoutes)
|
||||||
.route('/api/courts', courtRoutes)
|
.route('/api/courts', courtRoutes)
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||||
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<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>
|
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
Ingresá con tu cuenta para acceder a Home.
|
Ingresá con tu cuenta para acceder a Home.
|
||||||
@@ -89,9 +89,8 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
¿Solo querés explorar?{' '}
|
<Link to="/reset-password" className="text-primary hover:underline">
|
||||||
<Link to="/about" className="text-primary hover:underline">
|
¿Olvidaste tu contraseña?
|
||||||
Ir a About
|
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/frontend/src/routes/reset-password.tsx
Normal file
15
apps/frontend/src/routes/reset-password.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -94,5 +94,19 @@ export type {
|
|||||||
} from './public-booking'
|
} from './public-booking'
|
||||||
export { sportSchema, createSportSchema, updateSportSchema } from './sport'
|
export { sportSchema, createSportSchema, updateSportSchema } from './sport'
|
||||||
export type { Sport, CreateSportInput, UpdateSportInput } from './sport'
|
export type { Sport, CreateSportInput, UpdateSportInput } from './sport'
|
||||||
export { userProfileSchema } from './user'
|
export {
|
||||||
export type { UserProfile, UserProfileResponse } from './user'
|
userProfileSchema,
|
||||||
|
passwordResetStartSchema,
|
||||||
|
passwordResetVerifySchema,
|
||||||
|
passwordResetResendSchema,
|
||||||
|
} from './user'
|
||||||
|
export type {
|
||||||
|
UserProfile,
|
||||||
|
UserProfileResponse,
|
||||||
|
PasswordResetStartInput,
|
||||||
|
PasswordResetVerifyInput,
|
||||||
|
PasswordResetResendInput,
|
||||||
|
PasswordResetStartResponse,
|
||||||
|
PasswordResetVerifyResponse,
|
||||||
|
PasswordResetResendResponse,
|
||||||
|
} from './user'
|
||||||
|
|||||||
@@ -11,3 +11,43 @@ export const userProfileSchema = z.object({
|
|||||||
|
|
||||||
export type UserProfile = z.infer<typeof userProfileSchema>
|
export type UserProfile = z.infer<typeof userProfileSchema>
|
||||||
export type UserProfileResponse = UserProfile
|
export type UserProfileResponse = UserProfile
|
||||||
|
|
||||||
|
export const passwordResetStartSchema = z.object({
|
||||||
|
email: z.string().email('Ingresá un email válido.'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PasswordResetStartInput = z.infer<typeof passwordResetStartSchema>
|
||||||
|
|
||||||
|
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<typeof passwordResetVerifySchema>
|
||||||
|
|
||||||
|
export const passwordResetResendSchema = z.object({
|
||||||
|
requestId: z.string().uuid('Request ID inválido.'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PasswordResetResendInput = z.infer<typeof passwordResetResendSchema>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user