From 4ee7d789949c8b22dba78896055dd0cfdb5b0d5f Mon Sep 17 00:00:00 2001 From: adevopg Date: Wed, 15 Jul 2026 19:13:06 +0000 Subject: [PATCH] El enlace de restablecer deja de servir una vez usado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La página pintaba el formulario sin mirar el token: rellenabas la contraseña nueva y solo al enviarla te enterabas de que el enlace ya no valía. El API sí marcaba `used = 1` y rechazaba el reintento, pero la pantalla no lo reflejaba. Ahora la página comprueba el token al abrirse con `isResetTokenValid()` (mismas condiciones que `resetPassword` —existe, sin usar, dentro de la hora— pero SIN consumirlo) y, si no vale, muestra el aviso del tema en vez del formulario. Textos: `invalidLink` pasa a «El enlace de restablecer la contraseña es inválido» y se añade `needHelp`, como en la página de activación. Verificado en producción con enlaces reales: válido -> formulario; usado, caducado, inexistente y sin token -> aviso, sin formulario. Y el ciclo entero: restablecer con un enlace válido lo marca usado y al reabrirlo ya sale el aviso. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/reset-password/page.tsx | 22 +++++++++++++++++++ web-next/lib/recover.ts | 20 +++++++++++++++++ web-next/messages/en.json | 5 +++-- web-next/messages/es.json | 5 +++-- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/web-next/app/[locale]/reset-password/page.tsx b/web-next/app/[locale]/reset-password/page.tsx index d61c4d8..31063c4 100644 --- a/web-next/app/[locale]/reset-password/page.tsx +++ b/web-next/app/[locale]/reset-password/page.tsx @@ -1,7 +1,12 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { PageShell } from '@/components/PageShell' +import { getRealmName } from '@/lib/realm' +import { isResetTokenValid } from '@/lib/recover' import { ResetForm } from './ResetForm' +// Consulta el token en la BD al abrirse: no se puede prerenderizar. +export const dynamic = 'force-dynamic' + export default async function ResetPasswordPage({ params, searchParams, @@ -14,6 +19,23 @@ export default async function ResetPasswordPage({ const { token } = await searchParams const t = await getTranslations('Reset') + // El enlace es de un solo uso y dura 1 hora. Antes el formulario se pintaba + // igualmente y solo al enviarlo te enterabas de que el enlace ya no servía. + if (!(await isResetTokenValid(token ?? ''))) { + const realm = await getRealmName() + return ( + +
+
+

{t('invalidLink')}

+
+

{t('needHelp', { realm })}

+
+
+
+ ) + } + return (
diff --git a/web-next/lib/recover.ts b/web-next/lib/recover.ts index edc3401..3bd7a09 100644 --- a/web-next/lib/recover.ts +++ b/web-next/lib/recover.ts @@ -144,6 +144,26 @@ export async function requestRecovery(type: string, value: string): Promise { + if (!token) return false + try { + const [rows] = await db(DB.default).query( + 'SELECT created_at FROM passwordreset WHERE token = ? AND used = 0', + [token], + ) + const pr = rows[0] + if (!pr) return false + return Date.now() - new Date(pr.created_at).getTime() <= 3600_000 + } catch { + return false + } +} + export async function resetPassword(token: string, newPassword: string, confPassword: string): Promise { const [rows] = await db(DB.default).query( 'SELECT id, email, created_at FROM passwordreset WHERE token = ? AND used = 0', diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 9f7c051..f43323c 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -276,8 +276,9 @@ "saving": "Saving…", "success": "Password reset. You can now sign in.", "goLogin": "Go to sign in", - "invalidLink": "The link is invalid or has already been used.", - "expiredLink": "The link has expired. Please request a new one.", + "invalidLink": "The password reset link is invalid", + "expiredLink": "The password reset link has expired", + "needHelp": "If you need help creating an account, you can contact the {realm} team.", "passwordMismatch": "Passwords do not match.", "passwordTooLong": "The password must not exceed 16 characters.", "accountNotFound": "Account not found.", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 08d7f47..d37faf1 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -276,8 +276,9 @@ "saving": "Guardando…", "success": "Contraseña restablecida. Ya puedes iniciar sesión.", "goLogin": "Ir a iniciar sesión", - "invalidLink": "El enlace no es válido o ya se ha usado.", - "expiredLink": "El enlace ha caducado. Solicita uno nuevo.", + "invalidLink": "El enlace de restablecer la contraseña es inválido", + "expiredLink": "El enlace de restablecer la contraseña ha caducado", + "needHelp": "Si necesitas ayuda para crear una cuenta, puedes contactar con el equipo de {realm}.", "passwordMismatch": "Las contraseñas no coinciden.", "passwordTooLong": "La contraseña no debe exceder los 16 caracteres.", "accountNotFound": "No se ha encontrado la cuenta.",