Cierra el flujo de login: logout, selector de cuenta y /account con guardas
- Route handlers: /api/auth/logout (destroy sesión), /api/auth/select-account (fija la cuenta de juego elegida, valida que pertenece a la bnet en sesión). - app/[locale]/select-account: página SSR (guarda: sin bnetId -> login) + lista de cuentas de juego (SelectAccountForm cliente) -> /account. - app/[locale]/account: página protegida (sin bnetId -> login; sin username -> select-account) con datos de sesión + LogoutButton. - Textos en messages (Account, SelectAccount). Verificado: guardas locale-aware (ES /account -> /login, EN /en/account -> /en/login, 307), logout OK, select-account sin sesión 401. Redirect i18n con next-intl. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
export function LogoutButton() {
|
||||
const t = useTranslations('Account')
|
||||
const router = useRouter()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function logout() {
|
||||
setBusy(true)
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' })
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={logout}
|
||||
disabled={busy}
|
||||
className="rounded border border-amber-900/60 px-4 py-2 text-amber-200 disabled:opacity-60"
|
||||
>
|
||||
{t('logout')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { LogoutButton } from './LogoutButton'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AccountPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Account')
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<div className="space-y-2 rounded-lg border border-amber-900/60 bg-[#241812] p-4 text-sm">
|
||||
<p>
|
||||
{t('bnetAccount')}: <span className="text-amber-400">{session.bnetEmail}</span>
|
||||
</p>
|
||||
<p>
|
||||
{t('gameAccount')}: <span className="text-amber-400">{session.username}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
interface GameAccount {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export function SelectAccountForm({ accounts }: { accounts: GameAccount[] }) {
|
||||
const t = useTranslations('SelectAccount')
|
||||
const router = useRouter()
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function select(id: number) {
|
||||
if (busy !== null) return
|
||||
setBusy(id)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/auth/select-account', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ accountId: id }),
|
||||
})
|
||||
const data: { success?: boolean } = await res.json()
|
||||
if (data.success) router.push('/account')
|
||||
else {
|
||||
setError(t('error'))
|
||||
setBusy(null)
|
||||
}
|
||||
} catch {
|
||||
setError(t('error'))
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => select(a.id)}
|
||||
disabled={busy !== null}
|
||||
className="w-full rounded bg-amber-600 px-4 py-2 font-semibold text-[#1b120b] disabled:opacity-60"
|
||||
>
|
||||
{busy === a.id ? t('entering') : a.username}
|
||||
</button>
|
||||
))}
|
||||
{error && <p className="text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameAccounts } from '@/lib/auth'
|
||||
import { SelectAccountForm } from './SelectAccountForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SelectAccountPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('SelectAccount')
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
|
||||
const games = await getGameAccounts(session.bnetId!)
|
||||
// Autoseleccionar si solo hay una
|
||||
if (games.length === 1) {
|
||||
// La selección real la hace el cliente/route; aquí mostramos el formulario igual,
|
||||
// pero con una sola opción es un clic. (La auto-selección en login ya cubre el caso común.)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-md px-4 py-8 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
{session.bnetEmail && (
|
||||
<p className="mb-4 text-sm text-amber-200/70">
|
||||
{t('loggedInAs')} <strong>{session.bnetEmail}</strong>
|
||||
</p>
|
||||
)}
|
||||
{games.length > 0 ? (
|
||||
<>
|
||||
<p className="mb-4">{t('choose')}</p>
|
||||
<SelectAccountForm accounts={games} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-amber-200/70">{t('noAccounts')}</p>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getSession } from '@/lib/session'
|
||||
|
||||
export async function POST() {
|
||||
const session = await getSession()
|
||||
session.destroy()
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getGameAccounts } from '@/lib/auth'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { setGameAccountSession } from '@/lib/account-session'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) {
|
||||
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||
}
|
||||
let accountId: number
|
||||
try {
|
||||
const body = await request.json()
|
||||
accountId = Number(body.accountId)
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
}
|
||||
|
||||
const games = await getGameAccounts(session.bnetId)
|
||||
const chosen = games.find((g) => g.id === accountId)
|
||||
if (!chosen) {
|
||||
return Response.json({ success: false, error: 'invalidAccount' })
|
||||
}
|
||||
setGameAccountSession(session, chosen)
|
||||
await session.save()
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
@@ -30,5 +30,19 @@
|
||||
"forgot": "I forgot my password/username",
|
||||
"newHere": "New to the community? You can create an account",
|
||||
"createAccount": "here"
|
||||
},
|
||||
"Account": {
|
||||
"title": "My account",
|
||||
"bnetAccount": "Account (Battle.net)",
|
||||
"gameAccount": "Game account",
|
||||
"logout": "Log out"
|
||||
},
|
||||
"SelectAccount": {
|
||||
"title": "Select your game account",
|
||||
"loggedInAs": "Logged in as",
|
||||
"choose": "Your Battle.net account has several game accounts. Choose which one to continue with:",
|
||||
"noAccounts": "No game accounts were found linked to this Battle.net account.",
|
||||
"entering": "Entering…",
|
||||
"error": "The account could not be selected."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,5 +30,19 @@
|
||||
"forgot": "He olvidado mi contraseña/usuario",
|
||||
"newHere": "¿Nuevo en la comunidad? Puedes crear una cuenta",
|
||||
"createAccount": "aquí"
|
||||
},
|
||||
"Account": {
|
||||
"title": "Mi cuenta",
|
||||
"bnetAccount": "Cuenta (Battle.net)",
|
||||
"gameAccount": "Cuenta de juego",
|
||||
"logout": "Cerrar sesión"
|
||||
},
|
||||
"SelectAccount": {
|
||||
"title": "Selecciona tu cuenta de juego",
|
||||
"loggedInAs": "Conectado como",
|
||||
"choose": "Tu cuenta Battle.net tiene varias cuentas de juego. Elige con cuál quieres continuar:",
|
||||
"noAccounts": "No se han encontrado cuentas de juego asociadas a esta cuenta Battle.net.",
|
||||
"entering": "Entrando…",
|
||||
"error": "No se pudo seleccionar la cuenta."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user