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 { displayName } from "../lib/api/account"; import { notify } from "../lib/notifications"; import { formatDateTime, relativeTime } from "../lib/format"; import { invalidateUserAccess } from "../lib/adminUsers"; import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; import { Badge } from "./ui/Badge"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; 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 "./confirmContext"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import { useAccountPersistedState } from "../lib/storage"; import { PageToolbar } from "./ui/PageShell"; import { useMe } from "../lib/useMe"; import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; // 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.) // A user row's B3 layer (DESIGN.md "State layers"), painted by the card under its content, and the name // button's stretched hit layer over the whole card. Why the row is built so: at the row below. const ROW_LAYER = "relative isolate before:absolute before:inset-0 before:z-under before:rounded-[inherit] before:transition-colors before:content-[''] hover:before:bg-(--hover) has-[[data-row-open]:active]:before:bg-(--press)"; const ROW_OPEN = "focus-visible:outline-none after:absolute after:inset-0 after:rounded-xl after:content-[''] focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-accent"; export default function AdminUsers() { const me = useMe(); const { t } = useTranslation(); const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles"); const panel = useTabPanel(); 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 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; }); // A failed user action: said once (notifyActionError skips what `req` already put in its dialog), // and the list refetched — a user deleted in another tab would otherwise stay listed, every click // on the row doing nothing. const onGoneOrFailed = (e: unknown, fallback: string) => { notifyActionError(e, fallback); qc.invalidateQueries({ queryKey: qk.adminUsers() }); }; 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), // Guards (last admin / self) surface via the global error dialog with the server's detail; what // it does not show (a user deleted meanwhile, a 404) is said here — see onGoneOrFailed. onError: (e) => onGoneOrFailed(e, t("users.roles.failed")), }); 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; the rest, as above. onError: (e) => onGoneOrFailed(e, t("users.suspend.failed")), }); 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), onError: (e) => onGoneOrFailed(e, t("users.delete.failed")), }); 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 ( // The whole card is the row, so the whole card answers (owner, E28): the name's button // stretches an invisible layer over the card (`after:inset-0`), so a click on a badge or // a gap opens the account too, and the B3 hover layer is the card's own `::before`, under // the badges and buttons (`isolate` + `z-under`). The actions are `relative`, above the // stretched layer. The hover used to be the name button's own and ended at the first badge.
setPanelFor(u)} title={t("users.account.open")} className={`min-w-0 flex-1 cursor-pointer ${ROW_OPEN}`} >
{displayName(u)} {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")} )} onSuspend(u)} aria-disabled={u.is_demo || self} disabled={pendingIds.has(u.id)} > {u.is_suspended ? ( ) : ( )} onDelete(u)} aria-disabled={u.is_demo || self} disabled={pendingIds.has(u.id)} > {/* How long ago this account last signed in. LAST in the row on purpose: the badges and the role button before it vary in width row to row, so only the row's own right edge lines the values up into one column. The exact moment is the hint here and a line in the account panel; below `sm` the row has no room, and the panel still says it. */} {u.last_login_at ? relativeTime(u.last_login_at) : "—"}
); })}
)}
); } // --- 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: (err) => notifyActionError(err, 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, and the server's reason says WHY // (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie. onError: (err) => notifyActionError(err, 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: formatDateTime(row.created_at), })} )} {row.email_verified ? ( {t("users.pendingAccess.emailConfirmed")} ) : ( {t("users.pendingAccess.emailUnconfirmed")} )} {!row.is_active && ( {t("users.pendingAccess.inactive")} )}
{!row.email_verified && ( )} {row.can_activate && ( )}
); } /** 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: (e) => notifyActionError(e, 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: (e) => notifyActionError(e, 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; if (conflict) notifyKnownFailure(e, t("settings.invites.deleteHasAccount")); else notifyActionError(e, t("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) => notifyActionError(err, 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: formatDateTime(inv.invited_at ?? new Date(0).toISOString()), }, )}

(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: (e) => notifyActionError(e, 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")} />
{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 && ( onInvite(i)} disabled={invite.isPending} label={t("settings.invites.inviteLabel", { email: i.email })} > {/* Already invited: the glyph fades, the action stays (a resend). */} )} {/* 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. e.preventDefault()} label={t("settings.invites.deleteHasAccount")} > ) : ( onDelete(i)} disabled={remove.isPending} label={t("settings.invites.deleteLabel", { email: i.email })} > )}
))}
)}
); } 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: (e) => notifyActionError(e, 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: (e) => notifyActionError(e, 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: (e) => notifyActionError(e, 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 })}
)}
onRemove(r)} disabled={remove.isPending} >
))}
)}
{ e.preventDefault(); if (newEmail.trim()) add.mutate(newEmail.trim()); }} className="flex items-center gap-2" > setNewEmail(e.target.value)} placeholder={t("settings.demo.addPlaceholder")} />
); } 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: formatDateTime(inv.requested_at), })}
)}
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account exists for it the row is that account's approval gate, so it is managed from Users & roles instead — shown as a disabled control rather than omitted, or the admin sees a row that is silently missing a button their other rows have. */} {inv.has_account ? ( // aria-disabled, not `disabled`: the reason has to stay reachable by keyboard. e.preventDefault()} label={t("settings.invites.deleteHasAccount")} > ) : ( )}
); }