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>
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { motion } from "framer-motion";
|
|
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
|
|
|
|
import { api, type Me, type Track } from "@/lib/api";
|
|
import { tint } from "@/lib/colour";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { Aurora } from "@/components/aurora";
|
|
import { SearchBar } from "@/components/search-bar";
|
|
import { TrackRow } from "@/components/track-row";
|
|
import { AlbumView } from "@/components/album-view";
|
|
import { ArtistView } from "@/components/artist-view";
|
|
import { BulkImport } from "@/components/bulk-import";
|
|
import { Requests, type JobsSnapshot } from "@/components/requests";
|
|
import { Skeletons } from "@/components/skeletons";
|
|
import { PlayerProvider } from "@/components/player";
|
|
import { ToastProvider } from "@/components/toast";
|
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
|
|
|
type Tab = "search" | "jobs" | "bulk" | "admin";
|
|
type View =
|
|
| { k: "welcome" }
|
|
| { k: "results"; q: string }
|
|
| { k: "album"; id: number }
|
|
| { k: "artist"; id: number };
|
|
|
|
const EXAMPLES = [
|
|
"Radiohead Creep",
|
|
"Kendrick Lamar Money Trees",
|
|
"Fleetwood Mac Dreams",
|
|
"Tame Impala The Less I Know The Better",
|
|
"Frank Ocean Ivy",
|
|
];
|
|
|
|
export default function Page() {
|
|
return (
|
|
<ToastProvider>
|
|
<PlayerProvider>
|
|
<Muse />
|
|
</PlayerProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|
|
|
|
function Muse() {
|
|
const [tab, setTab] = React.useState<Tab>("search");
|
|
const [view, setView] = React.useState<View>({ k: "welcome" });
|
|
const [history, setHistory] = React.useState<View[]>([]);
|
|
const [results, setResults] = React.useState<Track[] | null>(null);
|
|
const [busy, setBusy] = React.useState(false);
|
|
const [me, setMe] = React.useState<Me | null>(null);
|
|
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
|
|
|
const go = React.useCallback((next: View) => {
|
|
setView((cur) => {
|
|
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
|
|
return next;
|
|
});
|
|
setTab("search");
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}, []);
|
|
|
|
const back = () => {
|
|
setHistory((h) => {
|
|
const prev = h[h.length - 1];
|
|
if (prev) setView(prev);
|
|
return h.slice(0, -1);
|
|
});
|
|
};
|
|
|
|
const doSearch = React.useCallback(
|
|
async (q: string) => {
|
|
if (!q.trim()) return;
|
|
go({ k: "results", q });
|
|
setResults(null);
|
|
setBusy(true);
|
|
try {
|
|
const r = await api.search(q);
|
|
setResults(r.results);
|
|
if (r.results[0]) void tint(r.results[0].cover);
|
|
} catch {
|
|
setResults([]);
|
|
}
|
|
setBusy(false);
|
|
},
|
|
[go],
|
|
);
|
|
|
|
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
|
|
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
|
|
|
|
const refresh = React.useCallback(async () => {
|
|
try {
|
|
setSnap(await api.jobs());
|
|
} catch {
|
|
/* a 401 here just means the poll outlived the session */
|
|
}
|
|
// Refetched alongside jobs rather than once at mount so the quota chip
|
|
// counts down as you queue, and so a role change or a disable lands within
|
|
// one poll instead of at next sign-in.
|
|
try {
|
|
setMe(await api.me());
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
void refresh();
|
|
const t = setInterval(refresh, 4000);
|
|
return () => clearInterval(t);
|
|
}, [refresh]);
|
|
|
|
const active =
|
|
(snap?.counts.queued ?? 0) +
|
|
(snap?.counts.searching ?? 0) +
|
|
(snap?.counts.downloading ?? 0);
|
|
|
|
const isAdmin = me?.role === "admin";
|
|
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
|
// is noise on a single-user install, which is most of them.
|
|
const quotaLeft =
|
|
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
|
|
|
return (
|
|
<>
|
|
<Aurora />
|
|
|
|
<header
|
|
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
|
|
backdrop-blur-[22px] backdrop-saturate-[1.7]"
|
|
>
|
|
<div className="mx-auto w-full max-w-[940px] px-4">
|
|
<div className="mb-3.5 flex items-center gap-3">
|
|
<motion.div
|
|
whileHover={{ rotate: 12, scale: 1.06 }}
|
|
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
|
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
|
|
border border-accent-line bg-accent-soft text-accent"
|
|
>
|
|
<Music2 className="h-5 w-5" />
|
|
</motion.div>
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className="text-[19px] font-bold leading-tight tracking-[-0.4px]">muse</h1>
|
|
<p className="text-xs text-dim">search · request · it appears in Navidrome</p>
|
|
</div>
|
|
{me?.user && (
|
|
<div className="flex items-center gap-2 text-[12.5px] text-dim">
|
|
{quotaLeft !== null && (
|
|
<span
|
|
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
|
className={cn(
|
|
"hidden rounded-full border px-2 py-0.5 text-[11px] sm:inline",
|
|
quotaLeft === 0
|
|
? "border-err/50 text-err"
|
|
: quotaLeft <= 5
|
|
? "border-warn/50 text-warn"
|
|
: "border-edge text-dim",
|
|
)}
|
|
>
|
|
{quotaLeft} left
|
|
</span>
|
|
)}
|
|
{me.avatar && (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img
|
|
src={me.avatar}
|
|
alt=""
|
|
className="h-6 w-6 rounded-full border border-edge"
|
|
/>
|
|
)}
|
|
<span className="hidden font-medium text-ink sm:inline">
|
|
{me.display || me.user}
|
|
</span>
|
|
{me.role && me.role !== "user" && (
|
|
<span
|
|
className="rounded-full border border-accent-line bg-accent-soft px-1.5
|
|
py-0.5 text-[10.5px] uppercase tracking-[0.6px] text-accent"
|
|
>
|
|
{me.role}
|
|
</span>
|
|
)}
|
|
<a
|
|
href="/auth/logout"
|
|
className="border-b border-transparent hover:border-edge-hi hover:text-ink"
|
|
>
|
|
Log out
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
|
|
|
<nav className="flex overflow-x-auto pt-3 no-scrollbar">
|
|
{(
|
|
[
|
|
["search", "Search"],
|
|
["jobs", "Requests"],
|
|
["bulk", "Import list"],
|
|
...(isAdmin ? ([["admin", "Admin"]] as [Tab, string][]) : []),
|
|
] as [Tab, string][]
|
|
).map(([k, label]) => (
|
|
<button
|
|
key={k}
|
|
onClick={() => setTab(k)}
|
|
className={cn(
|
|
"relative mr-4 cursor-pointer px-1 pb-3 pt-2 text-[13.5px] font-medium transition-colors",
|
|
tab === k ? "text-ink" : "text-dim hover:text-ink",
|
|
)}
|
|
>
|
|
{label}
|
|
{k === "jobs" && active > 0 && (
|
|
<span
|
|
className="ml-1.5 inline-block min-w-[18px] rounded-full border
|
|
border-accent-line bg-accent-soft px-1.5 text-[11px]
|
|
font-bold text-accent"
|
|
>
|
|
{active}
|
|
</span>
|
|
)}
|
|
{tab === k && (
|
|
<motion.i
|
|
layoutId="tab-underline"
|
|
className="absolute inset-x-0 bottom-0 h-0.5 rounded-t bg-accent"
|
|
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
|
/>
|
|
)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
|
|
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
|
|
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
|
|
the incoming panel until the outgoing one's exit animation reports
|
|
finished, which makes navigation depend on an animation completing.
|
|
Anything that stalls the frame loop — a backgrounded tab, a browser
|
|
that throttles rAF — then leaves the UI stuck on the old panel with
|
|
no way forward. A cosmetic transition should never gate navigation. */}
|
|
<motion.div
|
|
key={tab}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.22 }}
|
|
>
|
|
{tab === "search" && (
|
|
<>
|
|
{history.length > 0 && (
|
|
<motion.button
|
|
initial={{ opacity: 0, x: -8 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
onClick={back}
|
|
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
|
|
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
|
</motion.button>
|
|
)}
|
|
|
|
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
|
|
|
{view.k === "results" &&
|
|
(results === null ? (
|
|
<Skeletons />
|
|
) : results.length === 0 ? (
|
|
<div className="py-12 text-center text-dim">
|
|
No matches. Try adding the artist name.
|
|
</div>
|
|
) : (
|
|
<motion.div layout className="flex flex-col gap-[7px]">
|
|
{results.map((t, i) => (
|
|
<TrackRow
|
|
key={t.deezer_id}
|
|
track={t}
|
|
index={i}
|
|
onAlbum={openAlbum}
|
|
onArtist={openArtist}
|
|
onQueued={refresh}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
))}
|
|
|
|
{view.k === "album" && (
|
|
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
|
)}
|
|
{view.k === "artist" && (
|
|
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
|
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
|
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
|
</motion.div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Welcome({ onPick }: { onPick: (q: string) => void }) {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
|
className="px-5 py-14 text-center"
|
|
>
|
|
<motion.div
|
|
animate={{ y: [0, -7, 0] }}
|
|
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
|
|
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
|
|
border border-accent-line bg-accent-soft text-accent"
|
|
>
|
|
<Disc3 className="h-7 w-7" />
|
|
</motion.div>
|
|
<h2 className="mb-2 text-[23px] font-bold tracking-[-0.4px]">Find anything</h2>
|
|
<p className="mx-auto max-w-[46ch] text-sm text-dim">
|
|
Search a song and press{" "}
|
|
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-[11.5px] text-ink">
|
|
Enter
|
|
</kbd>
|
|
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
|
|
your library, tagged.
|
|
</p>
|
|
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
|
{EXAMPLES.map((e, i) => (
|
|
<motion.button
|
|
key={e}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.1 + i * 0.05 }}
|
|
whileHover={{ y: -2 }}
|
|
onClick={() => onPick(e)}
|
|
className="glass cursor-pointer rounded-full px-3.5 py-1.5 text-[13px] text-dim
|
|
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
|
|
>
|
|
{e}
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|