Add multi-user: Google OAuth, roles, quotas, admin panel

Phase 2. The schema landed last commit; this makes it real.

AUTH is now multi-provider. MUSE_AUTH takes a comma list, so github,google
runs both. The callback URL is unchanged — /auth/callback for every provider,
with the provider carried in the state COOKIE rather than the query string —
so adding Google needs no edit to an already-registered GitHub app, and the
callback can't be steered to a different provider by the redirect. With more
than one enabled, /auth/login shows a chooser built from style attributes
only, which the CSP already permits, so it needs no stylesheet and no extra
hash.

Google identities are the VERIFIED email. An unverified one is refused: anyone
can put someone else's address on an account they control, and the allowlist is
keyed on exactly that string.

WHO MAY LOG IN is now two rules — the MUSE_ALLOWED_USERS bootstrap list, then
a non-disabled users row. Inviting is adding a row; no email, no token, no
expiry to manage. MUSE_ALLOWED_USERS stays mandatory only while the table is
empty, so a working multi-user install isn't held hostage by an env var it has
outgrown, but an empty database with no allowlist still refuses to start
because nobody could ever get in. The rule is one function used at login AND
on every request: checking only at sign-in isn't revocation, it's a delay.

QUOTAS are requests per calendar month and bytes of imported audio, 0 =
unlimited. Calendar month, not a rolling window — "12 left until the 1st" is
something a person can plan around. Batches are checked against the whole
batch, so a 30-track album with 5 requests left is refused up front rather
than accepted and abandoned halfway. Storage is measured from the file
actually written, not the size the peer advertised. Admins are exempt.

ADMIN PANEL: host CPU/memory/disk read straight from /proc — no psutil, no
compiled dependency, and in a container /proc reports the host, which is what
you want for a homelab box. Plus library totals, slskd connection state, a
30-day chart, per-user and per-artist breakdowns, and inline role/quota
editing. The chart is hand-rolled SVG: a charting library would add ~150KB for
one stacked bar chart and most of them inject <style> at runtime, which this
app's CSP blocks.

Guard rails: the last active admin cannot be demoted, disabled or deleted;
disabling revokes sessions immediately; deleting a user keeps their jobs,
because the history records what is in the library and why. Requests are
private by default — users see their own, admins see all — and retrying
someone else's job returns 404 rather than 403, since a distinct error would
confirm the id exists.

Also drops AnimatePresence mode="wait" from the tab container. It gates the
incoming panel on the outgoing one's exit animation reporting finished, so
anything that stalls the frame loop leaves the UI stuck on the old tab with no
way forward — found while testing, where a backgrounded page froze navigation
entirely. A cosmetic transition should never gate navigation.

