Files
ttrpg_manager/src/features/settings/CloudCampaigns.tsx
T
NilsBriggen 5119fc6d1c Proper admin panel at /admin
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>
2026-06-10 20:49:59 +02:00

157 lines
9.2 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, 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 players'}</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">
Youre 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 campaigns 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`;
}