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('basicData')}

{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 && ( +
+ setPassword(e.target.value)} + className="account-select add-account-input" + /> + + {error && {error}} +
+ )} +
) } 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",