Verified: admin endpoints 200/403/403/401 for admin/user/guest/anon; last-admin
demote, disable and delete all 409; disable revokes mid-session; quota refuses
the 3rd of 2 and a 30-track batch, then succeeds once raised; users see only
their own jobs; cross-user retry 404s; bad role/provider/duplicate/empty patch
all rejected; multi-provider chooser and both redirects correct; three CSRF
variants 400; preflight refuses unknown providers, missing secrets, and an
empty database with no allowlist. CPU, memory and disk confirmed populated
inside the linux/amd64 container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-04 20:54:12 -05:00
1 parent 3b4b68daaf
commit f3c64d4fd0
8 files changed
+1642 -171

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Ban, Check, Plus, ShieldCheck, Trash2, Undo2, X } from "lucide-react";
import { api, bytes, type AdminUser } from "@/lib/api";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function UsersTable({
users,
roles,
me,
refresh,
}: {
users: AdminUser[];
roles: string[];
me: string | null;
refresh: () => void;
}) {
const [adding, setAdding] = React.useState(false);
return (
<div className="glass rounded-panel p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
Users
</h3>
<p className="mt-1 text-[12.5px] text-dim">
Adding someone here <em>is</em> the invitation — no email, no token. They sign in
with that provider and they&rsquo;re in.
</p>
</div>
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{adding ? "Cancel" : "Invite"}
</Button>
</div>
<AnimatePresence>
{adding && (
<InviteForm
roles={roles}
onDone={() => {
setAdding(false);
refresh();
}}
/>
)}
</AnimatePresence>
<div className="mt-3 flex flex-col gap-2">
<AnimatePresence initial={false}>
{users.map((u) => (
<UserRow key={u.username} u={u} roles={roles} isMe={u.username === me} refresh={refresh} />
))}
</AnimatePresence>
</div>
</div>
);
}
function UserRow({
u,
roles,
isMe,
refresh,
}: {
u: AdminUser;
roles: string[];
isMe: boolean;
refresh: () => void;
}) {
const toast = useToast();
const [busy, setBusy] = React.useState(false);
const [confirmDel, setConfirmDel] = React.useState(false);
const run = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try {
await fn();
toast(ok);
refresh();
} catch (e) {
toast((e as Error).message, true);
}
setBusy(false);
};
const quotaPct = u.quota ? (u.usage.requests / u.quota) * 100 : null;
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ type: "spring", stiffness: 360, damping: 32 }}
className={cn(
"glass-hi rounded-glass p-3",
u.disabled && "opacity-60",
)}
>
<div className="flex flex-wrap items-center gap-3">
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-full bg-faint">
{u.avatar && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={u.avatar}
alt=""
className="h-full w-full object-cover"
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[14px] font-semibold clamp1">
{u.display || u.username}
{u.role === "admin" && <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-accent" />}
{isMe && <span className="text-[11px] font-normal text-dim">(you)</span>}
</div>
<div className="text-[12px] text-dim clamp1">
{u.username} · {u.provider}
{u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`}
{u.disabled && " · disabled"}
</div>
</div>
<select
value={u.role}
disabled={busy}
onChange={(e) => run(() => api.admin.patchUser(u.username, { role: e.target.value }), `${u.username} is now ${e.target.value}`)}
aria-label={`Role for ${u.username}`}
className="shrink-0 cursor-pointer rounded-full border border-edge-hi bg-transparent
px-2.5 py-1 text-[12px] text-ink outline-none
focus:border-accent-line"
>
{roles.map((r) => (
<option key={r} value={r} className="bg-[#12141c]">
{r}
</option>
))}
</select>
<Button
size="sm"
variant="quiet"
disabled={busy}
onClick={() =>
run(
() => api.admin.patchUser(u.username, { disabled: !u.disabled }),
u.disabled ? `${u.username} re-enabled` : `${u.username} disabled`,
)
}
title={u.disabled ? "Re-enable" : "Disable and revoke sessions"}
>
{u.disabled ? <Undo2 className="h-3.5 w-3.5" /> : <Ban className="h-3.5 w-3.5" />}
</Button>
{confirmDel ? (
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="quiet"
disabled={busy}
className="border-err/50 text-err"
onClick={() => run(() => api.admin.deleteUser(u.username), `${u.username} deleted`)}
>
<Check className="h-3.5 w-3.5" /> Delete
</Button>
<Button size="sm" variant="quiet" onClick={() => setConfirmDel(false)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<Button
size="sm"
variant="quiet"
disabled={busy}
onClick={() => setConfirmDel(true)}
title="Delete user (their request history is kept)"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-[12px]">
<QuotaField
label="Requests / month"
value={u.quota}
suffix={`used ${u.usage.requests}`}
pct={quotaPct}
onSave={(v) => run(() => api.admin.patchUser(u.username, { quota: v }), "Quota updated")}
/>
<QuotaField
label="Storage (GB)"
value={+(u.storage_quota / 2 ** 30).toFixed(1)}
suffix={`used ${bytes(u.usage.storage)}`}
pct={u.storage_quota ? (u.usage.storage / u.storage_quota) * 100 : null}
onSave={(v) =>
run(() => api.admin.patchUser(u.username, { storage_quota_gb: v }), "Quota updated")
}
/>
{u.bootstrap && (
<span className="text-[11.5px] text-warn" title="Listed in MUSE_ALLOWED_USERS">
bootstrap account — disabling won&rsquo;t lock them out
</span>
)}
</div>
</motion.div>
);
}
/** Number input that only commits on blur or Enter, so a quota isn't PATCHed
* once per keystroke while you type "120". */
function QuotaField({
label,
value,
suffix,
pct,
onSave,
}: {
label: string;
value: number;
suffix: string;
pct: number | null;
onSave: (v: number) => void;
}) {
const [v, setV] = React.useState(String(value));
React.useEffect(() => setV(String(value)), [value]);
const commit = () => {
const n = Number(v);
if (!Number.isFinite(n) || n < 0) {
setV(String(value));
return;
}
if (n !== value) onSave(n);
};
return (
<label className="flex items-center gap-2">
<span className="text-dim">{label}</span>
<input
type="number"
min={0}
step="any"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="w-16 rounded-md border border-edge-hi bg-transparent px-1.5 py-0.5
text-right text-ink outline-none focus:border-accent-line"
/>
<span className={cn("text-dim", pct != null && pct >= 90 && "text-err")}>
{Number(v) === 0 ? "unlimited" : suffix}
</span>
</label>
);
}
function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) {
const toast = useToast();
const [username, setUsername] = React.useState("");
const [provider, setProvider] = React.useState("github");
const [role, setRole] = React.useState("user");
const [quota, setQuota] = React.useState("0");
const [storage, setStorage] = React.useState("0");
const [busy, setBusy] = React.useState(false);
const submit = async () => {
if (!username.trim()) {
toast("Username required", true);
return;
}
setBusy(true);
try {
await api.admin.addUser({
username: username.trim(),
provider,
role,
quota: Number(quota) || 0,
storage_quota_gb: Number(storage) || 0,
});
toast(`Invited ${username.trim()}`);
onDone();
} catch (e) {
toast((e as Error).message, true);
}
setBusy(false);
};
const input =
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-[13px] " +
"text-ink outline-none focus:border-accent-line";
return (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
<span className="text-[11.5px] text-dim">
{provider === "github" ? "GitHub username" : "Google email"}
</span>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder={provider === "github" ? "octocat" : "friend@gmail.com"}
className={input}
autoFocus
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11.5px] text-dim">Provider</span>
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}>
<option value="github" className="bg-[#12141c]">GitHub</option>
<option value="google" className="bg-[#12141c]">Google</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11.5px] text-dim">Role</span>
<select value={role} onChange={(e) => setRole(e.target.value)} className={cn(input, "cursor-pointer")}>
{roles.map((r) => (
<option key={r} value={r} className="bg-[#12141c]">
{r}
</option>
))}
</select>
</label>
<label className="flex w-24 flex-col gap-1">
<span className="text-[11.5px] text-dim">Req/month</span>
<input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} className={input} />
</label>
<label className="flex w-24 flex-col gap-1">
<span className="text-[11.5px] text-dim">Storage GB</span>
<input type="number" min={0} value={storage} onChange={(e) => setStorage(e.target.value)} className={input} />
</label>
<Button onClick={submit} disabled={busy}>
{busy ? "Inviting…" : "Invite"}
</Button>
<span className="w-full text-[11.5px] text-dim">0 means unlimited.</span>
</div>
</motion.div>
);
}