diff --git a/web-next/app/[locale]/recover/RecoverForm.tsx b/web-next/app/[locale]/recover/RecoverForm.tsx
new file mode 100644
index 0000000..d0ea59b
--- /dev/null
+++ b/web-next/app/[locale]/recover/RecoverForm.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Turnstile } from '@/components/Turnstile'
+
+const ERROR_KEYS = ['invalidEmail', 'captchaFailed'] as const
+const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
+
+export function RecoverForm() {
+ const t = useTranslations('Recover')
+ const [type, setType] = useState('password')
+ const [email, setEmail] = useState('')
+ const [captcha, setCaptcha] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (busy) return
+ setBusy(true)
+ setMessage(null)
+ try {
+ const res = await fetch('/api/auth/recover', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type, email: email.trim(), turnstileToken: captcha }),
+ })
+ const data: { success?: boolean; error?: string } = await res.json()
+ if (data.success) setMessage({ ok: true, text: t('success') })
+ else {
+ const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError'
+ setMessage({ ok: false, text: t(key) })
+ }
+ } catch {
+ setMessage({ ok: false, text: t('genericError') })
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
+
+ return (
+
+
+ {message &&
{message.text}
}
+
+ )
+}
diff --git a/web-next/app/[locale]/recover/page.tsx b/web-next/app/[locale]/recover/page.tsx
new file mode 100644
index 0000000..033c180
--- /dev/null
+++ b/web-next/app/[locale]/recover/page.tsx
@@ -0,0 +1,14 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { RecoverForm } from './RecoverForm'
+
+export default async function RecoverPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ const t = await getTranslations('Recover')
+ return (
+
+ {t('title')}
+
+
+ )
+}
diff --git a/web-next/app/[locale]/reset-password/ResetForm.tsx b/web-next/app/[locale]/reset-password/ResetForm.tsx
new file mode 100644
index 0000000..ac9e196
--- /dev/null
+++ b/web-next/app/[locale]/reset-password/ResetForm.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { useRouter, Link } from '@/i18n/navigation'
+
+const ERROR_KEYS = ['invalidLink', 'expiredLink', 'passwordMismatch', 'passwordTooLong', 'accountNotFound'] as const
+
+export function ResetForm({ token }: { token: string }) {
+ const t = useTranslations('Reset')
+ const router = useRouter()
+ const [password, setPassword] = useState('')
+ const [confPassword, setConfPassword] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [done, setDone] = useState(false)
+ const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (busy || done) return
+ setBusy(true)
+ setMessage(null)
+ try {
+ const res = await fetch('/api/auth/reset-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token, password, confPassword }),
+ })
+ const data: { success?: boolean; error?: string } = await res.json()
+ if (data.success) {
+ setDone(true)
+ setMessage({ ok: true, text: t('success') })
+ setTimeout(() => router.push('/login'), 2500)
+ } else {
+ const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError'
+ setMessage({ ok: false, text: t(key) })
+ }
+ } catch {
+ setMessage({ ok: false, text: t('genericError') })
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
+
+ return (
+
+
+ {message &&
{message.text}
}
+ {done && (
+
+
+ {t('goLogin')}
+
+
+ )}
+
+ )
+}
diff --git a/web-next/app/[locale]/reset-password/page.tsx b/web-next/app/[locale]/reset-password/page.tsx
new file mode 100644
index 0000000..f45077f
--- /dev/null
+++ b/web-next/app/[locale]/reset-password/page.tsx
@@ -0,0 +1,22 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { ResetForm } from './ResetForm'
+
+export default async function ResetPasswordPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams: Promise<{ token?: string }>
+}) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ const { token } = await searchParams
+ const t = await getTranslations('Reset')
+
+ return (
+
+ {t('title')}
+
+
+ )
+}
diff --git a/web-next/app/api/auth/recover/route.ts b/web-next/app/api/auth/recover/route.ts
new file mode 100644
index 0000000..44ef7b9
--- /dev/null
+++ b/web-next/app/api/auth/recover/route.ts
@@ -0,0 +1,22 @@
+import { requestRecovery } from '@/lib/recover'
+import { verifyTurnstile } from '@/lib/turnstile'
+
+export async function POST(request: Request) {
+ let type = ''
+ let email = ''
+ let turnstileToken = ''
+ try {
+ const body = await request.json()
+ type = String(body.type ?? '')
+ email = String(body.email ?? '')
+ turnstileToken = String(body.turnstileToken ?? '')
+ } catch {
+ return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
+ }
+ const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim()
+ if (!(await verifyTurnstile(turnstileToken, ip))) {
+ return Response.json({ success: false, error: 'captchaFailed' })
+ }
+ const result = await requestRecovery(type, email)
+ return Response.json(result)
+}
diff --git a/web-next/app/api/auth/reset-password/route.ts b/web-next/app/api/auth/reset-password/route.ts
new file mode 100644
index 0000000..ac23217
--- /dev/null
+++ b/web-next/app/api/auth/reset-password/route.ts
@@ -0,0 +1,18 @@
+import { resetPassword } from '@/lib/recover'
+
+export async function POST(request: Request) {
+ let token = ''
+ let password = ''
+ let confPassword = ''
+ try {
+ const body = await request.json()
+ token = String(body.token ?? '')
+ password = String(body.password ?? '').trim()
+ confPassword = String(body.confPassword ?? '').trim()
+ } catch {
+ return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
+ }
+ if (!token) return Response.json({ success: false, error: 'invalidLink' })
+ const result = await resetPassword(token, password, confPassword)
+ return Response.json(result)
+}
diff --git a/web-next/lib/recover.ts b/web-next/lib/recover.ts
new file mode 100644
index 0000000..03af418
--- /dev/null
+++ b/web-next/lib/recover.ts
@@ -0,0 +1,129 @@
+import crypto from 'node:crypto'
+import type { RowDataPacket } from 'mysql2'
+import { db, DB } from './db'
+import { normalizeEmail, bnetMakeRegistration } from './bnet'
+import { sendMail } from './mail'
+
+const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
+
+export interface Result {
+ success: boolean
+ error?: string
+ redirect?: string
+}
+
+function emailShell(title: string, bodyHtml: string): string {
+ return `
+
+
Nova WoW
${title}
${bodyHtml}
+ `
+}
+
+/** Recuperación por email (anti-enumeración: respuesta genérica siempre). */
+export async function requestRecovery(type: string, email: string): Promise {
+ email = (email || '').trim()
+ if (!email || !GMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' }
+ const site = process.env.SITE_URL || ''
+
+ if (type === 'password') {
+ try {
+ const [rows] = await db(DB.auth).query(
+ 'SELECT id FROM battlenet_accounts WHERE email = ?',
+ [normalizeEmail(email)],
+ )
+ if (rows[0]) {
+ const token = crypto.randomBytes(32).toString('hex')
+ await db(DB.default).query(
+ 'INSERT INTO home_passwordreset (email, token, created_at, used) VALUES (?, ?, NOW(), 0)',
+ [email, token],
+ )
+ const link = `${site}/reset-password?token=${token}`
+ await sendMail(
+ email,
+ 'Restablecer contraseña - Nova WoW',
+ emailShell(
+ 'Restablecer contraseña',
+ `Pulsa para elegir una nueva contraseña (válido 1 hora):
+ Restablecer
+ ${link}
`,
+ ),
+ )
+ }
+ } catch {
+ /* acore no disponible: respuesta genérica igualmente */
+ }
+ return { success: true }
+ }
+
+ if (type === 'accountname') {
+ try {
+ const [acc] = await db(DB.auth).query(
+ 'SELECT id FROM battlenet_accounts WHERE email = ?',
+ [normalizeEmail(email)],
+ )
+ if (acc[0]) {
+ const [games] = await db(DB.auth).query(
+ 'SELECT username FROM account WHERE battlenet_account = ? ORDER BY battlenet_index',
+ [acc[0].id],
+ )
+ const list = games.map((g) => `${g.username}`).join('') || '—'
+ await sendMail(
+ email,
+ 'Tus cuentas de juego - Nova WoW',
+ emailShell('Tus cuentas de juego', `Cuentas asociadas a ${email}:
`),
+ )
+ }
+ } catch {
+ /* ignore */
+ }
+ return { success: true }
+ }
+
+ if (type === 'activation') {
+ try {
+ const [rows] = await db(DB.default).query(
+ 'SELECT hash, password FROM home_accountactivation WHERE email = ? AND old_email IS NULL ORDER BY created_at DESC LIMIT 1',
+ [email],
+ )
+ if (rows[0]) {
+ const link = `${site}/activate-account?act=${rows[0].hash}`
+ await sendMail(
+ email,
+ `Activación de la cuenta ${email} - Nova WoW`,
+ emailShell('Activa tu cuenta', `Activar cuenta
${link}
`),
+ )
+ }
+ } catch {
+ /* ignore */
+ }
+ return { success: true }
+ }
+
+ return { success: false, error: 'invalidType' }
+}
+
+export async function resetPassword(token: string, newPassword: string, confPassword: string): Promise {
+ const [rows] = await db(DB.default).query(
+ 'SELECT id, email, created_at FROM home_passwordreset WHERE token = ? AND used = 0',
+ [token],
+ )
+ const pr = rows[0]
+ if (!pr) return { success: false, error: 'invalidLink' }
+ if (Date.now() - new Date(pr.created_at).getTime() > 3600_000) return { success: false, error: 'expiredLink' }
+ if (!newPassword || newPassword !== confPassword) return { success: false, error: 'passwordMismatch' }
+ if (newPassword.length > 16) return { success: false, error: 'passwordTooLong' }
+
+ try {
+ const reg = bnetMakeRegistration(pr.email, newPassword)
+ const [res] = await db(DB.auth).query(
+ 'UPDATE battlenet_accounts SET srp_version = ?, salt = ?, verifier = ? WHERE email = ?',
+ [reg.srpVersion, reg.salt, reg.verifier, normalizeEmail(pr.email)],
+ )
+ // @ts-expect-error affectedRows
+ if (!res.affectedRows) return { success: false, error: 'accountNotFound' }
+ } catch {
+ return { success: false, error: 'genericError' }
+ }
+ await db(DB.default).query('UPDATE home_passwordreset SET used = 1 WHERE id = ?', [pr.id])
+ return { success: true, redirect: '/login' }
+}
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index a21463a..9c21eda 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -81,5 +81,34 @@
"invalidLink": "The link is invalid or has already been used.",
"expiredLink": "The link has expired. Please request a new one.",
"goLogin": "Go to sign in"
+ },
+ "Recover": {
+ "title": "Recover account",
+ "type": "What do you want to recover?",
+ "typePassword": "Password",
+ "typeAccountName": "Account name",
+ "typeActivation": "Activation link",
+ "email": "Gmail email address",
+ "submit": "Request",
+ "sending": "Sending…",
+ "success": "If an account exists with that email, we have sent you the information.",
+ "invalidEmail": "Enter a valid Gmail address.",
+ "genericError": "Something went wrong. Please try again later.",
+ "captchaFailed": "Anti-bot verification failed. Please retry."
+ },
+ "Reset": {
+ "title": "Reset password",
+ "password": "New password",
+ "confPassword": "Confirm password",
+ "submit": "Reset",
+ "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.",
+ "passwordMismatch": "Passwords do not match.",
+ "passwordTooLong": "The password must not exceed 16 characters.",
+ "accountNotFound": "Account not found.",
+ "genericError": "Something went wrong. Please try again later."
}
}
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index 247b757..ebefa1a 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -81,5 +81,34 @@
"invalidLink": "El enlace no es válido o ya se ha usado.",
"expiredLink": "El enlace ha caducado. Solicita uno nuevo.",
"goLogin": "Ir a iniciar sesión"
+ },
+ "Recover": {
+ "title": "Recuperar cuenta",
+ "type": "¿Qué quieres recuperar?",
+ "typePassword": "Contraseña",
+ "typeAccountName": "Nombre de cuenta",
+ "typeActivation": "Enlace de activación",
+ "email": "Correo electrónico de Gmail",
+ "submit": "Solicitar",
+ "sending": "Enviando…",
+ "success": "Si existe una cuenta con ese correo, te hemos enviado la información.",
+ "invalidEmail": "Introduce un correo de Gmail válido.",
+ "genericError": "Algo ha salido mal. Inténtalo más tarde.",
+ "captchaFailed": "Verificación anti-bots fallida. Reintenta."
+ },
+ "Reset": {
+ "title": "Restablecer contraseña",
+ "password": "Nueva contraseña",
+ "confPassword": "Confirmar contraseña",
+ "submit": "Restablecer",
+ "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.",
+ "passwordMismatch": "Las contraseñas no coinciden.",
+ "passwordTooLong": "La contraseña no debe exceder los 16 caracteres.",
+ "accountNotFound": "No se ha encontrado la cuenta.",
+ "genericError": "Algo ha salido mal. Inténtalo más tarde."
}
}