import { useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Check, Clock, MailWarning, Mails, RotateCcw, Send, Shield, Trash2, UserPlus, X, } from "lucide-react"; import { api, HttpError, type AdminUserRow, type Invite, type Me, type PendingAccessRow, } from "../lib/api"; import { notify } from "../lib/notifications"; import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers"; import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; import { Button } from "./ui/Button"; import Tooltip from "./ui/Tooltip"; import InlineNotice from "./ui/InlineNotice"; import { Section } from "./ui/form"; import SegmentedToggle from "./ui/SegmentedToggle"; import { LANGUAGES, type LangCode } from "../i18n"; import { LoadingState, StateMessage } from "./ui/QueryState"; import AccountPanel from "./AccountPanel"; import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./ui/Tabs"; import { PageToolbar } from "./ui/PageShell"; import { useMe } from "../lib/useMe"; // 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 tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can // pre-select the tab without importing this lazy-loaded page — see that file.) export default function AdminUsers() { const me = useMe(); const { t } = useTranslation(); const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles"); const tabs = [ { id: "roles", label: t("users.tabs.roles") }, // Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose // address was approved BEFORE they registered — the second never reaches the invite queue, // so the badge used to read zero while somebody sat locked out (roadmap UX-25). { id: "access", label: t("users.tabs.access"), badge: me.pending_invites + me.pending_activations, }, { id: "demo", label: t("users.tabs.demo") }, ]; const active = tabs.some((x) => x.id === tab) ? tab : "roles"; return ( <> {/* The tab bar is fixed chrome; the tab content scrolls under it. */}
{active === "roles" && } {active === "access" && ( <> )} {active === "demo" && }
); } function Badge({ tone, children, }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode; }) { const cls = tone === "accent" ? "border-accent/40 text-accent" : tone === "warning" ? "border-warn/40 text-warn" : "border-border text-muted"; return ( {children} ); } function UsersRoles({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); // Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since // with per-row disabling the admin can now start actions on several rows concurrently. const [pendingIds, setPendingIds] = useState>(new Set()); // The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so // only one is ever mounted. const [panelFor, setPanelFor] = useState(null); const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id)); const endPending = (id: number) => setPendingIds((s) => { const n = new Set(s); n.delete(id); return n; }); const setRole = useMutation({ mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) => api.setUserRole(id, role), onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: qk.adminUsers() }); notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); }, onSettled: (_d, _e, vars) => endPending(vars.id), // 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), onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: qk.adminUsers() }); notify({ level: "success", message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", { email: vars.email, }), }); }, onSettled: (_d, _e, vars) => endPending(vars.id), // Guards (demo / self / last admin) surface via the global error dialog. }); const del = useMutation({ mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id), onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: qk.adminUsers() }); notify({ level: "success", message: t("users.delete.done", { email: vars.email }) }); }, onSettled: (_d, _e, vars) => endPending(vars.id), }); 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 (
{panelFor && setPanelFor(null)} />}

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

{q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading && !q.data ? ( ) : rows.length === 0 ? (

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

) : (
{rows.map((u) => { const self = u.id === me.id; return (
{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")} )}
); })}
)}
); } /** Did the invitation fail because this instance has no working SMTP? */ function inviteEmailOff(err: HttpError): boolean { const d = err.detailData as { code?: string; reason?: string } | null | undefined; return d?.code === "invite_send" && d.reason === "email_off"; } // --- Sign-ups that are still outside (roadmap UX-25) -------------------------------------------- // // The gap this closes: approve an address BEFORE the person registers and their sign-up produces no // queue row at all — `_decide` only takes a pending invite — so the Access-requests screen above was // empty while they sat locked out. The only working move was re-typing the address into the // whitelist box, which nobody would guess. This section makes the state visible and the fix one // click, WITHOUT changing who may cause what: the button is admin-authenticated and explicit. Two // earlier attempts moved authority instead (auto-activating on registration, reopening the approved // invite) and were reverted after review. function PendingAccess() { const { t } = useTranslation(); const qc = useQueryClient(); const rows = useQuery({ queryKey: qk.adminPendingAccess(), queryFn: () => api.adminPendingAccess(), }); // Activating changes /api/me's badge count too, so refresh all of them or the number lies until // reload. Shared with the account panel's copy of this mutation (see lib/adminUsers). const refresh = () => invalidateUserAccess(qc); const activate = useMutation({ mutationFn: (u: PendingAccessRow) => api.activateUser(u.id), onSuccess: (_r, u) => { refresh(); notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) }); }, onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }), }); const resend = useMutation({ mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id), onSuccess: (_r, u) => { refresh(); notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) }); }, // The public resend is deliberately silent; this one is not, so say WHY it failed — an instance // with no working SMTP would otherwise show a success toast for a mail that was never sent. onError: (err) => notify({ level: "error", message: isEmailOff(err) ? t("users.pendingAccess.resendEmailOff") : t("users.pendingAccess.resendFailed"), }), }); const list = rows.data ?? []; const busy = activate.isPending || resend.isPending; if (rows.isSuccess && list.length === 0) return null; // nothing stuck: no empty box to explain return (

