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 { useTabPersistedState } 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 { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import type { ActionMenuItem } from "./ui/ActionMenu";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { StorageTab } from "./downloadAdmin";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// storage every user's downloads take (with their quotas). 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] = useTabPersistedState(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,
},
// What every user's downloads take on the disk, against their own limit (the quota editor).
{ id: "storage", label: t("users.tabs.storage") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return (
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
{rows.map((u) => {
const self = u.id === me.id;
return (
// The whole card is the row, so the whole card answers (owner's call): 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.
{u.role === "admin" && {t("users.roles.admin")}}
{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")}
)}
{/* How long ago this account last signed in. At the row's right end on purpose (only the
fixed-width "…" after it): the badges before it vary in width row to row, so only
the 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, lang, t) : "—"}
);
})}
)}
);
}
// --- 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 (
)}
);
}
// 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, i18n } = useTranslation();
const lang = i18n.language;
return (
);
}
/** 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 lang = i18n.language;
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) => {
// The language the INVITE is written in — not the admin's `lang`, which dates the sentence below.
let mailLang: 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: (
<>
{i.email}
{t(
i.status === "approved"
? "settings.invites.status.approved"
: "settings.invites.status.denied",
)}
{/* 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 })}
>
)}
{/* 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")}
>
) : (
)}