components/ui/form.tsx replaces the per-panel copies: - Switch — was duplicated in SettingsPanel (Switch) + ConfigPanel (Toggle) + Sidebar (inline labeled toggle). - Section (with a card variant) — was in SettingsPanel + Stats (plain) and AdminUsers (glass card). - SettingRow + HintLabel — the label+hint+control row was identical in SettingsPanel + Stats. No visual change intended (Sidebar's toggle gains the standard knob shadow).
479 lines
19 KiB
TypeScript
479 lines
19 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
|
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
|
import { notify } from "../lib/notifications";
|
|
import { LS } from "../lib/storage";
|
|
import Tooltip from "./Tooltip";
|
|
import { Section } from "./ui/form";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import Tabs, { usePersistedTab } from "./Tabs";
|
|
|
|
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
|
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
|
// carries a badge with the count of pending requests so it's visible without switching to it.
|
|
// The persisted-tab storage key + the access-requests tab id, exported so a notification's
|
|
// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key
|
|
// on mount, and this page mounts fresh on navigation).
|
|
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
|
|
export const ADMIN_USERS_ACCESS_TAB = "access";
|
|
|
|
export function focusAccessRequestsTab(): void {
|
|
localStorage.setItem(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
|
|
}
|
|
|
|
export default function AdminUsers({ me }: { me: Me }) {
|
|
const { t } = useTranslation();
|
|
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
|
|
const tabs = [
|
|
{ id: "roles", label: t("users.tabs.roles") },
|
|
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
|
|
{ id: "demo", label: t("users.tabs.demo") },
|
|
];
|
|
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
|
return (
|
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
|
<Tabs tabs={tabs} active={active} onChange={setTab} />
|
|
{active === "roles" && <UsersRoles me={me} />}
|
|
{active === "access" && <AdminInvites />}
|
|
{active === "demo" && <AdminDemo />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
|
|
const cls =
|
|
tone === "accent"
|
|
? "border-accent/40 text-accent"
|
|
: tone === "warning"
|
|
? "border-amber-500/40 text-amber-500"
|
|
: "border-border text-muted";
|
|
return (
|
|
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
|
|
);
|
|
}
|
|
|
|
function UsersRoles({ me }: { me: Me }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
|
|
const setRole = useMutation({
|
|
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
|
|
api.setUserRole(id, role),
|
|
onSuccess: (_d, vars) => {
|
|
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
|
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
|
|
},
|
|
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
|
|
});
|
|
|
|
const suspend = useMutation({
|
|
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
|
|
api.setUserSuspended(id, suspended),
|
|
onSuccess: (_d, vars) => {
|
|
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
|
notify({
|
|
level: "success",
|
|
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
|
|
email: vars.email,
|
|
}),
|
|
});
|
|
},
|
|
// Guards (demo / self / last admin) surface via the global error dialog.
|
|
});
|
|
const del = useMutation({
|
|
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
|
|
onSuccess: (_d, vars) => {
|
|
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
|
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
|
},
|
|
});
|
|
|
|
const onToggle = async (u: AdminUserRow) => {
|
|
const makeAdmin = u.role !== "admin";
|
|
const ok = await confirm({
|
|
title: makeAdmin ? t("users.roles.promoteTitle") : t("users.roles.demoteTitle"),
|
|
message: t(makeAdmin ? "users.roles.promoteConfirm" : "users.roles.demoteConfirm", {
|
|
email: u.email,
|
|
}),
|
|
confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"),
|
|
danger: !makeAdmin,
|
|
});
|
|
if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user", email: u.email });
|
|
};
|
|
|
|
const onSuspend = async (u: AdminUserRow) => {
|
|
const willSuspend = !u.is_suspended;
|
|
const ok = await confirm({
|
|
title: t(willSuspend ? "users.suspend.title" : "users.suspend.undoTitle"),
|
|
message: t(willSuspend ? "users.suspend.confirm" : "users.suspend.undoConfirm", {
|
|
email: u.email,
|
|
}),
|
|
confirmLabel: t(willSuspend ? "users.suspend.action" : "users.suspend.undoAction"),
|
|
danger: willSuspend,
|
|
});
|
|
if (ok) suspend.mutate({ id: u.id, suspended: willSuspend, email: u.email });
|
|
};
|
|
|
|
const onDelete = async (u: AdminUserRow) => {
|
|
const ok = await confirm({
|
|
title: t("users.delete.title"),
|
|
message: t("users.delete.confirm", { email: u.email }),
|
|
confirmLabel: t("users.delete.action"),
|
|
danger: true,
|
|
});
|
|
if (ok) del.mutate({ id: u.id, email: u.email });
|
|
};
|
|
|
|
const rows = q.data ?? [];
|
|
|
|
return (
|
|
<Section card title={t("users.roles.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
|
{rows.length === 0 ? (
|
|
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{rows.map((u) => {
|
|
const self = u.id === me.id;
|
|
return (
|
|
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm truncate">
|
|
{u.display_name ?? u.email.split("@")[0]}
|
|
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
|
</div>
|
|
<div className="text-[11px] text-muted truncate">{u.email}</div>
|
|
</div>
|
|
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
|
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
|
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
|
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
|
|
{/* A Google sign-in proves the email, so "unverified" only applies to a
|
|
password-only account that hasn't clicked its verification link. */}
|
|
{u.is_active && !u.email_verified && !u.has_google && (
|
|
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
|
|
)}
|
|
<Tooltip
|
|
hint={
|
|
u.is_demo
|
|
? t("users.roles.demoLocked")
|
|
: self
|
|
? t("users.roles.selfLocked")
|
|
: u.role === "admin"
|
|
? t("users.roles.makeUser")
|
|
: t("users.roles.makeAdmin")
|
|
}
|
|
>
|
|
<button
|
|
onClick={() => onToggle(u)}
|
|
disabled={u.is_demo || self || setRole.isPending}
|
|
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
|
|
>
|
|
<Shield className="w-3.5 h-3.5" />
|
|
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip
|
|
hint={
|
|
u.is_demo
|
|
? t("users.suspend.demoLocked")
|
|
: self
|
|
? t("users.suspend.selfLocked")
|
|
: u.is_suspended
|
|
? t("users.suspend.undoAction")
|
|
: t("users.suspend.action")
|
|
}
|
|
>
|
|
<button
|
|
onClick={() => onSuspend(u)}
|
|
disabled={u.is_demo || self || suspend.isPending}
|
|
aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")}
|
|
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
|
u.is_suspended
|
|
? "border-amber-500/40 text-amber-500 hover:bg-amber-500/10"
|
|
: "border-border text-muted hover:text-amber-500"
|
|
}`}
|
|
>
|
|
{u.is_suspended ? <RotateCcw className="w-4 h-4" /> : <Ban className="w-4 h-4" />}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
|
|
<button
|
|
onClick={() => onDelete(u)}
|
|
disabled={u.is_demo || self || del.isPending}
|
|
aria-label={t("users.delete.action")}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
|
|
|
|
function AdminInvites() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
|
|
const [newEmail, setNewEmail] = useState("");
|
|
const refresh = () => {
|
|
qc.invalidateQueries({ queryKey: ["admin-invites"] });
|
|
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
|
|
};
|
|
const approve = useMutation({
|
|
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
|
|
onSuccess: (_d, vars) => {
|
|
refresh();
|
|
notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
|
|
});
|
|
const deny = useMutation({
|
|
mutationFn: (id: number) => api.denyInvite(id),
|
|
onSuccess: refresh,
|
|
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
|
|
});
|
|
const add = useMutation({
|
|
mutationFn: (email: string) => api.addInvite(email),
|
|
onSuccess: (_d, email) => {
|
|
setNewEmail("");
|
|
refresh();
|
|
notify({ level: "success", message: t("settings.invites.addedToWhitelist", { email }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
|
|
});
|
|
|
|
const list = invites.data ?? [];
|
|
const pending = list.filter((i) => i.status === "pending");
|
|
const decided = list.filter((i) => i.status !== "pending");
|
|
|
|
return (
|
|
<Section card title={t("settings.invites.title")}>
|
|
{pending.length === 0 ? (
|
|
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{pending.map((i) => (
|
|
<InviteRow
|
|
key={i.id}
|
|
inv={i}
|
|
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
|
|
onDeny={() => deny.mutate(i.id)}
|
|
busy={approve.isPending || deny.isPending}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (newEmail.trim()) add.mutate(newEmail.trim());
|
|
}}
|
|
className="flex items-center gap-2 mt-3"
|
|
>
|
|
<input
|
|
type="email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
placeholder={t("settings.invites.addPlaceholder")}
|
|
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
|
>
|
|
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
|
</button>
|
|
</form>
|
|
|
|
{decided.length > 0 && (
|
|
<details className="mt-3">
|
|
<summary className="text-xs text-muted cursor-pointer">
|
|
{t("settings.invites.decided", { count: decided.length })}
|
|
</summary>
|
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
|
{decided.map((i) => (
|
|
<div key={i.id} className="flex items-center justify-between gap-2">
|
|
<span className="truncate">{i.email}</span>
|
|
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
|
|
{i.status}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function AdminDemo() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() });
|
|
const [newEmail, setNewEmail] = useState("");
|
|
|
|
const add = useMutation({
|
|
mutationFn: (email: string) => api.addDemoWhitelist(email),
|
|
onSuccess: (_d, email) => {
|
|
setNewEmail("");
|
|
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
|
|
notify({ level: "success", message: t("settings.demo.added", { email }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
|
|
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
|
|
});
|
|
const reset = useMutation({
|
|
mutationFn: () => api.resetDemo(),
|
|
onSuccess: (r: { playlists_seeded: number }) =>
|
|
notify({
|
|
level: "success",
|
|
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
|
|
}),
|
|
onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
|
|
});
|
|
|
|
const rows = list.data ?? [];
|
|
|
|
return (
|
|
<Section card title={t("settings.demo.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
|
|
|
{rows.length > 0 && (
|
|
<div className="space-y-1.5 mb-3">
|
|
{rows.map((r) => (
|
|
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm truncate">{r.email}</div>
|
|
{r.added_by && (
|
|
<div className="text-[11px] text-muted truncate">
|
|
{t("settings.demo.addedBy", { who: r.added_by })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Tooltip hint={t("settings.demo.removeHint")}>
|
|
<button
|
|
onClick={() => remove.mutate(r.id)}
|
|
disabled={remove.isPending}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
|
aria-label={t("settings.demo.remove")}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (newEmail.trim()) add.mutate(newEmail.trim());
|
|
}}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<input
|
|
type="email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
placeholder={t("settings.demo.addPlaceholder")}
|
|
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
|
>
|
|
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="mt-4 pt-3 border-t border-border">
|
|
<Tooltip hint={t("settings.demo.resetHint")}>
|
|
<button
|
|
onClick={async () => {
|
|
if (
|
|
await confirm({
|
|
title: t("settings.demo.resetTitle"),
|
|
message: t("settings.demo.resetConfirm"),
|
|
confirmLabel: t("settings.demo.reset"),
|
|
danger: true,
|
|
})
|
|
)
|
|
reset.mutate();
|
|
}}
|
|
disabled={reset.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
|
{t("settings.demo.reset")}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function InviteRow({
|
|
inv,
|
|
onApprove,
|
|
onDeny,
|
|
busy,
|
|
}: {
|
|
inv: Invite;
|
|
onApprove: () => void;
|
|
onDeny: () => void;
|
|
busy: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm truncate">{inv.email}</div>
|
|
{inv.requested_at && (
|
|
<div className="text-[11px] text-muted">
|
|
{t("settings.invites.requested", {
|
|
time: new Date(inv.requested_at).toLocaleString(),
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Tooltip hint={t("settings.invites.approveHint")}>
|
|
<button
|
|
onClick={onApprove}
|
|
disabled={busy}
|
|
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
|
aria-label={t("settings.invites.approve")}
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip hint={t("settings.invites.denyHint")}>
|
|
<button
|
|
onClick={onDeny}
|
|
disabled={busy}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
|
aria-label={t("settings.invites.deny")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|