Files
muse/web/components/admin/users-table.tsx
Aryan SinghandClaude Opus 5 e1b7b69261 Recover blocked searches, fall through to the next peer, rebuild the UI
Measured against a live instance: 47 failed jobs against 33 imported. Three
independent causes, all of which ended the request.

The Soulseek server filters some artist names. "Michael Jackson Billie Jean"
and bare "Michael Jackson" both return zero responses; "Billie Jean" returns
1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since
pick_and_enqueue sent exactly one query of `artist title`, every request for a
filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences.
search_queries() now walks a ladder, loosest last, ending at the bare title,
and escalates only when a search underdelivers so the common case still costs
one search. Dropping the artist from the query does not mean ignoring it:
match_ratio() looks for it in the folder path, where an organised share puts it.

A rejected transfer ended the request. pick_and_enqueue kept a running maximum
and discarded every runner-up, so "Transfer rejected: File not shared." -- a
per-file condition where candidate two almost always works -- was terminal, as
were timeouts and slskd's 500 on an unreachable peer. The ranked list is now
persisted on the job row, ordered to put a different peer next, and
try_next_candidate() advances through it. A job fails only once the list is
exhausted, and then reports every peer it tried and what each one said.

norm() destroyed every non-ASCII character, so "Beyonce" never matched
Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops
apostrophes while leaving & as a separator.

Also: availability outranks format in score(), because a FLAC behind 5000
queued users used to beat an immediately available MP3; transfers that stall or
that slskd forgets are abandoned instead of sitting in `downloading` forever;
searches are deleted only once finished, since deleting one mid-flight makes
slskd throw an optimistic-concurrency error and lose the record; a failed
search POST is retried rather than failing the job on one blip.

The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with
neutral chrome and the sampled artwork colour confined to hero areas rather
than repainting the whole interface. Adds URL routing, a primitive set under
components/ui, and a request sheet showing the attempt ladder. Fixes
hover:bg-glass-hi, which was never a valid class, so track rows and the search
field had no hover or focus state at all; a reduced-motion path that could
strand elements at opacity 0; the login page's hardcoded palette; and the
missing 404 and error boundaries.

tests/test_acquisition.py covers this path against a faked slskd. It had none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:33:52 -05:00

400 lines
13 KiB
TypeScript

"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Ban, Check, Plus, Send, 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="card rounded-panel p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
Users
</h3>
<p className="mt-1 max-w-[62ch] text-callout text-dim">
Adding someone here <em>is</em> the authorisation, and it binds them to that
one provider. If SMTP is set up they also get an email; the link in it
carries no token, because possession of the mail isn&rsquo;t what lets
anyone in — the row is.
</p>
</div>
<Button size="pill" variant={adding ? "secondary" : "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(
"card rounded-card 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-body 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-caption font-normal text-dim">(you)</span>}
</div>
<div className="text-caption 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-caption text-ink outline-none
focus:border-accent-line"
>
{roles.map((r) => (
<option key={r} value={r} className="bg-[#12141c]">
{r}
</option>
))}
</select>
{/* Never signed in and we have somewhere to write: the likeliest
reason is they missed the mail, so offer it where that is visible.
Resending is harmless — the link carries no token. */}
{u.email && !u.last_seen && (
<Button
size="sm"
variant="quiet"
disabled={busy}
onClick={() =>
run(() => api.admin.resend(u.username), `Invite resent to ${u.email}`)
}
title={`Resend the invitation to ${u.email}`}
>
<Send className="h-3.5 w-3.5" />
</Button>
)}
<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-caption">
<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-caption 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("");
// Google first: it is the invite path for everyone who is not the owner,
// and its username is already the address to write to.
const [provider, setProvider] = React.useState("google");
const [role, setRole] = React.useState("user");
const [quota, setQuota] = React.useState("0");
const [storage, setStorage] = React.useState("0");
const [email, setEmail] = React.useState("");
const [busy, setBusy] = React.useState(false);
const submit = async () => {
if (!username.trim()) {
toast("Username required", true);
return;
}
setBusy(true);
try {
const r = await api.admin.addUser({
username: username.trim(),
provider,
role,
quota: Number(quota) || 0,
storage_quota_gb: Number(storage) || 0,
...(provider === "github" && email.trim() ? { email: email.trim() } : {}),
});
// Say whether the mail actually went. "Invited" alone would read as
// success while the person waits for an email that was never sent.
toast(
r.invite_sent
? `Invited ${username.trim()} — email sent`
: `Invited ${username.trim()} — no email (${r.invite_why})`,
);
onDone();
} catch (e) {
toast((e as Error).message, true);
}
setBusy(false);
};
const input =
"rounded-card border border-edge-hi bg-transparent px-3 py-2 text-callout " +
"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="card mt-1 flex flex-wrap items-end gap-2.5 rounded-card p-3">
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
<span className="text-caption 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-caption text-dim">Provider</span>
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}>
<option value="google" className="bg-[#12141c]">Google</option>
<option value="github" className="bg-[#12141c]">GitHub</option>
</select>
</label>
{/* Only GitHub needs this asked. A Google invite already is an
address, and a second field for the same value invites them to
disagree. */}
{provider === "github" && (
<label className="flex min-w-[190px] flex-col gap-1">
<span className="text-caption text-dim">Email them at (optional)</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="octocat@example.com"
className={input}
/>
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-caption 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-caption 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-caption 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-caption text-dim">0 means unlimited.</span>
</div>
</motion.div>
);
}