P15d: storage usage display + owner admin panel

- Server: AccountStore gains admin gating (ADMIN_USERS env), per-user quotaBytes
  override, listUsers + blobBytes. /api/usage now reports total bytes (backup blob +
  cloud characters) + an admin flag. Admin routes GET /api/admin/users and POST
  /api/admin/quota (gated). Quotas are tracked, not yet enforced. +1 test.
- Client: Settings cloud panel shows "Your cloud storage", and for an admin a users
  table with per-user usage + an editable quota (GB). compose sets ADMIN_USERS=nilsb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:48:27 +02:00
parent e8371f6d3b
commit ea4522f877
6 changed files with 118 additions and 4 deletions
+2
View File
@@ -15,6 +15,8 @@ services:
- NODE_ENV=production - NODE_ENV=production
- DATA_DIR=/data - DATA_DIR=/data
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev - ALLOWED_ORIGINS=https://ttrpg.briggen.dev
# comma-separated usernames that can see the admin panel (set to your account)
- ADMIN_USERS=nilsb
volumes: volumes:
- ttrpg-data:/data - ttrpg-data:/data
security_opt: security_opt:
+12
View File
@@ -38,4 +38,16 @@ describe('AccountStore', () => {
await store2.load(); await store2.load();
expect((await store2.login('dave', 'password123')).ok).toBe(true); expect((await store2.login('dave', 'password123')).ok).toBe(true);
}); });
it('gates admin + stores a quota override', async () => {
const adminStore = new AccountStore(dir, undefined, ['boss']);
await adminStore.register('boss', 'password123');
await adminStore.register('peon', 'password123');
expect(adminStore.isAdmin('boss')).toBe(true);
expect(adminStore.isAdmin('Boss')).toBe(true); // case-insensitive
expect(adminStore.isAdmin('peon')).toBe(false);
expect(await adminStore.setQuota('peon', 5_000_000_000)).toBe(true);
expect(await adminStore.setQuota('ghost', 1)).toBe(false);
expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000);
});
}); });
+24 -1
View File
@@ -16,6 +16,7 @@ interface User {
salt: string; salt: string;
hash: string; hash: string;
tokens: string[]; // sha256(token) tokens: string[]; // sha256(token)
quotaBytes?: number; // admin override; 0/undefined = default
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
@@ -38,11 +39,33 @@ export class AccountStore {
private byId = new Map<string, User>(); private byId = new Map<string, User>();
private queue: Promise<unknown> = Promise.resolve(); private queue: Promise<unknown> = Promise.resolve();
private loaded = false; private loaded = false;
constructor(private dir: string, private now: () => number = () => Date.now()) {} constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = []) {}
private usersFile() { return path.join(this.dir, 'users.json'); } private usersFile() { return path.join(this.dir, 'users.json'); }
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); } private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); }
async listUsers(): Promise<Array<{ id: string; username: string; quotaBytes?: number }>> {
await this.load();
return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) }));
}
async setQuota(username: string, bytes: number): Promise<boolean> {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u) return false;
u.quotaBytes = Math.max(0, Math.floor(bytes));
u.updatedAt = this.now();
await this.persist();
return true;
}
/** Size in bytes of a user's stored backup blob (0 if none). */
async blobBytes(id: string): Promise<number> {
try { return (await fs.stat(this.blobFile(id))).size; } catch { return 0; }
}
async load(): Promise<void> { async load(): Promise<void> {
if (this.loaded) return; if (this.loaded) return;
this.loaded = true; this.loaded = true;
+24 -2
View File
@@ -14,6 +14,7 @@ const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), '
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS) const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS)
const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs) const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs)
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean); const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const bearer = (req: FastifyRequest): string | undefined => { const bearer = (req: FastifyRequest): string | undefined => {
const h = req.headers.authorization; const h = req.headers.authorization;
@@ -26,7 +27,7 @@ const RATE = { capacity: 80, refillMs: 10_000 };
export function buildServer() { export function buildServer() {
const app = Fastify({ bodyLimit: BODY_LIMIT }); const app = Fastify({ bodyLimit: BODY_LIMIT });
const hub = new RoomHub(); const hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR); const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS);
void accounts.load(); void accounts.load();
const cloud = new CloudStore(DATA_DIR); const cloud = new CloudStore(DATA_DIR);
void cloud.load(); void cloud.load();
@@ -116,7 +117,28 @@ export function buildServer() {
}); });
app.get('/api/usage', async (req, reply) => { app.get('/api/usage', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
return { bytes: await cloud.usageBytes(u.id) }; const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id));
return { bytes, admin: accounts.isAdmin(u.username) };
});
// ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ----
app.get('/api/admin/users', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
const users = await accounts.listUsers();
const rows = await Promise.all(users.map(async (x) => ({
username: x.username,
usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)),
quotaBytes: x.quotaBytes ?? 0,
})));
return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) };
});
app.post('/api/admin/quota', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
}); });
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
+45 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db'; import { db } from '@/lib/db/db';
import { cloudUsername, CloudError } from '@/lib/cloud/client'; import { cloudUsername, CloudError } from '@/lib/cloud/client';
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns'; import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns';
import { useCampaigns } from '@/features/campaigns/hooks'; import { useCampaigns } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
@@ -21,8 +21,13 @@ export function CloudCampaigns() {
const [targetCloud, setTargetCloud] = useState(''); const [targetCloud, setTargetCloud] = useState('');
const [viewing, setViewing] = useState<string | null>(null); const [viewing, setViewing] = useState<string | null>(null);
const [party, setParty] = useState<CloudCharInfo[]>([]); const [party, setParty] = useState<CloudCharInfo[]>([]);
const [usage, setUsage] = useState<{ bytes: number; admin: boolean } | null>(null);
const [admins, setAdmins] = useState<AdminUserRow[]>([]);
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]);
const loadAdmin = () => adminListUsers().then((r) => setAdmins(r.users)).catch(() => {});
useEffect(() => { if (usage?.admin) loadAdmin(); }, [usage?.admin]);
const refresh = () => listCloudCampaigns().then(setList).catch(() => {}); const refresh = () => listCloudCampaigns().then(setList).catch(() => {});
const run = (fn: () => Promise<void>) => async () => { const run = (fn: () => Promise<void>) => async () => {
@@ -105,8 +110,47 @@ export function CloudCampaigns() {
</div> </div>
</div> </div>
{usage && <p className="text-xs text-muted">Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span></p>}
{usage?.admin && (
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Admin · users &amp; storage</h3>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
<tbody>
{admins.map((u) => (
<tr key={u.username} className="border-t border-line">
<td className="py-1 text-ink">{u.username}</td>
<td className="py-1 text-muted">{fmtBytes(u.usageBytes)}</td>
<td className="py-1"><QuotaCell user={u} onSaved={loadAdmin} /></td>
</tr>
))}
</tbody>
</table>
<p className="mt-1 text-[11px] text-muted">Quotas are tracked but not yet enforced a safety valve for later.</p>
</div>
)}
{msg && <p className="text-sm text-accent">{msg}</p>} {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> <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> </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`;
}
function QuotaCell({ user, onSaved }: { user: AdminUserRow; onSaved: () => void }) {
const [gb, setGb] = useState(String(user.quotaBytes ? (user.quotaBytes / 1e9).toFixed(0) : '0'));
const [saving, setSaving] = useState(false);
const save = async () => { setSaving(true); try { await adminSetQuota(user.username, (Number(gb) || 0) * 1e9); onSaved(); } catch { /* ignore */ } finally { setSaving(false); } };
return (
<span className="flex items-center gap-1">
<input value={gb} onChange={(e) => setGb(e.target.value.replace(/[^0-9]/g, ''))} className="w-12 rounded border border-line bg-surface px-1 py-0.5 text-xs" aria-label={`Quota GB for ${user.username}`} />
<Button size="sm" variant="ghost" disabled={saving} onClick={() => void save()}>Set</Button>
</span>
);
}
+11
View File
@@ -43,3 +43,14 @@ export function listCloudCharacters(campaignId: string): Promise<CloudCharInfo[]
export async function cloudUsageBytes(): Promise<number> { export async function cloudUsageBytes(): Promise<number> {
return (await req<{ bytes: number }>('/usage')).bytes; return (await req<{ bytes: number }>('/usage')).bytes;
} }
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
export function getUsage(): Promise<{ bytes: number; admin: boolean }> {
return req<{ bytes: number; admin: boolean }>('/usage');
}
export function adminListUsers(): Promise<{ users: AdminUserRow[] }> {
return req<{ users: AdminUserRow[] }>('/admin/users');
}
export function adminSetQuota(username: string, quotaBytes: number): Promise<{ ok: boolean }> {
return req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) });
}