El enlace de restablecer deja de servir una vez usado
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
import { PageShell } from '@/components/PageShell'
|
import { PageShell } from '@/components/PageShell'
|
||||||
|
import { getRealmName } from '@/lib/realm'
|
||||||
|
import { isResetTokenValid } from '@/lib/recover'
|
||||||
import { ResetForm } from './ResetForm'
|
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({
|
export default async function ResetPasswordPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -14,6 +19,23 @@ export default async function ResetPasswordPage({
|
|||||||
const { token } = await searchParams
|
const { token } = await searchParams
|
||||||
const t = await getTranslations('Reset')
|
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 (
|
||||||
|
<PageShell title={t('title')}>
|
||||||
|
<div className="box-content">
|
||||||
|
<div className="body-box-content centered">
|
||||||
|
<p className="red-info2">{t('invalidLink')}</p>
|
||||||
|
<br />
|
||||||
|
<p>{t('needHelp', { realm })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title={t('title')}>
|
<PageShell title={t('title')}>
|
||||||
<div className="box-content">
|
<div className="box-content">
|
||||||
|
|||||||
@@ -144,6 +144,26 @@ export async function requestRecovery(type: string, value: string): Promise<Resu
|
|||||||
return { success: false, error: 'invalidType' }
|
return { success: false, error: 'invalidType' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ¿El enlace de restablecer sigue sirviendo? Mismas condiciones que `resetPassword`
|
||||||
|
* (existe, sin usar y dentro de la hora) pero SIN consumirlo: lo llama la página al
|
||||||
|
* abrirse, para no enseñar el formulario de un enlace que ya no vale.
|
||||||
|
*/
|
||||||
|
export async function isResetTokenValid(token: string): Promise<boolean> {
|
||||||
|
if (!token) return false
|
||||||
|
try {
|
||||||
|
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||||
|
'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<Result> {
|
export async function resetPassword(token: string, newPassword: string, confPassword: string): Promise<Result> {
|
||||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||||
'SELECT id, email, created_at FROM passwordreset WHERE token = ? AND used = 0',
|
'SELECT id, email, created_at FROM passwordreset WHERE token = ? AND used = 0',
|
||||||
|
|||||||
@@ -276,8 +276,9 @@
|
|||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"success": "Password reset. You can now sign in.",
|
"success": "Password reset. You can now sign in.",
|
||||||
"goLogin": "Go to sign in",
|
"goLogin": "Go to sign in",
|
||||||
"invalidLink": "The link is invalid or has already been used.",
|
"invalidLink": "The password reset link is invalid",
|
||||||
"expiredLink": "The link has expired. Please request a new one.",
|
"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.",
|
"passwordMismatch": "Passwords do not match.",
|
||||||
"passwordTooLong": "The password must not exceed 16 characters.",
|
"passwordTooLong": "The password must not exceed 16 characters.",
|
||||||
"accountNotFound": "Account not found.",
|
"accountNotFound": "Account not found.",
|
||||||
|
|||||||
@@ -276,8 +276,9 @@
|
|||||||
"saving": "Guardando…",
|
"saving": "Guardando…",
|
||||||
"success": "Contraseña restablecida. Ya puedes iniciar sesión.",
|
"success": "Contraseña restablecida. Ya puedes iniciar sesión.",
|
||||||
"goLogin": "Ir a iniciar sesión",
|
"goLogin": "Ir a iniciar sesión",
|
||||||
"invalidLink": "El enlace no es válido o ya se ha usado.",
|
"invalidLink": "El enlace de restablecer la contraseña es inválido",
|
||||||
"expiredLink": "El enlace ha caducado. Solicita uno nuevo.",
|
"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.",
|
"passwordMismatch": "Las contraseñas no coinciden.",
|
||||||
"passwordTooLong": "La contraseña no debe exceder los 16 caracteres.",
|
"passwordTooLong": "La contraseña no debe exceder los 16 caracteres.",
|
||||||
"accountNotFound": "No se ha encontrado la cuenta.",
|
"accountNotFound": "No se ha encontrado la cuenta.",
|
||||||
|
|||||||
Reference in New Issue
Block a user