Files
DorkNet/DorkNet.Server/admin-ui/src/pages/Players.tsx
T
Alexa bb04782e11 chore(deps): clear Dependabot alerts — React 19, react-router 8, Vite 7
Bumps both frontend apps (site, admin-ui):
- vite 5.4 -> 7.3.6 (pulls esbuild 0.28, patched dev-server CVEs)
- @vitejs/plugin-react 4 -> 5 (babel 7.29.7)
- postcss 8.4 -> 8.5.25
- react-router-dom 6.27 -> react-router 8.3.0 (package renamed
  upstream in v8; imports swapped in src). Fixes the open-redirect
  advisories with no patched 6.x, plus the RSC CSRF advisory
  (GHSA-qwww-vcr4-c8h2) that only 8.3.0 patches.
- react/react-dom 18.3 -> 19.2 (required by react-router 8)

npm audit: 0 vulnerabilities in both apps. tsc + vite build clean.
2026-08-02 00:57:34 +03:00

711 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { api, get } from '../lib/api';
import type { Player, PlayerDetail } from '../lib/types';
import { absoluteTime, currencyName, num, relativeTime } from '../lib/format';
import { PageHeader } from '../components/PageHeader';
import { Modal } from '../components/Modal';
import { Confirm } from '../components/Confirm';
import { useToast } from '../components/Toast';
import { Empty } from '../components/Empty';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { RefreshCw, Search } from '../components/Icons';
import { GiftPanel } from './Gift';
import { PasswordResetCard } from './Passwords';
export function Players({ embedded }: { embedded?: boolean } = {}) {
const [params] = useSearchParams();
const [query, setQuery] = useState(params.get('query') ?? '');
const [rows, setRows] = useState<Player[] | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
// Debounced search — the watch admin sat on /api/admin/v1/players for
// every keypress, blowing through the SQLite write lock pool. 200ms
// is enough to feel responsive without thrashing the DB.
const search = useMemo(() => {
const handler = (q: string) => {
setLoading(true);
setErr(null);
get<Player[]>(`/players?take=200${q ? `&query=${encodeURIComponent(q)}` : ''}`)
.then(setRows)
.catch(e => setErr((e as Error).message))
.finally(() => setLoading(false));
};
let t: number | undefined;
return (q: string) => {
window.clearTimeout(t);
t = window.setTimeout(() => handler(q), 200);
};
}, []);
useEffect(() => { search(query); }, [query, search]);
useEffect(() => {
const next = params.get('query') ?? '';
if (next !== query) setQuery(next);
}, [params, query]);
const refreshBtn = (
<button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
);
return (
<div>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Players"
blurb="Search and moderate every account in the database."
actions={refreshBtn}
/>
)}
<div className="card overflow-hidden">
<div className="border-b border-ink-800 p-3 flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search username or display name…"
className="input pl-8"
/>
</div>
<div className="text-xs text-ink-400 ml-auto">
{rows ? `${rows.length} match${rows.length === 1 ? '' : 'es'}` : ''}
</div>
</div>
{err && <div className="px-4 py-3 text-sm text-danger border-b border-danger/30 bg-danger/5">{err}</div>}
<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">Account</th>
<th className="text-left font-medium px-4 py-2.5">Status</th>
<th className="text-right font-medium px-4 py-2.5">Level</th>
<th className="text-left font-medium px-4 py-2.5">Last seen</th>
<th className="text-left font-medium px-4 py-2.5">Joined</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{rows?.map(p => (
<tr
key={p.id}
className="table-row-hover cursor-pointer"
onClick={() => setSelectedId(p.id)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={32} />
<div className="min-w-0">
<div className="font-medium text-ink-50 truncate">{p.displayName || p.username}</div>
<div className="text-xs text-ink-400 truncate">@{p.username} <span className="text-ink-600">·</span> #{p.id}</div>
</div>
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{p.online && <span className="badge-online">Online</span>}
{p.bannedUntil && new Date(p.bannedUntil) > new Date() && <span className="badge-banned">Banned</span>}
{p.isAdmin && <span className="badge-admin">Admin</span>}
{p.isDeveloper && <span className="badge-neutral">Dev</span>}
{p.isCommunityTeam && <span className="badge-neutral">Community</span>}
{p.isJunior && <span className="badge-junior">Junior</span>}
{p.isVerified && <span className="badge-neutral">Verified</span>}
{!p.online && !p.bannedUntil && !p.isAdmin && !p.isDeveloper && !p.isCommunityTeam && !p.isJunior && !p.isVerified && (
<span className="text-xs text-ink-500">—</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-200">{p.level}</td>
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(p.lastSeenAt)}>{relativeTime(p.lastSeenAt)}</td>
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(p.createdAt)}>{relativeTime(p.createdAt)}</td>
<td className="px-4 py-2.5 text-right">
<button className="btn-ghost text-xs">Open →</button>
</td>
</tr>
))}
{rows && rows.length === 0 && (
<tr><td colSpan={6}><Empty title="No matches" blurb="Try a different name or clear the filter." /></td></tr>
)}
{!rows && !err && (
<tr><td colSpan={6} className="py-10 text-center text-xs text-ink-400">Loading players…</td></tr>
)}
</tbody>
</table>
</div>
</div>
{selectedId !== null && (
<PlayerDetail
id={selectedId}
onClose={() => setSelectedId(null)}
onChanged={() => { setSelectedId(null); search(query); }}
/>
)}
</div>
);
}
// ── Detail / edit modal ───────────────────────────────────────────────
function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => void; onChanged: () => void }) {
const [data, setData] = useState<PlayerDetail | null>(null);
const [err, setErr] = useState<string | null>(null);
const [tab, setTab] = useState<'overview' | 'mod' | 'grants' | 'gift' | 'profile'>('overview');
const reload = () => {
setErr(null);
get<PlayerDetail>(`/players/${id}`)
.then(setData)
.catch(e => setErr((e as Error).message));
};
useEffect(reload, [id]);
return (
<Modal title={`Player #${id}`} open onClose={onClose} size="xl">
{err && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger mb-3">{err}</div>}
{!data && !err && <div className="py-10 text-center text-xs text-ink-400">Loading…</div>}
{data && (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<PlayerAvatar name={data.profileImageName} displayName={data.displayName || data.username} size={56} />
<div className="min-w-0">
<div className="text-lg font-semibold text-ink-50 truncate">{data.displayName || data.username}</div>
<div className="text-xs text-ink-400 truncate">@{data.username} · #{data.id} · {data.email ?? 'no email'}</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{data.online && <span className="badge-online">Online</span>}
{data.bannedUntil && new Date(data.bannedUntil) > new Date() && (
<span className="badge-banned" title={absoluteTime(data.bannedUntil)}>Banned · expires {relativeTime(data.bannedUntil)}</span>
)}
{data.isAdmin && <span className="badge-admin">Admin</span>}
{data.isDeveloper && <span className="badge-neutral">Developer</span>}
{data.isCommunityTeam && <span className="badge-neutral">Community Team</span>}
{data.isVerified && <span className="badge-neutral">Verified</span>}
{data.isJunior && <span className="badge-junior">Junior</span>}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<Stat label="Level" value={num(data.level)} />
<Stat label="XP" value={num(data.xp)} />
<Stat label="Last IP" value={data.lastIp ?? '—'} mono />
</div>
</div>
<div className="flex border-b border-ink-800 text-sm">
{(['overview', 'mod', 'grants', 'gift', 'profile'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-2 -mb-px border-b-2 capitalize ${tab === t ? 'border-brand-500 text-ink-50' : 'border-transparent text-ink-300 hover:text-ink-100'}`}
>
{t === 'mod' ? 'Moderation' : t}
</button>
))}
</div>
{tab === 'overview' && <OverviewTab data={data} />}
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
{tab === 'gift' && <GiftPanel playerId={data.id} />}
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} onDeleted={onChanged} />}
</div>
)}
</Modal>
);
}
function Stat({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="card !p-2 text-center">
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className={`text-sm font-medium text-ink-50 ${mono ? 'font-mono text-xs' : ''}`}>{value}</div>
</div>
);
}
function OverviewTab({ data }: { data: PlayerDetail }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<Field label="Display name" value={data.displayName} />
<Field label="Username" value={`@${data.username}`} />
<Field label="Email" value={data.email ?? '—'} />
<Field label="Joined" value={absoluteTime(data.createdAt)} />
<Field label="Last seen" value={absoluteTime(data.lastSeenAt)} />
<Field label="Last IP" value={data.lastIp ?? '—'} mono />
<Field label="Bio" full value={data.bio || <span className="text-ink-500">no bio</span>} />
<div className="card !p-3 md:col-span-2">
<div className="text-[11px] uppercase tracking-widest text-ink-400 mb-2">Wallet</div>
{data.balances.length === 0 ? (
<div className="text-xs text-ink-500">No currency balances on file.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{data.balances.map(b => (
<div key={b.currencyType} className="rounded border border-ink-800 px-3 py-2">
<div className="text-[10px] uppercase tracking-widest text-ink-400">{currencyName(b.currencyType)}</div>
<div className="text-sm font-medium text-ink-50 tabular-nums">{num(b.balance)}</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
function Field({ label, value, full, mono }: { label: string; value: React.ReactNode; full?: boolean; mono?: boolean }) {
return (
<div className={`card !p-3 ${full ? 'md:col-span-2' : ''}`}>
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className={`mt-0.5 text-sm text-ink-100 ${mono ? 'font-mono text-xs' : ''}`}>{value}</div>
</div>
);
}
// ── Moderation actions: ban, unban, kick, promote, demote ────────────
function ModerationTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
const toast = useToast();
const [banDays, setBanDays] = useState(7);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [confirmKey, setConfirmKey] = useState<null | 'ban' | 'unban' | 'kick' | 'promote' | 'demote'>(null);
const run = async (label: string, fn: () => Promise<unknown>) => {
setBusy(label);
try {
await fn();
toast.push(`${label} succeeded`, 'success');
onChanged();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(null);
}
};
const banned = data.bannedUntil && new Date(data.bannedUntil) > new Date();
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Ban / unban</h3>
<p className="text-xs text-ink-400 mb-3">
{banned ? `Currently banned until ${absoluteTime(data.bannedUntil)}.` : 'Temporary ban with optional reason.'}
</p>
<div className="flex items-center gap-2 mb-2">
<input type="number" min={1} max={3650} value={banDays} onChange={e => setBanDays(parseInt(e.target.value || '1'))} className="input w-24" />
<span className="text-xs text-ink-400">days</span>
</div>
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (optional, logged)" className="input mb-2 text-xs" />
<div className="flex gap-2">
<button onClick={() => setConfirmKey('ban')} className="btn-danger text-xs" disabled={!!busy || data.isAdmin}>
{busy === 'Ban' ? 'Banning…' : 'Ban player'}
</button>
{banned && (
<button onClick={() => setConfirmKey('unban')} className="btn-secondary text-xs" disabled={!!busy}>
Unban
</button>
)}
</div>
{data.isAdmin && <p className="text-[11px] text-warn mt-2">Demote this admin first.</p>}
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Kick</h3>
<p className="text-xs text-ink-400 mb-3">Sends a ModerationKick push so the watch boots out of its current session. The player can reconnect immediately.</p>
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (optional)" className="input mb-2 text-xs" />
<button onClick={() => setConfirmKey('kick')} className="btn-secondary text-xs" disabled={!!busy}>Kick player</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Admin role</h3>
<p className="text-xs text-ink-400 mb-3">
Admins bypass the ban check and can use this console. Don't demote yourself — there's a server-side guard but it's safer to have another admin do it.
</p>
<div className="flex gap-2">
{!data.isAdmin && (
<button onClick={() => setConfirmKey('promote')} className="btn-primary text-xs" disabled={!!busy}>Promote to admin</button>
)}
{data.isAdmin && (
<button onClick={() => setConfirmKey('demote')} className="btn-danger text-xs" disabled={!!busy}>Revoke admin</button>
)}
</div>
</div>
<Confirm
open={confirmKey === 'ban'}
onClose={() => setConfirmKey(null)}
title="Ban player"
body={`Ban @${data.username} for ${banDays} day${banDays === 1 ? '' : 's'}?`}
confirmLabel="Ban"
destructive
onConfirm={() => run('Ban', () => api(`/players/${data.id}/ban`, { method: 'POST', body: { DurationDays: banDays, Reason: reason } }))}
/>
<Confirm
open={confirmKey === 'unban'}
onClose={() => setConfirmKey(null)}
title="Unban player"
body={`Lift the ban on @${data.username}?`}
confirmLabel="Unban"
onConfirm={() => run('Unban', () => api(`/players/${data.id}/unban`, { method: 'POST', body: { Reason: reason } }))}
/>
<Confirm
open={confirmKey === 'kick'}
onClose={() => setConfirmKey(null)}
title="Kick player"
body={`Boot @${data.username} from their current session?`}
confirmLabel="Kick"
onConfirm={() => run('Kick', () => api(`/players/${data.id}/kick`, { method: 'POST', body: { Reason: reason } }))}
/>
<Confirm
open={confirmKey === 'promote'}
onClose={() => setConfirmKey(null)}
title="Promote to admin"
body={`Grant admin privileges to @${data.username}? They'll be able to use this console.`}
confirmLabel="Promote"
onConfirm={() => run('Promote', () => api(`/players/${data.id}/promote`, { method: 'POST' }))}
/>
<Confirm
open={confirmKey === 'demote'}
onClose={() => setConfirmKey(null)}
title="Revoke admin"
body={`Strip admin privileges from @${data.username}?`}
confirmLabel="Demote"
destructive
onConfirm={() => run('Demote', () => api(`/players/${data.id}/demote`, { method: 'POST' }))}
/>
</div>
);
}
// ── Grants: currency, XP, item, displayName, flags ───────────────────
function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
const toast = useToast();
const run = async (label: string, fn: () => Promise<unknown>) => {
try {
await fn();
toast.push(`${label} applied`, 'success');
onChanged();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const [currType, setCurrType] = useState(2);
const [currAmount, setCurrAmount] = useState(1000);
const [xp, setXp] = useState(1000);
const [targetLevel, setTargetLevel] = useState(data.level);
const [itemId, setItemId] = useState('');
const [itemQty, setItemQty] = useState(1);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Adjust currency</h3>
<p className="text-xs text-ink-400 mb-3">Positive grants, negative deducts (clamped to zero).</p>
<div className="flex gap-2 mb-2">
<select value={currType} onChange={e => setCurrType(parseInt(e.target.value))} className="input w-32">
<option value={1}>Tokens</option>
<option value={2}>Coins</option>
</select>
<input type="number" value={currAmount} onChange={e => setCurrAmount(parseInt(e.target.value || '0'))} className="input flex-1" />
</div>
<button
onClick={() => run('Currency', () => api(`/players/${data.id}/currency`, { method: 'POST', body: { CurrencyType: currType, Amount: currAmount, Reason: 'admin_grant' } }))}
className="btn-primary text-xs"
>Apply</button>
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Grant XP</h3>
<p className="text-xs text-ink-400 mb-3">May trigger level-up rewards.</p>
<div className="flex gap-2 mb-2">
<input type="number" value={xp} onChange={e => setXp(parseInt(e.target.value || '0'))} className="input" />
</div>
<button
onClick={() => run('XP', () => api(`/players/${data.id}/xp`, { method: 'POST', body: { Amount: xp, Reason: 'admin_grant' } }))}
className="btn-primary text-xs"
>Grant XP</button>
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Set level</h3>
<p className="text-xs text-ink-400 mb-3">Moves XP to the floor for that level.</p>
<div className="flex gap-2 mb-2">
<input
type="number"
min={1}
max={100}
value={targetLevel}
onChange={e => setTargetLevel(parseInt(e.target.value || '1'))}
className="input"
/>
</div>
<button
onClick={() => run('Level', () => api(`/players/${data.id}/level`, { method: 'POST', body: { Level: targetLevel } }))}
disabled={!Number.isFinite(targetLevel) || targetLevel < 1 || targetLevel === data.level}
className="btn-primary text-xs"
>Set level</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Unlock cheer badges</h3>
<p className="text-xs text-ink-400 mb-3">
Drops one cheer of each category (General, Helpful, Great Host, Sportsman, Creative, Credit) into the player's received-cheer list so they can pin any badge from the watch's Selected Cheer picker. Idempotent — re-running only fills in categories they don't already have. No XP is awarded.
</p>
<button
onClick={() => run('Cheers', () => api(`/players/${data.id}/cheers/unlock`, { method: 'POST' }))}
className="btn-primary text-xs"
>Unlock all categories</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Unlock all items</h3>
<p className="text-xs text-ink-400 mb-3">
Grants the player the entire catalog: every avatar cosmetic, every permanent hair dye, and every store equipment/consumable. Idempotent — only adds what's missing. The watch's wardrobe only displays up to ~120 owned cosmetics at once (hard client cap), but all are granted and store items show as owned.
</p>
<button
onClick={() => run('Unlock all', () => api(`/players/${data.id}/inventory/unlock-all`, { method: 'POST' }))}
className="btn-primary text-xs"
>Unlock everything</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Grant inventory item</h3>
<p className="text-xs text-ink-400 mb-3">Appends an item GUID to the player's inventory JSON. Use the Store catalog to find slugs / GUIDs.</p>
<div className="grid grid-cols-1 sm:grid-cols-[1fr,120px,auto] gap-2">
<input value={itemId} onChange={e => setItemId(e.target.value)} placeholder="item GUID" className="input font-mono text-xs" />
<input type="number" min={1} max={1000} value={itemQty} onChange={e => setItemQty(parseInt(e.target.value || '1'))} className="input" />
<button
onClick={() => run('Item', () => api(`/players/${data.id}/inventory/grant`, { method: 'POST', body: { ItemId: itemId.trim(), Quantity: itemQty } }))}
disabled={!itemId.trim()}
className="btn-primary text-xs"
>Grant</button>
</div>
</div>
</div>
);
}
// ── Profile: display name + role flags ───────────────────────────────
function ProfileTab({ data, onChanged, onDeleted }: { data: PlayerDetail; onChanged: () => void; onDeleted: () => void }) {
const toast = useToast();
const [displayName, setDisplayName] = useState(data.displayName);
const [username, setUsername] = useState(data.username);
const [deleteOpen, setDeleteOpen] = useState(false);
const [flags, setFlags] = useState({
isVerified: data.isVerified,
isDeveloper: data.isDeveloper,
isCommunityTeam: data.isCommunityTeam,
isJunior: data.isJunior,
});
const save = async (label: string, fn: () => Promise<unknown>) => {
try {
await fn();
toast.push(`${label} saved`, 'success');
onChanged();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Username</h3>
<p className="text-xs text-ink-400 mb-3">The unique @handle. 2–24 chars: letters, numbers, _ or -. Changing it doesn't touch the display name.</p>
<div className="flex gap-2">
<input value={username} onChange={e => setUsername(e.target.value)} className="input flex-1 font-mono text-xs" />
<button
onClick={() => save('Username', () => api(`/players/${data.id}/username`, { method: 'POST', body: { Username: username.trim() } }))}
disabled={!username.trim() || username.trim() === data.username}
className="btn-primary text-xs"
>Save</button>
</div>
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Display name</h3>
<p className="text-xs text-ink-400 mb-3">Force-override the player's display name.</p>
<div className="flex gap-2">
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="input flex-1" />
<button
onClick={() => save('Display name', () => api(`/players/${data.id}/displayName`, { method: 'POST', body: { DisplayName: displayName } }))}
disabled={!displayName.trim() || displayName === data.displayName}
className="btn-primary text-xs"
>Save</button>
</div>
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Profile flags</h3>
<p className="text-xs text-ink-400 mb-3">
Verified = blue check on profile. Developer or Community Team unlocks the watch's overhead-badge slider — once unlocked, the player picks "Developer" or "Community Team" from their in-watch settings and the label renders above their head for everyone in the room.
</p>
<div className="flex flex-col gap-2 mb-3">
{([
['isVerified', 'Verified'],
['isDeveloper', 'Developer'],
['isCommunityTeam', 'Community Team'],
['isJunior', 'Junior'],
] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-2 text-sm text-ink-200">
<input
type="checkbox"
checked={flags[k]}
onChange={e => setFlags(f => ({ ...f, [k]: e.target.checked }))}
className="size-4 accent-brand-500"
/>
{label}
</label>
))}
</div>
<button
onClick={() => save('Flags', () => api(`/players/${data.id}/flags`, { method: 'POST', body: { IsVerified: flags.isVerified, IsDeveloper: flags.isDeveloper, IsCommunityTeam: flags.isCommunityTeam, IsJunior: flags.isJunior } }))}
className="btn-primary text-xs"
>Save flags</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Reset avatar</h3>
<p className="text-xs text-ink-400 mb-3">
Wipes the player's equipped outfit and inventory back to the 2020 starter set.
Use this when the watch crashes loading the avatar — usually means an item
GUID in their inventory has no matching <code className="text-[11px]">outfits_assets_*.bundle</code> on disk
(the build can't load it and Unity's Addressables tears down the local player).
</p>
<button
onClick={() => {
if (!confirm(`Reset @${data.username}'s avatar to the starter outfit and inventory? Existing wardrobe items will be removed.`)) return;
save('Avatar reset', () => api(`/players/${data.id}/avatar/reset`, { method: 'POST' }));
}}
className="btn-danger text-xs"
>Reset to starter</button>
</div>
<PasswordResetCard playerId={data.id} username={data.username} />
<div className="card !p-4 md:col-span-2 border-danger/40 bg-danger/5">
<h3 className="text-sm font-semibold text-danger mb-1">Remove account</h3>
<p className="text-xs text-ink-300 mb-3">
Deletes the account and personal rows. Rooms, inventions, clubs, and other durable authored content are reassigned to the system account.
</p>
<button
onClick={() => setDeleteOpen(true)}
className="btn-danger text-xs"
disabled={data.isAdmin || data.id === 1}
>Remove account</button>
{data.isAdmin && <p className="text-[11px] text-warn mt-2">Demote this admin before removing the account.</p>}
{data.id === 1 && <p className="text-[11px] text-warn mt-2">The system account cannot be removed.</p>}
</div>
<DeleteAccountModal
open={deleteOpen}
data={data}
onClose={() => setDeleteOpen(false)}
onDeleted={onDeleted}
/>
</div>
);
}
function DeleteAccountModal({ open, data, onClose, onDeleted }: {
open: boolean;
data: PlayerDetail;
onClose: () => void;
onDeleted: () => void;
}) {
const toast = useToast();
const [confirmUsername, setConfirmUsername] = useState('');
const [confirmPhrase, setConfirmPhrase] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const expectedPhrase = `DELETE ${data.id}`;
const valid = confirmUsername.trim() === data.username && confirmPhrase.trim() === expectedPhrase;
const remove = async () => {
if (!valid) return;
setBusy(true);
try {
await api(`/players/${data.id}`, {
method: 'DELETE',
body: {
ConfirmUsername: confirmUsername.trim(),
ConfirmPhrase: confirmPhrase.trim(),
Reason: reason.trim() || null,
},
});
toast.push(`@${data.username} removed`, 'success');
onClose();
onDeleted();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
title="Remove account"
open={open}
onClose={onClose}
size="sm"
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button onClick={remove} className="btn-danger text-xs" disabled={busy || !valid}>
{busy ? 'Removing…' : 'Remove account'}
</button>
</>}
>
<div className="space-y-3 text-sm">
<p className="text-ink-200">
This removes <span className="font-mono">@{data.username}</span> and cannot be undone from the admin UI.
</p>
<label className="block">
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Type username</span>
<input
value={confirmUsername}
onChange={e => setConfirmUsername(e.target.value)}
placeholder={data.username}
className="input font-mono text-xs"
/>
</label>
<label className="block">
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Type confirmation phrase</span>
<input
value={confirmPhrase}
onChange={e => setConfirmPhrase(e.target.value)}
placeholder={expectedPhrase}
className="input font-mono text-xs"
/>
</label>
<label className="block">
<span className="block text-[11px] uppercase tracking-widest text-ink-400 mb-1">Reason</span>
<input
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Optional audit note"
className="input text-xs"
/>
</label>
</div>
</Modal>
);
}