web-next: botón «+» para crear cuentas de juego (WOW2, WOW3…)
Junto al «Nombre de la cuenta» hay un botón «+» que pide la contraseña y crea una nueva cuenta de juego bajo la Battle.net. Ruta /api/account/add-game-account: verifica la contraseña (authenticate contra battlenet_accounts), calcula el siguiente battlenet_index (máx 8) y crea la fila `account` con SRP6 Grunt derivado de la contraseña (gameMakeRegistration) — igual que la activación, así la cuenta sirve para entrar al juego. Recarga completa al crear/cambiar. GameAccountSwitcher siempre presente (select si >1, texto si 1) + «+». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,9 +56,11 @@ export default async function AccountPage({ params }: { params: Promise<{ locale
|
|||||||
<legend>{t('basicData')}</legend>
|
<legend>{t('basicData')}</legend>
|
||||||
<div className="separate account-info">
|
<div className="separate account-info">
|
||||||
<p><i className="fa-solid fa-user account-ficon third-brown"></i> {t('accountName')}:{' '}
|
<p><i className="fa-solid fa-user account-ficon third-brown"></i> {t('accountName')}:{' '}
|
||||||
{games.length > 1
|
<GameAccountSwitcher
|
||||||
? <GameAccountSwitcher accounts={games} currentId={session.accountId ?? 0} />
|
accounts={games}
|
||||||
: <span>{wowAccountName(data.gameAccount)}</span>}
|
currentId={session.accountId ?? 0}
|
||||||
|
currentLabel={wowAccountName(data.gameAccount)}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p><i className="fa-solid fa-at account-ficon third-brown"></i> {t('regMail')}: <span>{maskEmail(data.regMail)}</span></p>
|
<p><i className="fa-solid fa-at account-ficon third-brown"></i> {t('regMail')}: <span>{maskEmail(data.regMail)}</span></p>
|
||||||
<p><i className="fa-solid fa-at account-ficon third-brown"></i> {t('currentMail')}: <span>{maskEmail(data.email)}</span></p>
|
<p><i className="fa-solid fa-at account-ficon third-brown"></i> {t('currentMail')}: <span>{maskEmail(data.email)}</span></p>
|
||||||
|
|||||||
@@ -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<RowDataPacket[]>(
|
||||||
|
'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<ResultSetHeader>(
|
||||||
|
'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 })
|
||||||
|
}
|
||||||
@@ -359,7 +359,7 @@ textarea:focus {
|
|||||||
color: #b1997f;
|
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 {
|
.account-select {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 92px;
|
min-width: 92px;
|
||||||
@@ -369,6 +369,29 @@ textarea:focus {
|
|||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
vertical-align: middle;
|
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) */
|
/* Colores de clase de WoW (nombre de personaje) */
|
||||||
.class-warrior { color: #c79c6e; }
|
.class-warrior { color: #c79c6e; }
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desplegable INLINE para cambiar la cuenta de juego activa (WOW1, WOW2…),
|
* Campo «Nombre de la cuenta»: muestra la cuenta de juego activa (WOW1) —como
|
||||||
* mostrado en el campo «Nombre de la cuenta» cuando la Battle.net tiene más de
|
* desplegable si hay más de una— y un botón «+» para crear nuevas cuentas de
|
||||||
* una. Al cambiar, fija la cuenta en la sesión (/api/auth/select-account) y
|
* juego (WOW2, WOW3…) bajo la misma Battle.net. Cambiar o crear hace una
|
||||||
* hace una RECARGA COMPLETA (no router.refresh) → todo /account (datos,
|
* RECARGA COMPLETA para que todo /account opere sobre la cuenta correcta.
|
||||||
* personajes, servicios, compras…) pasa a operar sobre la cuenta elegida y se
|
|
||||||
* recargan bien todos los estilos/iconos.
|
|
||||||
*/
|
*/
|
||||||
export function GameAccountSwitcher({
|
export function GameAccountSwitcher({
|
||||||
accounts,
|
accounts,
|
||||||
currentId,
|
currentId,
|
||||||
|
currentLabel,
|
||||||
}: {
|
}: {
|
||||||
accounts: { id: number; index: number }[]
|
accounts: { id: number; index: number }[]
|
||||||
currentId: number
|
currentId: number
|
||||||
|
currentLabel: string
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('Account')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
async function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
async function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
const id = Number(e.target.value)
|
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 (
|
return (
|
||||||
<select value={currentId} onChange={onChange} disabled={busy} className="account-select" aria-label="Cuenta de juego">
|
<span className="game-account-field">
|
||||||
{accounts.map((a) => (
|
{accounts.length > 1 ? (
|
||||||
<option key={a.id} value={a.id}>
|
<select value={currentId} onChange={onChange} disabled={busy} className="account-select" aria-label={t('accountName')}>
|
||||||
WOW{a.index}
|
{accounts.map((a) => (
|
||||||
</option>
|
<option key={a.id} value={a.id}>
|
||||||
))}
|
WOW{a.index}
|
||||||
</select>
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span>{currentLabel}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-account-btn"
|
||||||
|
onClick={() => { setAdding((v) => !v); setError(null) }}
|
||||||
|
title={t('addAccount')}
|
||||||
|
aria-label={t('addAccount')}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
{adding && (
|
||||||
|
<form onSubmit={addAccount} className="add-account-form" autoComplete="off">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
maxLength={16}
|
||||||
|
placeholder={t('addAccountPassword')}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="account-select add-account-input"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="add-account-btn" disabled={busy}>
|
||||||
|
{busy ? '…' : t('addAccountConfirm')}
|
||||||
|
</button>
|
||||||
|
{error && <span className="red-form-response"> {error}</span>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,13 @@
|
|||||||
"twofaLabel": "2FA (Two-step verification)",
|
"twofaLabel": "2FA (Two-step verification)",
|
||||||
"twofaOff": "Disabled",
|
"twofaOff": "Disabled",
|
||||||
"banHistoryLink": "View history",
|
"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": {
|
"SelectAccount": {
|
||||||
"title": "Select your game account",
|
"title": "Select your game account",
|
||||||
|
|||||||
@@ -152,7 +152,13 @@
|
|||||||
"twofaLabel": "2FA (Verificación en 2 pasos)",
|
"twofaLabel": "2FA (Verificación en 2 pasos)",
|
||||||
"twofaOff": "Desactivado",
|
"twofaOff": "Desactivado",
|
||||||
"banHistoryLink": "Consultar historial",
|
"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": {
|
"SelectAccount": {
|
||||||
"title": "Selecciona tu cuenta de juego",
|
"title": "Selecciona tu cuenta de juego",
|
||||||
|
|||||||
Reference in New Issue
Block a user