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 Tooltip from "./Tooltip"; 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. export default function AdminUsers({ me }: { me: Me }) { const { t } = useTranslation(); const [tab, setTab] = usePersistedTab("siftlode.adminUsersTab", "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 (
{active === "roles" && } {active === "access" && } {active === "demo" && }
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
); } 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 ( {children} ); } 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 (

{t("users.roles.intro")}

{rows.length === 0 ? (

{t("users.roles.empty")}

) : (
{rows.map((u) => { const self = u.id === me.id; return (
{u.display_name ?? u.email.split("@")[0]} {self && · {t("users.roles.you")}}
{u.email}
{u.role === "admin" && {t("users.roles.admin")}} {u.is_demo && {t("users.roles.demo")}} {u.is_suspended && {t("users.roles.suspended")}} {!u.is_active && {t("users.roles.pending")}} {/* 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 && ( {t("users.roles.unverified")} )}
); })}
)}
); } // --- 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: () => { setNewEmail(""); refresh(); notify({ level: "success", message: t("settings.invites.addedToWhitelist") }); }, 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 (
{pending.length === 0 ? (

{t("settings.invites.noPending")}

) : (
{pending.map((i) => ( approve.mutate({ id: i.id, email: i.email })} onDeny={() => deny.mutate(i.id)} busy={approve.isPending || deny.isPending} /> ))}
)}
{ e.preventDefault(); if (newEmail.trim()) add.mutate(newEmail.trim()); }} className="flex items-center gap-2 mt-3" > 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" />
{decided.length > 0 && (
{t("settings.invites.decided", { count: decided.length })}
{decided.map((i) => (
{i.email} {i.status}
))}
)}
); } 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: () => { setNewEmail(""); qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); notify({ level: "success", message: t("settings.demo.added") }); }, 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 (

{t("settings.demo.intro")}

{rows.length > 0 && (
{rows.map((r) => (
{r.email}
{r.added_by && (
{t("settings.demo.addedBy", { who: r.added_by })}
)}
))}
)}
{ e.preventDefault(); if (newEmail.trim()) add.mutate(newEmail.trim()); }} className="flex items-center gap-2" > 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" />
); } function InviteRow({ inv, onApprove, onDeny, busy, }: { inv: Invite; onApprove: () => void; onDeny: () => void; busy: boolean; }) { const { t } = useTranslation(); return (
{inv.email}
{inv.requested_at && (
{t("settings.invites.requested", { time: new Date(inv.requested_at).toLocaleString(), })}
)}
); }