Files
DorkNet/DorkNet.Server/admin-ui/src/pages/Bans.tsx
T
Alexa 955246c6e4 feat: Refactor admin UI pages to support embedded mode and improve navigation
- Updated PlayerLogs, Players, Reports, Rooms, Settings, SignupCodes, and other pages to accept an optional `embedded` prop for better integration in various contexts.
- Introduced a new Activity page consolidating audit logs and player request logs with tab navigation.
- Added Content page for community board and loading tips with tab navigation.
- Created Moderation page to unify player directory, bans, and reports under a single interface.
- Implemented SettingsHome page to manage server settings and signup codes with tab navigation.
- Enhanced the Join page to include password creation for new accounts.
- Added global friends feature in Settings to allow all accounts to be friends without database entries.
- Introduced RelationshipQueries service to manage effective friend resolution based on global friends toggle.
- Added WireDates for consistent date handling in serialized data.
- Created reusable Tabs component for consistent tab navigation across various pages.
2026-06-06 16:39:19 +03:00

193 lines
8.3 KiB
TypeScript

import { useState } from 'react';
import { api } from '../lib/api';
import type { IpBan, Player } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Empty } from '../components/Empty';
import { absoluteTime, relativeTime } from '../lib/format';
import { RefreshCw, Trash } from '../components/Icons';
export function Bans({ embedded }: { embedded?: boolean } = {}) {
const [tab, setTab] = useState<'players' | 'ip'>('players');
return (
<div>
{!embedded && (
<PageHeader
title="Bans"
blurb="Player bans and IP-level bans, all in one place."
/>
)}
<div className="flex border-b border-ink-800 text-sm mb-4">
{(['players', 'ip'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 -mb-px border-b-2 ${tab === t ? 'border-brand-500 text-ink-50' : 'border-transparent text-ink-300 hover:text-ink-100'}`}
>
{t === 'players' ? 'Player bans' : 'IP bans'}
</button>
))}
</div>
{tab === 'players' && <PlayerBans />}
{tab === 'ip' && <IpBans />}
</div>
);
}
function PlayerBans() {
// Pull a generous page of players and filter client-side for currently-banned.
// For a small private server this is fine; if we ever scale we can add a
// dedicated /players/banned endpoint.
const { data, loading, error, refresh } = useApi<Player[]>('/players?take=200');
const now = Date.now();
const banned = (data ?? []).filter(p => p.bannedUntil && new Date(p.bannedUntil).getTime() > now);
const toast = useToast();
const unban = async (id: number) => {
try {
await api(`/players/${id}/unban`, { method: 'POST', body: { Reason: 'admin-unban' } });
toast.push('Unbanned', 'success');
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-ink-800 px-4 py-2.5">
<div className="text-xs text-ink-400">{banned.length} active player ban{banned.length === 1 ? '' : 's'}</div>
<button onClick={refresh} className="btn-ghost text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
{!error && banned.length === 0 && <Empty title="No active player bans" blurb="Issued bans appear here until they expire or are lifted." />}
{banned.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
<tr>
<th className="text-left font-medium px-4 py-2.5">Player</th>
<th className="text-left font-medium px-4 py-2.5">Expires</th>
<th className="text-left font-medium px-4 py-2.5">Last IP</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{banned.map(p => (
<tr key={p.id} className="table-row-hover">
<td className="px-4 py-2.5">
<div className="font-medium text-ink-50">{p.displayName || p.username}</div>
<div className="text-xs text-ink-400">@{p.username} · #{p.id}</div>
</td>
<td className="px-4 py-2.5 text-ink-200 text-xs" title={absoluteTime(p.bannedUntil)}>
{relativeTime(p.bannedUntil)} <span className="text-ink-500">({absoluteTime(p.bannedUntil)})</span>
</td>
<td className="px-4 py-2.5 text-ink-300 font-mono text-xs">{p.lastIp ?? '—'}</td>
<td className="px-4 py-2.5 text-right">
<button onClick={() => unban(p.id)} className="btn-secondary text-xs">Unban</button>
</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
);
}
function IpBans() {
const { data, loading, error, refresh } = useApi<IpBan[]>('/ipbans');
const toast = useToast();
const [cidr, setCidr] = useState('');
const [reason, setReason] = useState('');
const [days, setDays] = useState<number | ''>('');
const add = async (e: React.FormEvent) => {
e.preventDefault();
if (!cidr.trim()) return;
try {
await api('/ipbans', { method: 'POST', body: { Cidr: cidr.trim(), Reason: reason, DurationDays: days || null } });
setCidr(''); setReason(''); setDays('');
toast.push('IP ban added', 'success');
refresh();
} catch (err) {
toast.push((err as Error).message, 'error');
}
};
const remove = async (id: number) => {
if (!confirm('Remove this IP ban?')) return;
try {
await api(`/ipbans/${id}`, { method: 'DELETE' });
toast.push('IP ban removed', 'success');
refresh();
} catch (err) {
toast.push((err as Error).message, 'error');
}
};
const active = (b: IpBan) => !b.until || new Date(b.until).getTime() > Date.now();
return (
<div className="space-y-4">
<form onSubmit={add} className="card !p-4 grid grid-cols-1 md:grid-cols-[1fr,1fr,140px,auto] gap-2">
<input value={cidr} onChange={e => setCidr(e.target.value)} placeholder="1.2.3.4 or 10.0.0.0/24" required className="input" />
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (logged)" className="input" />
<input
type="number"
min={1}
max={3650}
value={days}
onChange={e => setDays(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="days (∞)"
className="input"
/>
<button className="btn-primary text-xs">Add ban</button>
</form>
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-ink-800 px-4 py-2.5">
<div className="text-xs text-ink-400">{data ? `${data.length} total · ${data.filter(active).length} active` : ''}</div>
<button onClick={refresh} className="btn-ghost text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
{data && data.length === 0 && <Empty title="No IP bans" blurb="Add a CIDR above to start blocking ranges." />}
{data && data.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
<tr>
<th className="text-left font-medium px-4 py-2.5">CIDR</th>
<th className="text-left font-medium px-4 py-2.5">Reason</th>
<th className="text-left font-medium px-4 py-2.5">Issued</th>
<th className="text-left font-medium px-4 py-2.5">Expires</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{data.map(b => (
<tr key={b.id} className="table-row-hover">
<td className="px-4 py-2.5 font-mono text-xs text-ink-100">{b.cidr}</td>
<td className="px-4 py-2.5 text-ink-300">{b.reason || '—'}</td>
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(b.bannedAt)}>{relativeTime(b.bannedAt)}</td>
<td className="px-4 py-2.5 text-ink-300 text-xs">
{b.until ? <span title={absoluteTime(b.until)}>{relativeTime(b.until)}</span> : <span className="text-ink-500">∞</span>}
</td>
<td className="px-4 py-2.5 text-right">
<button onClick={() => remove(b.id)} className="btn-ghost text-xs text-danger">
<Trash /> Remove
</button>
</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
</div>
);
}