From cceab26999f092473f786da4393f537e516c2559 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 23:41:42 +0000 Subject: [PATCH] =?UTF-8?q?Cambio=20de=20email=20con=20doble=20confirmaci?= =?UTF-8?q?=C3=B3n=20(completa=20el=20bloque=20de=20auth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/change-email.ts: requestEmailChange (valida pass/email/token, crea activación con old_email/old_email_hash, email al correo actual), confirmOldEmail (marca is_used, email al nuevo), confirmNewEmail (re-deriva verifier con el nuevo email + actualiza battlenet_accounts y account; borra la activación). - components/ConfirmClient.tsx: cliente genérico que confirma un hash por POST al abrir. - Routes /api/account/change-email, /confirm-old-email, /confirm-new-email. - Páginas /change-email (protegida) y /confirm-old-email, /confirm-new-email (auto). - Catálogos ChangeEmail, ConfirmOldEmail, ConfirmNewEmail. Verificado: change-email protegida (401/redirect), confirmaciones con hash inválido -> invalidLink sin crash. AUTH 100% reimplementado en Next.js. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[locale]/change-email/ChangeEmailForm.tsx | 61 ++++++++++ web-next/app/[locale]/change-email/page.tsx | 21 ++++ .../app/[locale]/confirm-new-email/page.tsx | 15 +++ .../app/[locale]/confirm-old-email/page.tsx | 15 +++ .../app/api/account/change-email/route.ts | 23 ++++ .../api/account/confirm-new-email/route.ts | 13 +++ .../api/account/confirm-old-email/route.ts | 13 +++ web-next/components/ConfirmClient.tsx | 58 ++++++++++ web-next/lib/change-email.ts | 109 ++++++++++++++++++ web-next/messages/en.json | 31 +++++ web-next/messages/es.json | 31 +++++ 11 files changed, 390 insertions(+) create mode 100644 web-next/app/[locale]/change-email/ChangeEmailForm.tsx create mode 100644 web-next/app/[locale]/change-email/page.tsx create mode 100644 web-next/app/[locale]/confirm-new-email/page.tsx create mode 100644 web-next/app/[locale]/confirm-old-email/page.tsx create mode 100644 web-next/app/api/account/change-email/route.ts create mode 100644 web-next/app/api/account/confirm-new-email/route.ts create mode 100644 web-next/app/api/account/confirm-old-email/route.ts create mode 100644 web-next/components/ConfirmClient.tsx create mode 100644 web-next/lib/change-email.ts diff --git a/web-next/app/[locale]/change-email/ChangeEmailForm.tsx b/web-next/app/[locale]/change-email/ChangeEmailForm.tsx new file mode 100644 index 0000000..aeedcce --- /dev/null +++ b/web-next/app/[locale]/change-email/ChangeEmailForm.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' + +const ERROR_KEYS = ['missingFields', 'wrongCurrentEmail', 'invalidEmail', 'invalidToken', 'wrongCurrentPassword'] as const + +export function ChangeEmailForm() { + const t = useTranslations('ChangeEmail') + const [form, setForm] = useState({ currentPassword: '', currentEmail: '', newEmail: '', confEmail: '', token: '' }) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + function upd(k: string, v: string) { + setForm((f) => ({ ...f, [k]: v })) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/account/change-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(form), + }) + 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 ( +
+

{t('info')}

+
+ upd('currentPassword', e.target.value)} className={field} /> + upd('currentEmail', e.target.value)} className={field} /> + upd('newEmail', e.target.value)} className={field} /> + upd('confEmail', e.target.value)} className={field} /> + upd('token', e.target.value)} className={field} /> + +
+ {message &&

{message.text}

} +
+ ) +} diff --git a/web-next/app/[locale]/change-email/page.tsx b/web-next/app/[locale]/change-email/page.tsx new file mode 100644 index 0000000..c8968a6 --- /dev/null +++ b/web-next/app/[locale]/change-email/page.tsx @@ -0,0 +1,21 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { ChangeEmailForm } from './ChangeEmailForm' + +export const dynamic = 'force-dynamic' + +export default async function ChangeEmailPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('ChangeEmail') + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/[locale]/confirm-new-email/page.tsx b/web-next/app/[locale]/confirm-new-email/page.tsx new file mode 100644 index 0000000..6c83814 --- /dev/null +++ b/web-next/app/[locale]/confirm-new-email/page.tsx @@ -0,0 +1,15 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { ConfirmClient } from '@/components/ConfirmClient' + +export default async function Page({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ hash?: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const { hash } = await searchParams + const t = await getTranslations('ConfirmNewEmail') + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/[locale]/confirm-old-email/page.tsx b/web-next/app/[locale]/confirm-old-email/page.tsx new file mode 100644 index 0000000..532280a --- /dev/null +++ b/web-next/app/[locale]/confirm-old-email/page.tsx @@ -0,0 +1,15 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { ConfirmClient } from '@/components/ConfirmClient' + +export default async function Page({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ hash?: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const { hash } = await searchParams + const t = await getTranslations('ConfirmOldEmail') + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/api/account/change-email/route.ts b/web-next/app/api/account/change-email/route.ts new file mode 100644 index 0000000..9b171f2 --- /dev/null +++ b/web-next/app/api/account/change-email/route.ts @@ -0,0 +1,23 @@ +import { getSession } from '@/lib/session' +import { requestEmailChange } from '@/lib/change-email' + +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + let b: Record = {} + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + return Response.json( + await requestEmailChange( + session, + String(b.currentPassword ?? ''), + String(b.currentEmail ?? ''), + String(b.newEmail ?? ''), + String(b.confEmail ?? ''), + String(b.token ?? ''), + ), + ) +} diff --git a/web-next/app/api/account/confirm-new-email/route.ts b/web-next/app/api/account/confirm-new-email/route.ts new file mode 100644 index 0000000..96134b9 --- /dev/null +++ b/web-next/app/api/account/confirm-new-email/route.ts @@ -0,0 +1,13 @@ +import { confirmNewEmail } from '@/lib/change-email' + +export async function POST(request: Request) { + let hash = '' + try { + const b = await request.json() + hash = String(b.hash ?? '') + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + if (!hash) return Response.json({ success: false, error: 'invalidLink' }) + return Response.json(await confirmNewEmail(hash)) +} diff --git a/web-next/app/api/account/confirm-old-email/route.ts b/web-next/app/api/account/confirm-old-email/route.ts new file mode 100644 index 0000000..c27e985 --- /dev/null +++ b/web-next/app/api/account/confirm-old-email/route.ts @@ -0,0 +1,13 @@ +import { confirmOldEmail } from '@/lib/change-email' + +export async function POST(request: Request) { + let hash = '' + try { + const b = await request.json() + hash = String(b.hash ?? '') + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + if (!hash) return Response.json({ success: false, error: 'invalidLink' }) + return Response.json(await confirmOldEmail(hash)) +} diff --git a/web-next/components/ConfirmClient.tsx b/web-next/components/ConfirmClient.tsx new file mode 100644 index 0000000..64af165 --- /dev/null +++ b/web-next/components/ConfirmClient.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' + +/** Cliente genérico que confirma un hash contra un endpoint (POST) al abrir la página. */ +export function ConfirmClient({ + hash, + endpoint, + namespace, + showLogin = false, +}: { + hash: string + endpoint: string + namespace: string + showLogin?: boolean +}) { + const t = useTranslations(namespace) + const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading') + const ran = useRef(false) + + useEffect(() => { + if (ran.current) return + ran.current = true + if (!hash) { + setState('error') + return + } + fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash }), + }) + .then((r) => r.json()) + .then((d: { success?: boolean }) => setState(d.success ? 'ok' : 'error')) + .catch(() => setState('error')) + }, [hash, endpoint]) + + return ( +
+ {state === 'loading' &&

{t('activating')}

} + {state === 'ok' && ( + <> +

{t('success')}

+ {showLogin && ( +

+ + {t('goLogin')} + +

+ )} + + )} + {state === 'error' &&

{t('error')}

} +
+ ) +} diff --git a/web-next/lib/change-email.ts b/web-next/lib/change-email.ts new file mode 100644 index 0000000..dfc45ad --- /dev/null +++ b/web-next/lib/change-email.ts @@ -0,0 +1,109 @@ +import crypto from 'node:crypto' +import type { RowDataPacket, ResultSetHeader } from 'mysql2' +import { db, DB } from './db' +import { authenticate } from './auth' +import { bnetMakeRegistration, normalizeEmail } from './bnet' +import { checkSecurityToken } from './security-token' +import { sendMail } from './mail' +import type { SessionData } from './session' + +const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/ + +export interface Result { + success: boolean + error?: string +} + +function shell(title: string, body: string): string { + return ` +
+

Nova WoW

${title}

${body}
` +} + +export async function requestEmailChange( + session: SessionData, + curPassword: string, + curEmail: string, + newEmail: string, + confEmail: string, + token: string, +): Promise { + const current = session.bnetEmail ?? '' // ya normalizado (MAYÚS) + const userId = session.accountId ?? 0 + if (!curPassword || !curEmail || !newEmail || !confEmail || !token) return { success: false, error: 'missingFields' } + if (normalizeEmail(curEmail) !== current) return { success: false, error: 'wrongCurrentEmail' } + if (newEmail !== confEmail || !GMAIL_RE.test(newEmail)) return { success: false, error: 'invalidEmail' } + if (!(await checkSecurityToken(userId, token))) return { success: false, error: 'invalidToken' } + if (!(await authenticate(current, curPassword))) return { success: false, error: 'wrongCurrentPassword' } + + const oldHash = crypto.randomBytes(16).toString('hex') + const newHash = crypto.randomBytes(16).toString('hex') + await db(DB.default).query( + 'INSERT INTO home_accountactivation (username, email, old_email, password, recruiter_id, hash, old_email_hash, is_used, is_new_email_used, created_at) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NOW())', + [session.username ?? null, newEmail, current, curPassword, userId, newHash, oldHash], + ) + + const site = process.env.SITE_URL || '' + const link = `${site}/confirm-old-email?hash=${oldHash}` + await sendMail( + current, + `Confirma el cambio de correo - Nova WoW`, + shell('Confirma el cambio de correo', `

Has solicitado cambiar tu correo a ${newEmail}. Confirma desde tu correo actual:

Confirmar

${link}

`), + ) + return { success: true } +} + +export async function confirmOldEmail(hash: string): Promise { + const [rows] = await db(DB.default).query( + 'SELECT id, email, hash FROM home_accountactivation WHERE old_email_hash = ? AND is_used = 0', + [hash], + ) + const act = rows[0] + if (!act) return { success: false, error: 'invalidLink' } + await db(DB.default).query('UPDATE home_accountactivation SET is_used = 1 WHERE id = ?', [act.id]) + + const site = process.env.SITE_URL || '' + const link = `${site}/confirm-new-email?hash=${act.hash}` + await sendMail( + act.email, + `Confirma tu nuevo correo - Nova WoW`, + shell('Confirma tu nuevo correo', `

Confirma este correo como el nuevo de tu cuenta:

Confirmar nuevo correo

${link}

`), + ) + return { success: true } +} + +export async function confirmNewEmail(hash: string): Promise { + const [rows] = await db(DB.default).query( + 'SELECT id, email, old_email, password FROM home_accountactivation WHERE hash = ? AND is_new_email_used = 0 AND old_email IS NOT NULL', + [hash], + ) + const act = rows[0] + if (!act) return { success: false, error: 'invalidLink' } + await db(DB.default).query('UPDATE home_accountactivation SET is_new_email_used = 1 WHERE id = ?', [act.id]) + + const newNorm = normalizeEmail(act.email) + const oldNorm = normalizeEmail(act.old_email || '') + try { + const reg = bnetMakeRegistration(newNorm, act.password) + const [bnetRows] = await db(DB.auth).query( + 'SELECT id FROM battlenet_accounts WHERE email = ?', + [oldNorm], + ) + if (!bnetRows[0]) return { success: false, error: 'accountNotFound' } + const bnetId = bnetRows[0].id + await db(DB.auth).query( + 'UPDATE battlenet_accounts SET email = ?, srp_version = ?, salt = ?, verifier = ? WHERE id = ?', + [newNorm, reg.srpVersion, reg.salt, reg.verifier, bnetId], + ) + await db(DB.auth).query('UPDATE account SET email = ?, reg_mail = ? WHERE battlenet_account = ?', [ + newNorm, + newNorm, + bnetId, + ]) + } catch { + return { success: false, error: 'genericError' } + } + await db(DB.default).query('DELETE FROM home_accountactivation WHERE id = ?', [act.id]) + return { success: true } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index e143ebe..f6f0090 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -206,5 +206,36 @@ "wrongCurrentPassword": "The current password is incorrect.", "accountNotFound": "Account not found.", "genericError": "Something went wrong. Please try again later." + }, + "ChangeEmail": { + "title": "Change email", + "info": "The current email must be entered exactly, respecting case. The new email must be a Gmail address. You need a security token.", + "currentPassword": "Current password", + "currentEmail": "Current email", + "newEmail": "New email", + "confEmail": "Confirm new email", + "token": "Security token", + "submit": "Change email", + "sending": "Sending…", + "success": "A confirmation email has been sent to your current email.", + "missingFields": "Please fill in all fields.", + "wrongCurrentEmail": "The current email is not correct.", + "invalidEmail": "The new email is not valid.", + "invalidToken": "The security token is incorrect.", + "wrongCurrentPassword": "The current password is incorrect.", + "genericError": "Something went wrong. Please try again later." + }, + "ConfirmOldEmail": { + "title": "Confirm email change", + "activating": "Confirming…", + "success": "Current email confirmed. Check your NEW email to complete the second step.", + "error": "The link is invalid or has already been used." + }, + "ConfirmNewEmail": { + "title": "Confirm new email", + "activating": "Applying the change…", + "success": "Email changed successfully! You can now sign in with the new email.", + "error": "The link is invalid, expired or the account was not found.", + "goLogin": "Go to sign in" } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 700437f..8a0ce81 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -206,5 +206,36 @@ "wrongCurrentPassword": "La contraseña actual es incorrecta.", "accountNotFound": "No se ha encontrado la cuenta.", "genericError": "Algo ha salido mal. Inténtalo más tarde." + }, + "ChangeEmail": { + "title": "Cambiar correo", + "info": "El correo actual debe escribirse tal cual, respetando mayúsculas/minúsculas. El nuevo correo debe ser de Gmail. Necesitas un token de seguridad.", + "currentPassword": "Contraseña actual", + "currentEmail": "Correo actual", + "newEmail": "Nuevo correo", + "confEmail": "Confirmar nuevo correo", + "token": "Token de seguridad", + "submit": "Cambiar correo", + "sending": "Enviando…", + "success": "Se ha enviado un correo de confirmación a tu correo actual.", + "missingFields": "Por favor, complete todos los campos.", + "wrongCurrentEmail": "El correo actual no es correcto.", + "invalidEmail": "El nuevo correo no es válido.", + "invalidToken": "El token de seguridad es incorrecto.", + "wrongCurrentPassword": "La contraseña actual es incorrecta.", + "genericError": "Algo ha salido mal. Inténtalo más tarde." + }, + "ConfirmOldEmail": { + "title": "Confirmar cambio de correo", + "activating": "Confirmando…", + "success": "Correo actual confirmado. Revisa tu NUEVO correo para completar el segundo paso.", + "error": "El enlace no es válido o ya se ha usado." + }, + "ConfirmNewEmail": { + "title": "Confirmar nuevo correo", + "activating": "Aplicando el cambio…", + "success": "¡Correo cambiado con éxito! Ya puedes iniciar sesión con el nuevo correo.", + "error": "El enlace no es válido, ha caducado o no se ha encontrado la cuenta.", + "goLogin": "Ir a iniciar sesión" } }