{t("users.pendingAccess.body")}

{rows.isError && !rows.data ? ( rows.refetch()}> {t("common.loadError")} ) : rows.isLoading && !rows.data ? ( ) : (
{list.map((u) => ( activate.mutate(u)} onResend={() => resend.mutate(u)} /> ))}
)}
); } // One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same // reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone // in should see that the address has not been confirmed yet, because on a no-SMTP instance that is // the difference between the owner and whoever registered the address first (roadmap SEC-15). function PendingAccessRowView({ row, busy, onActivate, onResend, }: { row: PendingAccessRow; busy: boolean; onActivate: () => void; onResend: () => void; }) { const { t } = useTranslation(); return (
{row.email}
{row.created_at && ( {t("users.pendingAccess.signedUp", { time: new Date(row.created_at).toLocaleString(), })} )} {row.email_verified ? ( {t("users.pendingAccess.emailConfirmed")} ) : ( {t("users.pendingAccess.emailUnconfirmed")} )} {!row.is_active && ( {t("users.pendingAccess.inactive")} )}
{!row.email_verified && ( )}
); } /** The mail language for one invitation. Owns its own display state and reports every change up * through `onPick`, because the dialog that hosts it resolves to a plain boolean — the choice has * to live in the caller's closure, not in this component. Endonyms come from the app's own * language list, so a third locale would appear here for free. */ function InviteLangPicker({ initial, onPick, }: { initial: LangCode; onPick: (lang: LangCode) => void; }) { const { t } = useTranslation(); const [lang, setLang] = useState(initial); return ( ({ value: l.code, label: l.label }))} value={lang} onChange={(v) => { setLang(v); onPick(v); }} /> ); } // --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account ------- function AdminInvites() { const { t, i18n } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const me = useMe(); const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() }); const [newEmail, setNewEmail] = useState(""); const refresh = () => { qc.invalidateQueries({ queryKey: qk.adminInvites() }); qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge // The stuck-sign-ups section below describes the same backlog and changes with every decision // here: approving an address moves a registrant INTO it (active, still unverified), whitelisting // one takes a row out. Leaving it stale hides the resend button this epic added for them. qc.invalidateQueries({ queryKey: qk.adminPendingAccess() }); }; 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: (_d, _id) => { refresh(); notify({ level: "success", message: t("settings.invites.denied") }); }, onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }), }); const onDeny = async (i: { id: number; email: string }) => { const ok = await confirm({ title: t("settings.invites.denyTitle"), message: t("settings.invites.denyConfirm", { email: i.email }), confirmLabel: t("settings.invites.deny"), danger: true, }); if (ok) deny.mutate(i.id); }; const remove = useMutation({ mutationFn: (id: number) => api.deleteInvite(id), onSuccess: (d) => { refresh(); // Removing the row is not always a revoke: is_allowed falls back to the env allow-lists, so // for an address on those the person keeps access. Saying "removed" alone would mislead. if (d.still_allowed_by_env) notify({ level: "warning", message: t("settings.invites.deletedStillAllowed") }); else notify({ level: "success", message: t("settings.invites.deleted") }); }, onError: (e) => { // A 409 means the list was stale — someone registered on that address since it loaded. Say // what to do instead of a generic failure, and refetch so the row re-renders as read-only. const conflict = e instanceof HttpError && e.status === 409; notify({ level: "error", message: t( conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed", ), }); if (conflict) refresh(); }, }); const onDelete = async (i: Invite) => { const ok = await confirm({ title: t("settings.invites.deleteTitle"), // Removing the row is not a revoke when the address is also in the server's env allow-lists — // say so while the admin can still change their mind, not in a toast after the fact. message: t( i.still_allowed_by_env ? "settings.invites.deleteConfirmStillAllowed" : "settings.invites.deleteConfirm", { email: i.email }, ), confirmLabel: t("settings.invites.delete"), danger: true, }); if (ok) remove.mutate(i.id); }; // Sending is its OWN act, never a side effect of approving an address: the operator opens a door // as a possibility (often already agreed in person), and a mail fired automatically would be // nudging someone who never asked. `invited_at` comes back on the row so a second click is a // decision rather than an accident. const invite = useMutation({ mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang), onSuccess: (_r, v) => { refresh(); notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) }); }, onError: (err) => notify({ level: "error", message: err instanceof HttpError && inviteEmailOff(err) ? t("settings.invites.inviteEmailOff") : t("settings.invites.inviteFailed"), }), }); // The one recipient the app has no language signal about: the admin typed this address in, so // nobody ever told us how to write to its owner. The dialog is where that gets decided — and it // is also where the "you already wrote to them" warning belongs, next to the button that would // do it a second time, rather than only in a tooltip the admin may never hover. const onInvite = async (inv: Invite) => { let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang) ? (inv.lang as LangCode) : ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode); const ok = await confirm({ title: t("settings.invites.inviteTitle"), message: ( <>

