diff --git a/web-next/app/[locale]/login/LoginForm.tsx b/web-next/app/[locale]/login/LoginForm.tsx
new file mode 100644
index 0000000..d96cc6e
--- /dev/null
+++ b/web-next/app/[locale]/login/LoginForm.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { useRouter } from '@/i18n/navigation'
+
+const ERROR_KEYS = ['invalidCredentials', 'missingFields', 'invalidRequest'] as const
+
+export function LoginForm() {
+ const t = useTranslations('Login')
+ const router = useRouter()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [showPw, setShowPw] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (busy) return
+ if (!email.trim() || !password) {
+ setMessage({ ok: false, text: t('missingFields') })
+ return
+ }
+ setBusy(true)
+ setMessage(null)
+ try {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ email: email.trim(), password }),
+ })
+ const data: { success?: boolean; needsSelection?: boolean; error?: string } = await res.json()
+ if (data.success) {
+ setMessage({ ok: true, text: t('success') })
+ router.push(data.needsSelection ? '/select-account' : '/account')
+ } else {
+ const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError'
+ setMessage({ ok: false, text: t(key) })
+ setBusy(false)
+ }
+ } catch {
+ setMessage({ ok: false, text: t('genericError') })
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ {message && (
+
{message.text}
+ )}
+
+ )
+}
diff --git a/web-next/app/[locale]/login/page.tsx b/web-next/app/[locale]/login/page.tsx
new file mode 100644
index 0000000..4d2d576
--- /dev/null
+++ b/web-next/app/[locale]/login/page.tsx
@@ -0,0 +1,15 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { LoginForm } from './LoginForm'
+
+export default async function LoginPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ const t = await getTranslations('Login')
+
+ return (
+
+ {t('title')}
+
+
+ )
+}
diff --git a/web-next/app/api/auth/login/route.ts b/web-next/app/api/auth/login/route.ts
new file mode 100644
index 0000000..97b9d52
--- /dev/null
+++ b/web-next/app/api/auth/login/route.ts
@@ -0,0 +1,42 @@
+import { authenticate, getGameAccounts } from '@/lib/auth'
+import { getSession } from '@/lib/session'
+import { setGameAccountSession } from '@/lib/account-session'
+
+export async function POST(request: Request) {
+ let email = ''
+ let password = ''
+ try {
+ const body = await request.json()
+ email = String(body.email ?? '').trim()
+ password = String(body.password ?? '')
+ } catch {
+ return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
+ }
+
+ if (!email || !password) {
+ return Response.json({ success: false, error: 'missingFields' })
+ }
+
+ const account = await authenticate(email, password)
+ if (!account) {
+ return Response.json({ success: false, error: 'invalidCredentials' })
+ }
+
+ const session = await getSession()
+ session.bnetId = account.id
+ session.bnetEmail = account.email
+
+ const games = await getGameAccounts(account.id)
+ let needsSelection = true
+ if (games.length === 1) {
+ setGameAccountSession(session, games[0])
+ needsSelection = false
+ } else {
+ // 0 o >1 cuentas de juego: dejar la selección pendiente
+ delete session.username
+ delete session.accountId
+ }
+ await session.save()
+
+ return Response.json({ success: true, needsSelection })
+}
diff --git a/web-next/lib/account-session.ts b/web-next/lib/account-session.ts
new file mode 100644
index 0000000..7b878f3
--- /dev/null
+++ b/web-next/lib/account-session.ts
@@ -0,0 +1,8 @@
+import type { SessionData } from './session'
+import type { GameAccount } from './auth'
+
+/** Fija la cuenta de juego elegida en la sesión (equiv. a _set_game_account_session). */
+export function setGameAccountSession(session: SessionData, game: GameAccount): void {
+ session.username = game.username
+ session.accountId = game.id
+}
diff --git a/web-next/lib/auth.ts b/web-next/lib/auth.ts
new file mode 100644
index 0000000..2123e90
--- /dev/null
+++ b/web-next/lib/auth.ts
@@ -0,0 +1,46 @@
+import type { RowDataPacket } from 'mysql2'
+import { db, DB } from './db'
+import { bnetVerify, normalizeEmail } from './bnet'
+
+export interface BnetAccount {
+ id: number
+ email: string
+}
+
+export interface GameAccount {
+ id: number
+ username: string
+ index: number
+}
+
+/** Verifica email+password contra battlenet_accounts (SRP6 v2). Null si falla. */
+export async function authenticate(email: string, password: string): Promise {
+ try {
+ const [rows] = await db(DB.auth).query(
+ 'SELECT id, email, salt, verifier FROM battlenet_accounts WHERE email = ?',
+ [normalizeEmail(email)],
+ )
+ const row = rows[0]
+ if (!row) return null
+ const salt: Buffer | null = row.salt ?? null
+ const verifier: Buffer | null = row.verifier ?? null
+ if (!bnetVerify(email, password, salt, verifier)) return null
+ return { id: row.id, email: row.email }
+ } catch {
+ // La BD de cuentas (AzerothCore) puede no estar disponible/poblada.
+ return null
+ }
+}
+
+/** Cuentas de juego enlazadas a una cuenta Battle.net. */
+export async function getGameAccounts(bnetId: number): Promise {
+ try {
+ const [rows] = await db(DB.auth).query(
+ 'SELECT id, username, battlenet_index FROM account WHERE battlenet_account = ? ORDER BY battlenet_index',
+ [bnetId],
+ )
+ return rows.map((r) => ({ id: r.id, username: r.username, index: r.battlenet_index }))
+ } catch {
+ return []
+ }
+}
diff --git a/web-next/lib/session.ts b/web-next/lib/session.ts
new file mode 100644
index 0000000..88a0776
--- /dev/null
+++ b/web-next/lib/session.ts
@@ -0,0 +1,25 @@
+import { getIronSession, type SessionOptions } from 'iron-session'
+import { cookies } from 'next/headers'
+
+// Sesión cifrada en cookie httpOnly (equivalente a la sesión Django de antes).
+export interface SessionData {
+ bnetId?: number
+ bnetEmail?: string
+ username?: string // cuenta de juego elegida (#)
+ accountId?: number
+}
+
+export const sessionOptions: SessionOptions = {
+ password: process.env.SESSION_SECRET as string,
+ cookieName: 'novawow_session',
+ cookieOptions: {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ },
+}
+
+export async function getSession() {
+ return getIronSession(await cookies(), sessionOptions)
+}
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index 8963098..91c2205 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -15,5 +15,20 @@
"noNews": "There are no news available at the moment.",
"link": "Link",
"here": "here"
+ },
+ "Login": {
+ "title": "Sign in to Nova WoW",
+ "email": "Email",
+ "password": "Password",
+ "submit": "Sign in",
+ "connecting": "Signing in…",
+ "success": "Signed in successfully. Redirecting…",
+ "invalidCredentials": "Incorrect email or password",
+ "missingFields": "Please fill in all fields.",
+ "invalidRequest": "Invalid request.",
+ "genericError": "Something went wrong. Please try again later.",
+ "forgot": "I forgot my password/username",
+ "newHere": "New to the community? You can create an account",
+ "createAccount": "here"
}
}
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index 2ae749d..fd054a7 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -15,5 +15,20 @@
"noNews": "No hay noticias disponibles en este momento.",
"link": "Enlace",
"here": "aquí"
+ },
+ "Login": {
+ "title": "Conectar a Nova WoW",
+ "email": "Correo electrónico",
+ "password": "Contraseña",
+ "submit": "Conectar",
+ "connecting": "Conectando…",
+ "success": "Conexión exitosa. Redirigiendo…",
+ "invalidCredentials": "Correo o contraseña incorrectos",
+ "missingFields": "Por favor rellene todos los campos.",
+ "invalidRequest": "Solicitud no válida.",
+ "genericError": "Algo ha salido mal. Inténtalo más tarde.",
+ "forgot": "He olvidado mi contraseña/usuario",
+ "newHere": "¿Nuevo en la comunidad? Puedes crear una cuenta",
+ "createAccount": "aquí"
}
}
diff --git a/web-next/package-lock.json b/web-next/package-lock.json
index e7bbcfe..4005608 100644
--- a/web-next/package-lock.json
+++ b/web-next/package-lock.json
@@ -8,6 +8,7 @@
"name": "web-next",
"version": "0.1.0",
"dependencies": {
+ "iron-session": "^8.0.4",
"mysql2": "^3.22.6",
"next": "16.2.10",
"next-intl": "^4.13.2",
@@ -3506,6 +3507,14 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4867,6 +4876,28 @@
"@formatjs/icu-messageformat-parser": "3.5.14"
}
},
+ "node_modules/iron-session": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz",
+ "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==",
+ "funding": [
+ "https://github.com/sponsors/vvo",
+ "https://github.com/sponsors/brc-dd"
+ ],
+ "dependencies": {
+ "cookie": "^0.7.2",
+ "iron-webcrypto": "^1.2.1",
+ "uncrypto": "^0.1.3"
+ }
+ },
+ "node_modules/iron-webcrypto": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
+ "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==",
+ "funding": {
+ "url": "https://github.com/sponsors/brc-dd"
+ }
+ },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -7279,6 +7310,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/uncrypto": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
+ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
diff --git a/web-next/package.json b/web-next/package.json
index eae5f9f..917641b 100644
--- a/web-next/package.json
+++ b/web-next/package.json
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
+ "iron-session": "^8.0.4",
"mysql2": "^3.22.6",
"next": "16.2.10",
"next-intl": "^4.13.2",
diff --git a/web-next/tsconfig.json b/web-next/tsconfig.json
index 3a13f90..058448b 100644
--- a/web-next/tsconfig.json
+++ b/web-next/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "target": "ES2020",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,7 +23,9 @@
}
],
"paths": {
- "@/*": ["./*"]
+ "@/*": [
+ "./*"
+ ]
}
},
"include": [
@@ -30,5 +36,7 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
- "exclude": ["node_modules"]
+ "exclude": [
+ "node_modules"
+ ]
}