5119fc6d1c
Replaces the table tucked into Settings with a real instance dashboard, gated server-side to ADMIN_USERS (frontend nav entry appears for admins only). Backend (/api/admin/*, admin-token required): - GET overview: uptime, RSS/heap, data-dir bytes, account count vs MAX_USERS, default quota, campaign/character totals, live-room load - GET users: created date, usage vs effective quota, session count, admin flag - POST users/:name/revoke — log a user out everywhere - DELETE users/:name — delete account + backup + owned campaigns + published characters (admin accounts are protected from deletion) - GET/DELETE campaigns — oversight + removal incl. published characters - GET rooms — players/seats/images/idle per room; join codes never exposed Frontend: new /admin page (health cards, account management with quota editor + two-click destructive confirms, campaign table, live-room table), ShieldCheck nav entry via useIsAdmin(), Settings now links to the panel instead of embedding it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
9.2 KiB
TypeScript
157 lines
9.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Link } from '@tanstack/react-router';
|
||
import { useLiveQuery } from 'dexie-react-hooks';
|
||
import { db } from '@/lib/db/db';
|
||
import { cloudUsername, CloudError } from '@/lib/cloud/client';
|
||
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns';
|
||
import { useCampaigns } from '@/features/campaigns/hooks';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Input, Select } from '@/components/ui/Input';
|
||
|
||
/** Settings: publish a campaign for players, join one, and publish your character (it stays yours + syncs). */
|
||
export function CloudCampaigns() {
|
||
const signedIn = !!cloudUsername();
|
||
const [list, setList] = useState<CloudCampaignInfo[]>([]);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const localCampaigns = useCampaigns();
|
||
const localChars = useLiveQuery(() => db.characters.toArray(), [], []);
|
||
const [invite, setInvite] = useState('');
|
||
const [pubCampaign, setPubCampaign] = useState('');
|
||
const [charId, setCharId] = useState('');
|
||
const [targetCloud, setTargetCloud] = useState('');
|
||
const [viewing, setViewing] = useState<string | null>(null);
|
||
const [party, setParty] = useState<CloudCharInfo[]>([]);
|
||
const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null);
|
||
|
||
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
|
||
useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]);
|
||
const refresh = () => listCloudCampaigns().then(setList).catch(() => {});
|
||
|
||
const run = (fn: () => Promise<void>) => async () => {
|
||
setBusy(true); setMsg(null);
|
||
try { await fn(); } catch (e) { setMsg(e instanceof CloudError ? e.message : 'Something went wrong.'); } finally { setBusy(false); }
|
||
};
|
||
|
||
if (!signedIn) {
|
||
return (
|
||
<section className="rounded-lg border border-line bg-panel p-4">
|
||
<h2 className="mb-2 smallcaps">Shared campaigns (cloud)</h2>
|
||
<p className="text-sm text-muted">Sign in above to publish a campaign for your players, or join one and publish your character — it stays yours and syncs when you edit it.</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const owned = list.filter((c) => c.role === 'owner');
|
||
|
||
return (
|
||
<section className="space-y-4 rounded-lg border border-line bg-panel p-4">
|
||
<h2 className="smallcaps">Shared campaigns (cloud)</h2>
|
||
|
||
{list.length > 0 && (
|
||
<ul className="space-y-2">
|
||
{list.map((c) => (
|
||
<li key={c.id} className="rounded-md border border-line bg-surface p-2 text-sm">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-medium text-ink">{c.name}</span>
|
||
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{c.role}</span>
|
||
{c.inviteCode && (
|
||
<button className="font-mono text-xs text-accent" title="Copy invite code" onClick={() => void navigator.clipboard?.writeText(c.inviteCode!).then(() => setMsg(`Invite code ${c.inviteCode} copied.`))}>invite: {c.inviteCode}</button>
|
||
)}
|
||
{c.role === 'owner' && c.inviteCode && (
|
||
<Button size="sm" variant="ghost" title="Invalidate the old code and mint a new one" onClick={run(async () => { const r = await rotateInvite(c.id); setMsg(`New invite code: ${r.inviteCode}`); refresh(); })}>Rotate invite</Button>
|
||
)}
|
||
{c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>}
|
||
</div>
|
||
{viewing === c.id && (
|
||
<ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs">
|
||
{party.length === 0 ? <li className="text-muted">No characters published yet.</li> : party.map((p) => (
|
||
<li key={p.id} className="flex items-center justify-between gap-2">
|
||
<span className="text-ink">{p.name}</span>
|
||
<span className="flex items-center gap-2">
|
||
<span className="text-muted">{p.mine ? 'yours' : 'a player’s'}</span>
|
||
{!p.mine && (
|
||
<button className="text-danger hover:underline" title="Remove this player and their characters from the campaign"
|
||
onClick={run(async () => { await removeCloudMember(c.id, p.ownerUserId); setMsg('Player removed.'); setParty(await listCloudCharacters(c.id)); })}>remove</button>
|
||
)}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<h3 className="mb-1 text-xs font-medium text-muted">Publish a campaign (you = GM)</h3>
|
||
<div className="flex gap-1">
|
||
<Select value={pubCampaign} onChange={(e) => setPubCampaign(e.target.value)} aria-label="Local campaign to publish" className="flex-1">
|
||
<option value="">Choose a campaign…</option>
|
||
{localCampaigns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</Select>
|
||
<Button size="sm" variant="primary" disabled={busy || !pubCampaign} onClick={run(async () => { const c = localCampaigns.find((x) => x.id === pubCampaign); if (!c) return; await createCloudCampaign(c.name, c.system); setMsg(`Published “${c.name}” — share its invite code.`); refresh(); })}>Publish</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="mb-1 text-xs font-medium text-muted">Join a campaign (you = player)</h3>
|
||
<div className="flex gap-1">
|
||
<Input value={invite} onChange={(e) => setInvite(e.target.value.toUpperCase())} placeholder="Invite code" aria-label="Invite code" className="flex-1" />
|
||
<Button size="sm" variant="secondary" disabled={busy || !invite.trim()} onClick={run(async () => { const c = await joinCloudCampaign(invite.trim()); setInvite(''); setMsg(`Joined “${c.name}”.`); refresh(); })}>Join</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="mb-1 text-xs font-medium text-muted">Publish a character (stays yours, syncs on edit)</h3>
|
||
<div className="flex flex-wrap gap-1">
|
||
<Select value={charId} onChange={(e) => setCharId(e.target.value)} aria-label="Character to publish">
|
||
<option value="">Choose a character…</option>
|
||
{(localChars ?? []).map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</Select>
|
||
<Select value={targetCloud} onChange={(e) => setTargetCloud(e.target.value)} aria-label="Target shared campaign">
|
||
<option value="">…to which campaign</option>
|
||
{list.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</Select>
|
||
<Button size="sm" variant="primary" disabled={busy || !charId || !targetCloud} onClick={run(async () => { const ch = (localChars ?? []).find((c) => c.id === charId); if (!ch) return; await publishCharacter(targetCloud, { id: ch.id, name: ch.name, data: JSON.stringify(ch) }); setMsg(`Published ${ch.name}.`); if (viewing === targetCloud) setParty(await listCloudCharacters(targetCloud)); })}>Publish</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{usage && (() => {
|
||
const pct = usage.quota > 0 ? Math.min(100, Math.round((usage.bytes / usage.quota) * 100)) : 0;
|
||
const near = pct >= 90;
|
||
return (
|
||
<div className="text-xs">
|
||
<p className={near ? 'text-warning' : 'text-muted'}>
|
||
Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span> of {fmtBytes(usage.quota)} ({pct}%)
|
||
{near && ' — almost full; trim old data or ask the admin to raise your quota.'}
|
||
</p>
|
||
<div className="mt-1 h-1.5 w-full max-w-xs overflow-hidden rounded-full bg-elevated">
|
||
<div className={near ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{usage?.admin && (
|
||
<div className="rounded-md border border-accent/30 bg-accent/5 p-2 text-sm">
|
||
You’re the instance admin — manage accounts, storage, campaigns, and live rooms in the{' '}
|
||
<Link to="/admin" className="text-accent underline">Admin panel</Link>.
|
||
</div>
|
||
)}
|
||
|
||
{msg && <p className="text-sm text-accent">{msg}</p>}
|
||
<p className="text-xs text-muted">{owned.length > 0 ? 'Share an owner campaign’s invite code with your players. They sign in, join, and publish their characters — you see them under “View party”.' : 'Publish one of your campaigns to get an invite code for players.'}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function fmtBytes(b: number): string {
|
||
if (b >= 1e9) return `${(b / 1e9).toFixed(2)} GB`;
|
||
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
|
||
return `${Math.max(0, Math.round(b / 1024))} KB`;
|
||
}
|