{t( inv.invited_at ? "settings.invites.inviteConfirmAgain" : "settings.invites.inviteConfirm", { email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() }, )}

(lang = v)} /> ), confirmLabel: t("settings.invites.inviteSend"), }); if (ok) invite.mutate({ inv, lang }); }; 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 (
{!me.email_configured && ( {t("settings.invites.emailOff")} )} {invites.isError && !invites.data ? ( invites.refetch()}> {t("common.loadError")} ) : invites.isLoading && !invites.data ? ( ) : pending.length === 0 ? (

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

) : (
{pending.map((i) => ( approve.mutate({ id: i.id, email: i.email })} onDeny={() => onDeny(i)} onDelete={() => onDelete(i)} busy={approve.isPending || deny.isPending || remove.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-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" />
{decided.length > 0 && (
{t("settings.invites.decided", { count: decided.length })}
{decided.map((i) => (
{i.email} {i.status} {/* Only an approved row with NOBODY behind it yet: this button means "the door is open, come in", which is meaningless to someone already inside — and half the whitelist is existing users. An account that exists but is still stuck has its own surface above, with a re-send. The label carries the answer to "did I already write to them?", the question whose wrong answer means mailing a person twice. */} {i.status === "approved" && !i.has_account && ( )} {/* An entry with an account behind it stays read-only: deleting it would revoke that user's access, since is_allowed reads this row. Delete the user instead. */} {i.has_account ? ( // `disabled` would drop it from the tab order AND suppress the title, so the // reason reached nobody — same call as the Playlists drag handle. ) : ( )}
))}
)}
); } function AdminDemo() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const list = useQuery({ queryKey: qk.demoWhitelist(), queryFn: () => api.adminDemoWhitelist() }); const [newEmail, setNewEmail] = useState(""); const add = useMutation({ mutationFn: (email: string) => api.addDemoWhitelist(email), onSuccess: (_d, email) => { setNewEmail(""); qc.invalidateQueries({ queryKey: qk.demoWhitelist() }); 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: qk.demoWhitelist() }); notify({ level: "success", message: t("settings.demo.removed") }); }, onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }), }); const onRemove = async (r: { id: number; email: string }) => { const ok = await confirm({ title: t("settings.demo.removeTitle"), message: t("settings.demo.removeConfirm", { email: r.email }), confirmLabel: t("settings.demo.remove"), danger: true, }); if (ok) remove.mutate(r.id); }; 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-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" />
); } function InviteRow({ inv, onApprove, onDeny, onDelete, busy, }: { inv: Invite; onApprove: () => void; onDeny: () => void; onDelete: () => void; busy: boolean; }) { const { t } = useTranslation(); return (
{inv.email}
{inv.requested_at && (
{t("settings.invites.requested", { time: new Date(inv.requested_at).toLocaleString(), })}
)}
) : (
); }