Files
NightSpire/web-next/lib/admin-users.ts
T
Inna d382adcd59 Admin: gestión de usuarios (buscar, banear/desbanear)
- lib/admin-users.ts: searchAccounts (acore_auth.account + account_banned + gmlevel),
  banAccount/unbanAccount por BD (autoritativo) con kick best-effort por SOAP.
  Baneo días<=0 = permanente (unbandate=bandate, convención AzerothCore); desactiva
  baneo activo previo antes de insertar el nuevo.
- API: GET /api/admin/users?q=, POST {accountId, action:ban|unban, reason, days} (403 sin admin).
- UI: /admin/users con AdminUsersManager (buscar por usuario/email, badges GM/online/baneado,
  banear con motivo+días o desbanear), enlace desde el índice del admin.
- i18n es/en (Admin): 14 claves nuevas.

Verificado: build OK, /admin/users 307→login, APIs 403 sin admin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:39:08 +00:00

89 lines
3.2 KiB
TypeScript

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<AccountRow[]> {
const like = `%${query}%`
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
`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<string | null> {
const [rows] = await db(DB.auth).query<RowDataPacket[]>('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<boolean> {
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<boolean> {
const [res] = await db(DB.auth).query<RowDataPacket[] & { affectedRows?: number }>(
'UPDATE account_banned SET active = 0 WHERE id = ? AND active = 1',
[accountId],
)
return Boolean((res as unknown as { affectedRows: number }).affectedRows)
}