Registro + activación por email en Next.js (SRP6 + nodemailer)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 22:53:09 +00:00
parent e00a439dd9
commit f332fc39ca
12 changed files with 429 additions and 0 deletions
@@ -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<string>('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 (
<div className="text-center">
{state === 'loading' && <p>{t('activating')}</p>}
{state === 'ok' && (
<>
<p className="text-green-400">{t('success')}</p>
<p className="mt-4">
<Link href="/login" className="text-sky-400 underline">
{t('goLogin')}
</Link>
</p>
</>
)}
{state === 'error' && <p className="text-red-400">{t(errorKey)}</p>}
</div>
)
}
@@ -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 (
<main className="mx-auto max-w-lg px-4 py-12">
<ActivateClient hash={act ?? ''} />
</main>
)
}
@@ -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 (
<div className="mx-auto max-w-sm">
<p className="mb-4 text-sm text-amber-200/70">{t('info')}</p>
<form onSubmit={handleSubmit} className="space-y-3">
<input type="password" maxLength={16} placeholder={t('password')} value={form.password} onChange={(e) => upd('password', e.target.value)} className={input} />
<input type="password" maxLength={16} placeholder={t('confPassword')} value={form.confPassword} onChange={(e) => upd('confPassword', e.target.value)} className={input} />
<input type="email" placeholder={t('email')} value={form.email} onChange={(e) => upd('email', e.target.value)} className={input} />
<input type="email" placeholder={t('confEmail')} value={form.confEmail} onChange={(e) => upd('confEmail', e.target.value)} className={input} />
<input type="text" maxLength={12} placeholder={t('recruiter')} value={form.recruiter} onChange={(e) => upd('recruiter', e.target.value)} className={input} />
<label className="flex items-start gap-2 text-left text-sm">
<input type="checkbox" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} className="mt-1" />
<span>{t('terms')}</span>
</label>
<button type="submit" disabled={busy || !accepted} className="w-full rounded bg-amber-600 px-4 py-2 font-semibold text-[#1b120b] disabled:opacity-60">
{busy ? t('creating') : t('submit')}
</button>
</form>
{message && <p className={`mt-3 text-center ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
</div>
)
}
+15
View File
@@ -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 (
<main className="mx-auto max-w-3xl px-4 py-8">
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
<RegisterForm />
</main>
)
}
+16
View File
@@ -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)
}
+18
View File
@@ -0,0 +1,18 @@
import { registerAccount } from '@/lib/register'
export async function POST(request: Request) {
let body: Record<string, string>
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)
}
+34
View File
@@ -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<boolean> {
try {
await getTransporter().sendMail({
from: process.env.EMAIL_HOST_USER,
to,
subject,
html,
})
return true
} catch (e) {
console.error('sendMail error:', e)
return false
}
}
+122
View File
@@ -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 `<!DOCTYPE html><html lang="es"><body style="font-family:Arial,sans-serif;background:#1b120b;color:#e8dccb;padding:24px">
<div style="max-width:600px;margin:0 auto;background:#241812;border:1px solid #4a3320;border-radius:8px;padding:28px">
<h1 style="color:#d79602;margin:0 0 8px">Nova WoW</h1>
<h2 style="margin:0 0 16px">Activa tu cuenta</h2>
<p>La cuenta <strong>${email}</strong> se ha creado. Pulsa para activarla (enlace válido 1 hora):</p>
<p><a href="${link}" style="display:inline-block;padding:12px 22px;background:#d79602;color:#1b120b;text-decoration:none;border-radius:6px;font-weight:bold">Activar cuenta</a></p>
<p style="font-size:13px;word-break:break-all"><a href="${link}" style="color:#2471a9">${link}</a></p>
</div></body></html>`
}
export async function registerAccount(input: RegisterInput): Promise<Result> {
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<RowDataPacket[]>(
'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<RowDataPacket[]>('SELECT id FROM account WHERE username = ?', [recruiter])
if (a[0]) {
recruiterId = a[0].id
} else {
const [c] = await db(DB.characters).query<RowDataPacket[]>(
'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<Result> {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'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<ResultSetHeader>(
'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<ResultSetHeader>(
'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 }
}
+27
View File
@@ -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"
}
}
+27
View File
@@ -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"
}
}
+19
View File
@@ -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",
+2
View File
@@ -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",