diff --git a/web-next/app/[locale]/account/page.tsx b/web-next/app/[locale]/account/page.tsx
index 0c8b73d..7e638e3 100644
--- a/web-next/app/[locale]/account/page.tsx
+++ b/web-next/app/[locale]/account/page.tsx
@@ -56,9 +56,11 @@ export default async function AccountPage({ params }: { params: Promise<{ locale
{t('accountName')}:{' '}
- {games.length > 1
- ?
- : {wowAccountName(data.gameAccount)}}
+
{t('regMail')}: {maskEmail(data.regMail)}
{t('currentMail')}: {maskEmail(data.email)}
diff --git a/web-next/app/api/account/add-game-account/route.ts b/web-next/app/api/account/add-game-account/route.ts
new file mode 100644
index 0000000..307987a
--- /dev/null
+++ b/web-next/app/api/account/add-game-account/route.ts
@@ -0,0 +1,60 @@
+import type { ResultSetHeader, RowDataPacket } from 'mysql2'
+import { db, DB } from '@/lib/db'
+import { getSession } from '@/lib/session'
+import { authenticate } from '@/lib/auth'
+import { gameMakeRegistration, makeGameAccountUsername, normalizeEmail } from '@/lib/bnet'
+
+const MAX_GAME_ACCOUNTS = 8 // Battle.net: WoW1…WoW8
+
+/**
+ * Crea una nueva cuenta de juego (WoW2, WoW3…) bajo la Battle.net de la sesión.
+ * Requiere la contraseña (para derivar el SRP6 de la cuenta y que sirva in-game,
+ * ya que todas las cuentas de la bnet usan la misma contraseña).
+ */
+export async function POST(request: Request) {
+ const session = await getSession()
+ if (!session.bnetId || !session.bnetEmail) {
+ return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
+ }
+
+ let password = ''
+ try {
+ const body = await request.json()
+ password = String(body.password ?? '')
+ } catch {
+ return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
+ }
+ if (!password) return Response.json({ success: false, error: 'invalidPassword' })
+
+ // Verifica la contraseña contra la Battle.net.
+ const bnet = await authenticate(session.bnetEmail, password)
+ if (!bnet) return Response.json({ success: false, error: 'invalidPassword' })
+
+ const emailNorm = normalizeEmail(session.bnetEmail)
+
+ // Siguiente índice de cuenta de juego.
+ const [rows] = await db(DB.auth).query
(
+ 'SELECT COALESCE(MAX(battlenet_index), 0) AS maxIdx, COUNT(*) AS n FROM account WHERE battlenet_account = ?',
+ [session.bnetId],
+ )
+ const count = Number(rows[0]?.n ?? 0)
+ if (count >= MAX_GAME_ACCOUNTS) {
+ return Response.json({ success: false, error: 'maxAccounts' })
+ }
+ const nextIndex = Number(rows[0]?.maxIdx ?? 0) + 1
+
+ // Crea la cuenta de juego (SRP6 Grunt con la contraseña de la bnet).
+ const gameUsername = makeGameAccountUsername(session.bnetId, nextIndex)
+ const game = gameMakeRegistration(gameUsername, password)
+ try {
+ await db(DB.auth).query(
+ 'INSERT INTO account (username, salt, verifier, email, reg_mail, joindate, last_ip, expansion, battlenet_account, battlenet_index) ' +
+ 'VALUES (?, ?, ?, ?, ?, NOW(), ?, ?, ?, ?)',
+ [gameUsername, game.salt, game.verifier, emailNorm, emailNorm, '0.0.0.0', 2, session.bnetId, nextIndex],
+ )
+ } catch {
+ return Response.json({ success: false, error: 'createFailed' })
+ }
+
+ return Response.json({ success: true, index: nextIndex })
+}
diff --git a/web-next/app/globals.css b/web-next/app/globals.css
index 70be254..95aa9e7 100644
--- a/web-next/app/globals.css
+++ b/web-next/app/globals.css
@@ -359,7 +359,7 @@ textarea:focus {
color: #b1997f;
}
-/* Selector de cuenta inline (dentro de «Nombre de la cuenta»). */
+/* Selector de cuenta inline (dentro de «Nombre de la cuenta») + botón «+». */
.account-select {
width: auto;
min-width: 92px;
@@ -369,6 +369,29 @@ textarea:focus {
font-size: 17px;
vertical-align: middle;
}
+.add-account-btn {
+ height: auto;
+ padding: 1px 9px;
+ margin: 0 0 0 6px;
+ font-size: 17px;
+ line-height: 1.4;
+ vertical-align: middle;
+ border: 2px solid #352e2b;
+ background: hsla(0, 0%, 100%, 0.05);
+ color: #ebdec2;
+ cursor: pointer;
+ transition: 0.3s;
+}
+.add-account-btn:hover {
+ background: hsla(0, 0%, 100%, 0.1);
+ color: #fff;
+}
+.add-account-form {
+ display: inline;
+}
+.add-account-input {
+ min-width: 150px;
+}
/* Colores de clase de WoW (nombre de personaje) */
.class-warrior { color: #c79c6e; }
diff --git a/web-next/components/GameAccountSwitcher.tsx b/web-next/components/GameAccountSwitcher.tsx
index c7888c2..0a2a8c5 100644
--- a/web-next/components/GameAccountSwitcher.tsx
+++ b/web-next/components/GameAccountSwitcher.tsx
@@ -1,23 +1,28 @@
'use client'
import { useState } from 'react'
+import { useTranslations } from 'next-intl'
/**
- * Desplegable INLINE para cambiar la cuenta de juego activa (WOW1, WOW2…),
- * mostrado en el campo «Nombre de la cuenta» cuando la Battle.net tiene más de
- * una. Al cambiar, fija la cuenta en la sesión (/api/auth/select-account) y
- * hace una RECARGA COMPLETA (no router.refresh) → todo /account (datos,
- * personajes, servicios, compras…) pasa a operar sobre la cuenta elegida y se
- * recargan bien todos los estilos/iconos.
+ * Campo «Nombre de la cuenta»: muestra la cuenta de juego activa (WOW1) —como
+ * desplegable si hay más de una— y un botón «+» para crear nuevas cuentas de
+ * juego (WOW2, WOW3…) bajo la misma Battle.net. Cambiar o crear hace una
+ * RECARGA COMPLETA para que todo /account opere sobre la cuenta correcta.
*/
export function GameAccountSwitcher({
accounts,
currentId,
+ currentLabel,
}: {
accounts: { id: number; index: number }[]
currentId: number
+ currentLabel: string
}) {
+ const t = useTranslations('Account')
const [busy, setBusy] = useState(false)
+ const [adding, setAdding] = useState(false)
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
async function onChange(e: React.ChangeEvent) {
const id = Number(e.target.value)
@@ -38,13 +43,75 @@ export function GameAccountSwitcher({
}
}
+ async function addAccount(e: React.FormEvent) {
+ e.preventDefault()
+ if (busy || !password) return
+ setBusy(true)
+ setError(null)
+ try {
+ const res = await fetch('/api/account/add-game-account', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ password }),
+ })
+ const d: { success?: boolean; error?: string } = await res.json()
+ if (d.success) {
+ window.location.reload()
+ return
+ }
+ setError(
+ d.error === 'invalidPassword'
+ ? t('addAccountBadPassword')
+ : d.error === 'maxAccounts'
+ ? t('addAccountMax')
+ : t('addAccountError'),
+ )
+ setBusy(false)
+ } catch {
+ setError(t('addAccountError'))
+ setBusy(false)
+ }
+ }
+
return (
-
+
+ {accounts.length > 1 ? (
+
+ ) : (
+ {currentLabel}
+ )}
+
+ {adding && (
+
+ )}
+
)
}
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index b3df851..726feb4 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -152,7 +152,13 @@
"twofaLabel": "2FA (Two-step verification)",
"twofaOff": "Disabled",
"banHistoryLink": "View history",
- "switchAccountLabel": "Load account data:"
+ "switchAccountLabel": "Load account data:",
+ "addAccount": "Add account",
+ "addAccountPassword": "Password",
+ "addAccountConfirm": "Add",
+ "addAccountBadPassword": "Wrong password",
+ "addAccountMax": "You reached the account limit",
+ "addAccountError": "Could not add the account"
},
"SelectAccount": {
"title": "Select your game account",
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index f92a436..2111eba 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -152,7 +152,13 @@
"twofaLabel": "2FA (Verificación en 2 pasos)",
"twofaOff": "Desactivado",
"banHistoryLink": "Consultar historial",
- "switchAccountLabel": "Cargar datos de la cuenta:"
+ "switchAccountLabel": "Cargar datos de la cuenta:",
+ "addAccount": "Añadir cuenta",
+ "addAccountPassword": "Contraseña",
+ "addAccountConfirm": "Añadir",
+ "addAccountBadPassword": "Contraseña incorrecta",
+ "addAccountMax": "Has alcanzado el máximo de cuentas",
+ "addAccountError": "No se pudo añadir la cuenta"
},
"SelectAccount": {
"title": "Selecciona tu cuenta de juego",