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:
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
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, 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 { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
@@ -21,8 +21,13 @@ export function CloudCampaigns() {
|
||||
const [targetCloud, setTargetCloud] = useState('');
|
||||
const [viewing, setViewing] = useState<string | null>(null);
|
||||
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) 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 run = (fn: () => Promise<void>) => async () => {
|
||||
@@ -105,8 +110,47 @@ export function CloudCampaigns() {
|
||||
</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 & 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>}
|
||||
<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`;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user