From f332fc39ca1ffe5964d573a5a7d0c6fc258407b9 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 22:53:09 +0000 Subject: [PATCH] =?UTF-8?q?Registro=20+=20activaci=C3=B3n=20por=20email=20?= =?UTF-8?q?en=20Next.js=20(SRP6=20+=20nodemailer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/mail.ts: transporte SMTP (nodemailer, mismas creds Gmail que Django). - lib/register.ts: registerAccount (valida, comprueba email existente, crea fila en home_accountactivation reutilizando la tabla de Django, envía email de activación); activateAccount (crea battlenet_accounts con SRP6 v2 + account con SRP6 Grunt, como activate_account_view: expansion=2, battlenet_index=1; borra la activación). - Route handlers /api/auth/register y /api/auth/activate. - Páginas app/[locale]/register (RegisterForm cliente) y app/[locale]/activate-account (ActivateClient auto-POST al abrir el enlace). Textos en messages (Register, Activate). Validado: validaciones (missingFields/passwordMismatch/invalidEmail/passwordTooLong); ciclo completo activación->crea bnet+account->login OK con la cuenta creada (needsSelection=false), password mala->invalidCredentials. Todo en TS, crypto validada. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../activate-account/ActivateClient.tsx | 55 ++++++++ .../app/[locale]/activate-account/page.tsx | 20 +++ .../app/[locale]/register/RegisterForm.tsx | 74 +++++++++++ web-next/app/[locale]/register/page.tsx | 15 +++ web-next/app/api/auth/activate/route.ts | 16 +++ web-next/app/api/auth/register/route.ts | 18 +++ web-next/lib/mail.ts | 34 +++++ web-next/lib/register.ts | 122 ++++++++++++++++++ web-next/messages/en.json | 27 ++++ web-next/messages/es.json | 27 ++++ web-next/package-lock.json | 19 +++ web-next/package.json | 2 + 12 files changed, 429 insertions(+) create mode 100644 web-next/app/[locale]/activate-account/ActivateClient.tsx create mode 100644 web-next/app/[locale]/activate-account/page.tsx create mode 100644 web-next/app/[locale]/register/RegisterForm.tsx create mode 100644 web-next/app/[locale]/register/page.tsx create mode 100644 web-next/app/api/auth/activate/route.ts create mode 100644 web-next/app/api/auth/register/route.ts create mode 100644 web-next/lib/mail.ts create mode 100644 web-next/lib/register.ts diff --git a/web-next/app/[locale]/activate-account/ActivateClient.tsx b/web-next/app/[locale]/activate-account/ActivateClient.tsx new file mode 100644 index 0000000..2536a42 --- /dev/null +++ b/web-next/app/[locale]/activate-account/ActivateClient.tsx @@ -0,0 +1,55 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' + +const ERROR_KEYS = ['invalidLink', 'expiredLink'] as const + +export function ActivateClient({ hash }: { hash: string }) { + const t = useTranslations('Activate') + const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading') + const [errorKey, setErrorKey] = useState('invalidLink') + const ran = useRef(false) + + useEffect(() => { + if (ran.current) return // evita doble POST en StrictMode + ran.current = true + if (!hash) { + setErrorKey('invalidLink') + setState('error') + return + } + fetch('/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash }), + }) + .then((r) => r.json()) + .then((d: { success?: boolean; error?: string }) => { + if (d.success) setState('ok') + else { + setErrorKey((ERROR_KEYS as readonly string[]).includes(d.error ?? '') ? d.error! : 'invalidLink') + setState('error') + } + }) + .catch(() => setState('error')) + }, [hash]) + + return ( +
+ {state === 'loading' &&

{t('activating')}

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

{t('success')}

+

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

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

{t(errorKey)}

} +
+ ) +} diff --git a/web-next/app/[locale]/activate-account/page.tsx b/web-next/app/[locale]/activate-account/page.tsx new file mode 100644 index 0000000..59467b8 --- /dev/null +++ b/web-next/app/[locale]/activate-account/page.tsx @@ -0,0 +1,20 @@ +import { setRequestLocale } from 'next-intl/server' +import { ActivateClient } from './ActivateClient' + +export default async function ActivatePage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams: Promise<{ act?: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const { act } = await searchParams + + return ( +
+ +
+ ) +} diff --git a/web-next/app/[locale]/register/RegisterForm.tsx b/web-next/app/[locale]/register/RegisterForm.tsx new file mode 100644 index 0000000..6fac129 --- /dev/null +++ b/web-next/app/[locale]/register/RegisterForm.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' + +const ERROR_KEYS = [ + 'missingFields', + 'passwordMismatch', + 'passwordTooLong', + 'invalidEmail', + 'emailExists', + 'recruiterNotFound', +] as const + +export function RegisterForm() { + const t = useTranslations('Register') + const [form, setForm] = useState({ password: '', confPassword: '', email: '', confEmail: '', recruiter: '' }) + const [accepted, setAccepted] = useState(false) + 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 || !accepted) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/auth/register', { + 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 input = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2' + + return ( +
+

{t('info')}

+
+ upd('password', e.target.value)} className={input} /> + upd('confPassword', e.target.value)} className={input} /> + upd('email', e.target.value)} className={input} /> + upd('confEmail', e.target.value)} className={input} /> + upd('recruiter', e.target.value)} className={input} /> + + +
+ {message &&

{message.text}

} +
+ ) +} diff --git a/web-next/app/[locale]/register/page.tsx b/web-next/app/[locale]/register/page.tsx new file mode 100644 index 0000000..22df610 --- /dev/null +++ b/web-next/app/[locale]/register/page.tsx @@ -0,0 +1,15 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { RegisterForm } from './RegisterForm' + +export default async function RegisterPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('Register') + + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/api/auth/activate/route.ts b/web-next/app/api/auth/activate/route.ts new file mode 100644 index 0000000..a77acc0 --- /dev/null +++ b/web-next/app/api/auth/activate/route.ts @@ -0,0 +1,16 @@ +import { activateAccount } from '@/lib/register' + +export async function POST(request: Request) { + let hash = '' + try { + const body = await request.json() + hash = String(body.hash ?? '') + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + if (!hash) return Response.json({ success: false, error: 'invalidLink' }) + + const ip = (request.headers.get('x-forwarded-for') || '0.0.0.0').split(',')[0].trim() + const result = await activateAccount(hash, ip) + return Response.json(result) +} diff --git a/web-next/app/api/auth/register/route.ts b/web-next/app/api/auth/register/route.ts new file mode 100644 index 0000000..5d8ea44 --- /dev/null +++ b/web-next/app/api/auth/register/route.ts @@ -0,0 +1,18 @@ +import { registerAccount } from '@/lib/register' + +export async function POST(request: Request) { + let body: Record + try { + body = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + const result = await registerAccount({ + password: body.password ?? '', + confPassword: body.confPassword ?? '', + email: body.email ?? '', + confEmail: body.confEmail ?? '', + recruiter: body.recruiter ?? '', + }) + return Response.json(result) +} diff --git a/web-next/lib/mail.ts b/web-next/lib/mail.ts new file mode 100644 index 0000000..d79b480 --- /dev/null +++ b/web-next/lib/mail.ts @@ -0,0 +1,34 @@ +import nodemailer from 'nodemailer' + +// Transporte SMTP (mismas credenciales que usaba Django: Gmail por defecto). +let transporter: nodemailer.Transporter | null = null + +function getTransporter(): nodemailer.Transporter { + if (!transporter) { + transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: Number(process.env.EMAIL_PORT || 587), + secure: process.env.EMAIL_USE_SSL === 'True', // true para 465, false para 587 (STARTTLS) + auth: { + user: process.env.EMAIL_HOST_USER, + pass: process.env.EMAIL_HOST_PASSWORD, + }, + }) + } + return transporter +} + +export async function sendMail(to: string, subject: string, html: string): Promise { + try { + await getTransporter().sendMail({ + from: process.env.EMAIL_HOST_USER, + to, + subject, + html, + }) + return true + } catch (e) { + console.error('sendMail error:', e) + return false + } +} diff --git a/web-next/lib/register.ts b/web-next/lib/register.ts new file mode 100644 index 0000000..cdcf258 --- /dev/null +++ b/web-next/lib/register.ts @@ -0,0 +1,122 @@ +import crypto from 'node:crypto' +import type { ResultSetHeader, RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { normalizeEmail, bnetMakeRegistration, gameMakeRegistration, makeGameAccountUsername } from './bnet' +import { sendMail } from './mail' + +const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/ + +export interface RegisterInput { + password: string + confPassword: string + email: string + confEmail: string + recruiter?: string +} + +export interface Result { + success: boolean + error?: string +} + +function activationEmailHtml(email: string, link: string): string { + return ` +
+

Nova WoW

+

Activa tu cuenta

+

La cuenta ${email} se ha creado. Pulsa para activarla (enlace válido 1 hora):

+

Activar cuenta

+

${link}

+
` +} + +export async function registerAccount(input: RegisterInput): Promise { + const password = (input.password || '').trim() + const confPassword = (input.confPassword || '').trim() + const email = (input.email || '').trim() + const confEmail = (input.confEmail || '').trim() + const recruiter = (input.recruiter || '').trim() + + if (!password || !email) return { success: false, error: 'missingFields' } + if (password !== confPassword) return { success: false, error: 'passwordMismatch' } + if (password.length > 16) return { success: false, error: 'passwordTooLong' } + if (email !== confEmail || !GMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' } + + // ¿ya existe una cuenta Battle.net con ese email? + try { + const [rows] = await db(DB.auth).query( + 'SELECT COUNT(*) AS n FROM battlenet_accounts WHERE email = ?', + [normalizeEmail(email)], + ) + if (Number(rows[0]?.n ?? 0) > 0) return { success: false, error: 'emailExists' } + } catch { + // BD de cuentas no disponible en dev: continuamos. + } + + // Borrar cualquier activación de registro pendiente para ese email + await db(DB.default).query('DELETE FROM home_accountactivation WHERE email = ? AND old_email IS NULL', [email]) + + // Reclutador opcional + let recruiterId = 0 + if (recruiter) { + try { + const [a] = await db(DB.auth).query('SELECT id FROM account WHERE username = ?', [recruiter]) + if (a[0]) { + recruiterId = a[0].id + } else { + const [c] = await db(DB.characters).query( + 'SELECT account FROM characters WHERE name = ?', + [recruiter], + ) + if (c[0]) recruiterId = c[0].account + else return { success: false, error: 'recruiterNotFound' } + } + } catch { + return { success: false, error: 'recruiterNotFound' } + } + } + + const hash = crypto.randomBytes(16).toString('hex') // 32 hex chars (col hash = CharField(32)) + await db(DB.default).query( + 'INSERT INTO home_accountactivation (email, password, recruiter_id, hash, created_at, is_used, is_new_email_used) VALUES (?, ?, ?, ?, NOW(), 0, 0)', + [email, password, recruiterId, hash], + ) + + const link = `${process.env.SITE_URL}/activate-account?act=${hash}` + await sendMail(email, `Activación de la cuenta ${email} - Nova WoW`, activationEmailHtml(email, link)) + + return { success: true } +} + +export async function activateAccount(hash: string, lastIp = '0.0.0.0'): Promise { + const [rows] = await db(DB.default).query( + 'SELECT id, email, password, recruiter_id, created_at FROM home_accountactivation WHERE hash = ? AND old_email IS NULL', + [hash], + ) + const act = rows[0] + if (!act) return { success: false, error: 'invalidLink' } + if (Date.now() - new Date(act.created_at).getTime() > 3600_000) return { success: false, error: 'expiredLink' } + + const emailNorm = normalizeEmail(act.email) + const password: string = act.password + + // Cuenta Battle.net (SRP6 v2) + const bnet = bnetMakeRegistration(emailNorm, password) + const [bnetRes] = await db(DB.auth).query( + 'INSERT INTO battlenet_accounts (email, srp_version, salt, verifier) VALUES (?, ?, ?, ?)', + [emailNorm, bnet.srpVersion, bnet.salt, bnet.verifier], + ) + const bnetId = bnetRes.insertId + + // Cuenta de juego (SRP6 Grunt) + const gameUsername = makeGameAccountUsername(bnetId, 1) + const game = gameMakeRegistration(gameUsername, password) + await db(DB.auth).query( + 'INSERT INTO account (username, salt, verifier, email, reg_mail, recruiter, joindate, last_ip, expansion, battlenet_account, battlenet_index) ' + + 'VALUES (?, ?, ?, ?, ?, ?, NOW(), ?, ?, ?, 1)', + [gameUsername, game.salt, game.verifier, emailNorm, emailNorm, act.recruiter_id || 0, lastIp, 2, bnetId], + ) + + 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 fb3c522..42b4fd4 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -44,5 +44,32 @@ "noAccounts": "No game accounts were found linked to this Battle.net account.", "entering": "Entering…", "error": "The account could not be selected." + }, + "Register": { + "title": "Create account", + "info": "Your account is Battle.net type: it is identified by your email. The password must be alphanumeric and up to 16 characters. The email must be a Gmail address.", + "password": "Password", + "confPassword": "Confirm password", + "email": "Gmail email address", + "confEmail": "Confirm email address", + "recruiter": "Recruiter (optional)", + "terms": "I accept the Rules, Terms and Conditions and Privacy Policy", + "submit": "Create account", + "creating": "Creating…", + "success": "The account has been created. Check your email to activate it.", + "missingFields": "Please fill in all fields.", + "passwordMismatch": "Passwords do not match.", + "passwordTooLong": "The password must not exceed 16 characters.", + "invalidEmail": "The email address is not valid.", + "emailExists": "An account with that email already exists.", + "recruiterNotFound": "The recruiter entered does not exist.", + "genericError": "Something went wrong. Please try again later." + }, + "Activate": { + "activating": "Activating your account…", + "success": "Account activated! You can now sign in.", + "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" } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index b31fd24..4f7ff08 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -44,5 +44,32 @@ "noAccounts": "No se han encontrado cuentas de juego asociadas a esta cuenta Battle.net.", "entering": "Entrando…", "error": "No se pudo seleccionar la cuenta." + }, + "Register": { + "title": "Creación de cuenta", + "info": "Tu cuenta es de tipo Battle.net: se identifica con tu correo electrónico. La contraseña debe ser alfanumérica y de hasta 16 caracteres. El correo debe ser de Gmail.", + "password": "Contraseña", + "confPassword": "Confirmar contraseña", + "email": "Correo electrónico de Gmail", + "confEmail": "Confirmar correo electrónico", + "recruiter": "Reclutante (opcional)", + "terms": "Acepto las Normas, los Términos y Condiciones y la Política de Privacidad", + "submit": "Crear cuenta", + "creating": "Creando…", + "success": "La cuenta se ha creado. Revisa tu correo para activarla.", + "missingFields": "Por favor, complete todos los campos.", + "passwordMismatch": "Las contraseñas no coinciden.", + "passwordTooLong": "La contraseña no debe exceder los 16 caracteres.", + "invalidEmail": "El correo electrónico no es válido.", + "emailExists": "Ya existe una cuenta con ese correo electrónico.", + "recruiterNotFound": "El reclutador ingresado no existe.", + "genericError": "Algo ha salido mal. Inténtalo más tarde." + }, + "Activate": { + "activating": "Activando tu cuenta…", + "success": "¡Cuenta activada! Ya puedes iniciar sesión.", + "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" } } diff --git a/web-next/package-lock.json b/web-next/package-lock.json index 4005608..5461101 100644 --- a/web-next/package-lock.json +++ b/web-next/package-lock.json @@ -12,12 +12,14 @@ "mysql2": "^3.22.6", "next": "16.2.10", "next-intl": "^4.13.2", + "nodemailer": "^9.0.3", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.2", "@types/node": "^20", + "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -2436,6 +2438,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -6090,6 +6101,14 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/web-next/package.json b/web-next/package.json index 917641b..803f233 100644 --- a/web-next/package.json +++ b/web-next/package.json @@ -13,12 +13,14 @@ "mysql2": "^3.22.6", "next": "16.2.10", "next-intl": "^4.13.2", + "nodemailer": "^9.0.3", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.2", "@types/node": "^20", + "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9",