diff --git a/web-next/app/[locale]/admin/page.tsx b/web-next/app/[locale]/admin/page.tsx index 1c2a4a1..9dfa536 100644 --- a/web-next/app/[locale]/admin/page.tsx +++ b/web-next/app/[locale]/admin/page.tsx @@ -37,6 +37,11 @@ export default async function AdminPage({ params }: { params: Promise<{ locale: {t('manageGold')} +
  • + + {t('manageUsers')} + +
  • ) diff --git a/web-next/app/[locale]/admin/users/page.tsx b/web-next/app/[locale]/admin/users/page.tsx new file mode 100644 index 0000000..f73a459 --- /dev/null +++ b/web-next/app/[locale]/admin/users/page.tsx @@ -0,0 +1,22 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { AdminUsersManager } from '@/components/AdminUsersManager' + +export const dynamic = 'force-dynamic' + +export default async function AdminUsersPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('Admin') + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!(await isAdmin(session))) redirect({ href: '/', locale }) + return ( +
    +

    {t('users')}

    + +
    + ) +} diff --git a/web-next/app/api/admin/users/route.ts b/web-next/app/api/admin/users/route.ts new file mode 100644 index 0000000..689cf42 --- /dev/null +++ b/web-next/app/api/admin/users/route.ts @@ -0,0 +1,38 @@ +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { searchAccounts, banAccount, unbanAccount } from '@/lib/admin-users' + +/** Búsqueda de cuentas: GET /api/admin/users?q=... */ +export async function GET(request: Request) { + const session = await getSession() + if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) + const q = (new URL(request.url).searchParams.get('q') || '').trim() + if (q.length < 2) return Response.json({ success: true, accounts: [] }) + const accounts = await searchAccounts(q) + return Response.json({ success: true, accounts }) +} + +/** Ban/unban: POST /api/admin/users { accountId, action, reason?, days? } */ +export async function POST(request: Request) { + const session = await getSession() + if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) + + let b: Record = {} + try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } + const accountId = Number(b.accountId) + const action = String(b.action ?? '') + if (!accountId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + + if (action === 'ban') { + const reason = String(b.reason ?? '').trim() || 'Baneado desde el panel de administración' + const days = Number(b.days) || 0 + const bannedBy = session.username || session.bnetEmail || 'admin' + const ok = await banAccount(accountId, reason, bannedBy, days) + return Response.json({ success: ok }) + } + if (action === 'unban') { + const ok = await unbanAccount(accountId) + return Response.json({ success: ok }) + } + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) +} diff --git a/web-next/components/AdminUsersManager.tsx b/web-next/components/AdminUsersManager.tsx new file mode 100644 index 0000000..6dc17a8 --- /dev/null +++ b/web-next/components/AdminUsersManager.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import type { AccountRow } from '@/lib/admin-users' + +export function AdminUsersManager() { + const t = useTranslations('Admin') + const [q, setQ] = useState('') + const [accounts, setAccounts] = useState([]) + const [busy, setBusy] = useState(false) + const [searched, setSearched] = useState(false) + + async function search(e: React.FormEvent) { + e.preventDefault() + if (q.trim().length < 2) return + setBusy(true) + try { + const res = await fetch(`/api/admin/users?q=${encodeURIComponent(q.trim())}`, { credentials: 'same-origin' }) + const data: { success?: boolean; accounts?: AccountRow[] } = await res.json() + setAccounts(data.accounts ?? []) + setSearched(true) + } finally { + setBusy(false) + } + } + + async function act(acc: AccountRow, action: 'ban' | 'unban') { + let reason = '' + let days = 0 + if (action === 'ban') { + reason = prompt(t('banReasonPrompt'), '') ?? '' + if (reason === null) return + const d = prompt(t('banDaysPrompt'), '0') + if (d === null) return + days = Number(d) || 0 + } else if (!confirm(t('confirmUnban', { name: acc.username }))) { + return + } + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ accountId: acc.id, action, reason, days }), + }) + if ((await res.json()).success) { + setAccounts((list) => + list.map((a) => + a.id === acc.id + ? { ...a, banned: action === 'ban', ban_reason: action === 'ban' ? reason : null, ban_until: null } + : a, + ), + ) + } + } + + return ( +
    +
    + setQ(e.target.value)} + placeholder={t('userSearchPlaceholder')} + className="nw-input flex-1" + /> + +
    + + {searched && accounts.length === 0 ? ( +

    {t('noUsers')}

    + ) : ( +
      + {accounts.map((a) => ( +
    • +
      +

      + {a.username} + {a.gmlevel > 0 && ( + GM {a.gmlevel} + )} + {a.online && ● {t('online')}} + {a.banned && ⛔ {t('banned')}} +

      +

      + #{a.id} + {a.email && ` · ${a.email}`} + {a.last_login && ` · ${t('lastLogin')}: ${new Date(a.last_login).toLocaleDateString()}`} + {a.banned && a.ban_reason && ` · ${a.ban_reason}`} + {a.banned && a.ban_until && ` (${new Date(a.ban_until).toLocaleDateString()})`} +

      +
      + {a.banned ? ( + + ) : ( + + )} +
    • + ))} +
    + )} +
    + ) +} diff --git a/web-next/lib/admin-users.ts b/web-next/lib/admin-users.ts new file mode 100644 index 0000000..91b135e --- /dev/null +++ b/web-next/lib/admin-users.ts @@ -0,0 +1,88 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { executeSoapCommand } from './soap' + +export interface AccountRow { + id: number + username: string + email: string | null + last_ip: string | null + last_login: string | null + online: boolean + gmlevel: number + banned: boolean + ban_reason: string | null + ban_until: string | null // ISO, o null si permanente/no baneado +} + +/** Busca cuentas por nombre de usuario o email (acore_auth.account). */ +export async function searchAccounts(query: string, limit = 30): Promise { + const like = `%${query}%` + const [rows] = await db(DB.auth).query( + `SELECT a.id, a.username, a.email, a.last_ip, a.last_login, a.online, + (SELECT MAX(x.gmlevel) FROM account_access x WHERE x.id = a.id) AS gmlevel, + b.banreason AS ban_reason, b.bandate, b.unbandate + FROM account a + LEFT JOIN account_banned b ON b.id = a.id AND b.active = 1 + WHERE a.username LIKE ? OR a.email LIKE ? + ORDER BY a.username + LIMIT ?`, + [like, like, limit], + ) + return rows.map((r) => { + const permanent = r.bandate != null && Number(r.unbandate) === Number(r.bandate) + return { + id: r.id, + username: r.username, + email: r.email || null, + last_ip: r.last_ip || null, + last_login: r.last_login ? new Date(r.last_login).toISOString() : null, + online: r.online === 1, + gmlevel: r.gmlevel != null ? Number(r.gmlevel) : 0, + banned: r.bandate != null, + ban_reason: r.ban_reason ?? null, + ban_until: r.bandate != null && !permanent ? new Date(Number(r.unbandate) * 1000).toISOString() : null, + } + }) +} + +async function getUsername(accountId: number): Promise { + const [rows] = await db(DB.auth).query('SELECT username FROM account WHERE id = ?', [accountId]) + return rows[0]?.username ?? null +} + +/** + * Banea una cuenta. `days` <= 0 => permanente (unbandate = bandate, convención AzerothCore). + * Escribe en account_banned (autoritativo) e intenta kickear por SOAP si está conectado. + */ +export async function banAccount( + accountId: number, + reason: string, + bannedBy: string, + days: number, +): Promise { + const username = await getUsername(accountId) + if (!username) return false + + // Desactiva un baneo activo previo para no acumular activos. + await db(DB.auth).query('UPDATE account_banned SET active = 0 WHERE id = ? AND active = 1', [accountId]) + + const duration = days > 0 ? Math.floor(days) * 86400 : 0 + await db(DB.auth).query( + `INSERT INTO account_banned (id, bandate, unbandate, bannedby, banreason, active) + VALUES (?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() + ?, ?, ?, 1)`, + [accountId, duration, bannedBy, reason], + ) + // Best-effort: kickear al jugador si está en línea (no bloquea el baneo si SOAP falla). + await executeSoapCommand(`.kick ${username}`).catch(() => null) + return true +} + +/** Levanta el baneo activo de una cuenta. */ +export async function unbanAccount(accountId: number): Promise { + const [res] = await db(DB.auth).query( + 'UPDATE account_banned SET active = 0 WHERE id = ? AND active = 1', + [accountId], + ) + return Boolean((res as unknown as { affectedRows: number }).affectedRows) +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index e5b0878..39c5426 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -332,7 +332,20 @@ "goldAmount": "Gold amount", "goldPrice": "Price (€)", "goldUnit": "gold", - "noGold": "No gold options." + "noGold": "No gold options.", + "manageUsers": "Manage users", + "users": "Users", + "searching": "Searching…", + "userSearchPlaceholder": "Search account by username or email…", + "noUsers": "No matching accounts.", + "online": "Online", + "banned": "Banned", + "lastLogin": "Last login", + "ban": "Ban", + "unban": "Unban", + "banReasonPrompt": "Ban reason:", + "banDaysPrompt": "Ban days (0 = permanent):", + "confirmUnban": "Unban {name}?" }, "Vote": { "title": "Vote for us", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 4aa1f22..7aedd9f 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -332,7 +332,20 @@ "goldAmount": "Cantidad de oro", "goldPrice": "Precio (€)", "goldUnit": "oro", - "noGold": "No hay opciones de oro." + "noGold": "No hay opciones de oro.", + "manageUsers": "Gestionar usuarios", + "users": "Usuarios", + "searching": "Buscando…", + "userSearchPlaceholder": "Buscar cuenta por usuario o email…", + "noUsers": "No hay cuentas que coincidan.", + "online": "En línea", + "banned": "Baneado", + "lastLogin": "Último acceso", + "ban": "Banear", + "unban": "Desbanear", + "banReasonPrompt": "Motivo del baneo:", + "banDaysPrompt": "Días de baneo (0 = permanente):", + "confirmUnban": "¿Desbanear a {name}?" }, "Vote": { "title": "Votar por nosotros",