Files
siftlode/frontend/src/components/AdminUsers.tsx
T
peter 078194731b feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place
- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
2026-10-11 19:37:13 +02:00

867 lines
35 KiB
TypeScript

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. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<Tabs idBase={panel.idBase} tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
<div {...panel.panelProps(active)} className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && (
<>
<AdminInvites />
<PendingAccess />
</>
)}
{active === "storage" && <StorageTab />}
</div>
</>
);
}
function UsersRoles({ me }: { me: Me }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
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<Set<number>>(new Set());
// The account panel: which user it is showing, or null. Held here rather than per row so
// only one is ever mounted.
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(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 (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 });
};
// The row's actions are ONE menu (DESIGN § Action placement): the role, the suspension and
// the delete — rare and two of them destructive — open from the row's "…", a right-click on the row,
// a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state
// badges, the last sign-in. A locked action (on yourself) stays listed with its reason.
const rowMenu = useContextMenu<AdminUserRow>({ keyOf: (u) => u.id });
const menuItems = (u: AdminUserRow): ActionMenuItem[] => {
const self = u.id === me.id;
const pending = pendingIds.has(u.id);
const roleNote = self ? t("users.roles.selfLocked") : undefined;
const suspendNote = self ? t("users.suspend.selfLocked") : undefined;
const deleteNote = self ? t("users.delete.locked") : undefined;
return [
{
id: "role",
label: u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin"),
icon: <Shield />,
disabled: pending || !!roleNote,
note: roleNote,
onSelect: () => onToggle(u),
},
{
id: "suspend",
label: u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action"),
icon: u.is_suspended ? <RotateCcw /> : <Ban />,
disabled: pending || !!suspendNote,
note: suspendNote,
onSelect: () => onSuspend(u),
},
{
id: "delete",
label: t("users.delete.action"),
icon: <Trash2 />,
danger: true,
separatorBefore: true,
disabled: pending || !!deleteNote,
note: deleteNote,
onSelect: () => onDelete(u),
},
];
};
const rows = q.data ?? [];
return (
<Section card title={t("users.roles.title")}>
<ContextMenu
menu={rowMenu}
ariaLabel={t("users.rowMenu")}
items={rowMenu.target ? menuItems(rowMenu.target) : []}
/>
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState shape="list" />
) : 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 (
// 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.
<div
key={u.id}
{...rowMenu.triggerProps(u)}
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
>
<Pressable
selectable
data-row-open=""
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
className={`min-w-0 flex-1 cursor-pointer ${ROW_OPEN}`}
>
<div className="text-sm truncate">
{displayName(u)}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div>
<div className="text-2xs text-muted truncate">{u.email}</div>
</Pressable>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warn">{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="warn">{t("users.roles.unverified")}</Badge>
)}
{/* 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. */}
<Tooltip
hint={
u.last_login_at
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at, lang) })
: t("users.roles.lastLoginNone")
}
>
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
{u.last_login_at ? relativeTime(u.last_login_at, lang, t) : "—"}
</span>
</Tooltip>
<MoreButton
label={t("users.rowMenu")}
size="md"
expanded={rowMenu.open && rowMenu.target?.id === u.id}
menuProps={rowMenu.buttonProps(u)}
className="relative"
/>
</div>
);
})}
</div>
)}
</Section>
);
}
// --- 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 (
<Section card title={t("users.pendingAccess.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.pendingAccess.body")}</p>
{rows.isError && !rows.data ? (
<StateMessage tone="error" onRetry={() => rows.refetch()}>
{t("common.loadError")}
</StateMessage>
) : rows.isLoading && !rows.data ? (
<LoadingState shape="list" />
) : (
<div className="space-y-1.5">
{list.map((u) => (
<PendingAccessRowView
key={u.id}
row={u}
busy={busy}
onActivate={() => activate.mutate(u)}
onResend={() => resend.mutate(u)}
/>
))}
</div>
)}
</Section>
);
}
// 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 (
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{row.email}</div>
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
{row.created_at && (
<span>
{t("users.pendingAccess.signedUp", {
time: formatDateTime(row.created_at, lang),
})}
</span>
)}
{row.email_verified ? (
<span className="inline-flex items-center gap-1 text-success">
<Check className="w-3 h-3" />
{t("users.pendingAccess.emailConfirmed")}
</span>
) : (
<span className="inline-flex items-center gap-1 text-warn">
<MailWarning className="w-3 h-3" />
{t("users.pendingAccess.emailUnconfirmed")}
</span>
)}
{!row.is_active && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
{t("users.pendingAccess.inactive")}
</span>
)}
</div>
</div>
{!row.email_verified && (
<Tooltip hint={t("users.pendingAccess.resendHint")}>
<IconButton
noTitle
label={t("users.pendingAccess.resend")}
size="sm"
variant="outline"
onClick={onResend}
disabled={busy}
>
<Send className="w-4 h-4" />
</IconButton>
</Tooltip>
)}
{row.can_activate && (
<Tooltip hint={t("users.pendingAccess.activateHint")}>
<Button
variant="outline-accent"
size="xs"
className="shrink-0"
onClick={onActivate}
disabled={busy}
>
{t("users.pendingAccess.activate")}
</Button>
</Tooltip>
)}
</div>
);
}
/** 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<LangCode>(initial);
return (
<SegmentedToggle
className="mt-3"
size="md"
ariaLabel={t("settings.invites.inviteLang")}
label={t("settings.invites.inviteLang")}
options={LANGUAGES.map((l) => ({ 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: (
<>
<p>
{t(
inv.invited_at
? "settings.invites.inviteConfirmAgain"
: "settings.invites.inviteConfirm",
{
email: inv.email,
time: formatDateTime(inv.invited_at ?? new Date(0).toISOString(), lang),
},
)}
</p>
<InviteLangPicker initial={mailLang} onPick={(v) => (mailLang = v)} />
</>
),
confirmLabel: t("settings.invites.inviteSend"),
});
if (ok) invite.mutate({ inv, lang: mailLang });
};
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 (
<Section card title={t("settings.invites.title")}>
{!me.email_configured && (
<InlineNotice className="mb-3">{t("settings.invites.emailOff")}</InlineNotice>
)}
{invites.isError && !invites.data ? (
<StateMessage tone="error" onRetry={() => invites.refetch()}>
{t("common.loadError")}
</StateMessage>
) : invites.isLoading && !invites.data ? (
<LoadingState shape="list" />
) : 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={() => onDeny(i)}
onDelete={() => onDelete(i)}
busy={approve.isPending || deny.isPending || remove.isPending}
/>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2 mt-3"
>
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.invites.addPlaceholder")}
/>
<Button
type="submit"
variant="glass"
className="shrink-0"
icon={<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 gap-2">
<span className="truncate flex-1">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
{t(
i.status === "approved"
? "settings.invites.status.approved"
: "settings.invites.status.denied",
)}
</span>
{/* 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 && (
<Tooltip
hint={
i.invited_at
? t("settings.invites.invitedAt", {
time: formatDateTime(i.invited_at, lang),
})
: t("settings.invites.inviteHint")
}
>
<IconButton
noTitle
onClick={() => onInvite(i)}
disabled={invite.isPending}
label={t("settings.invites.inviteLabel", { email: i.email })}
>
{/* Already invited: the glyph fades, the action stays (a resend). */}
<Mails className={i.invited_at ? "opacity-50" : ""} />
</IconButton>
</Tooltip>
)}
{/* 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.
<IconButton
aria-disabled
onClick={(e) => e.preventDefault()}
label={t("settings.invites.deleteHasAccount")}
>
<Trash2 />
</IconButton>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<IconButton
noTitle
variant="danger"
onClick={() => onDelete(i)}
disabled={remove.isPending}
label={t("settings.invites.deleteLabel", { email: i.email })}
>
<Trash2 />
</IconButton>
</Tooltip>
)}
</div>
))}
</div>
</details>
)}
</Section>
);
}
function InviteRow({
inv,
onApprove,
onDeny,
onDelete,
busy,
}: {
inv: Invite;
onApprove: () => void;
onDeny: () => void;
onDelete: () => void;
busy: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
return (
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{inv.email}</div>
{inv.requested_at && (
<div className="text-2xs text-muted">
{t("settings.invites.requested", {
time: formatDateTime(inv.requested_at, lang),
})}
</div>
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<IconButton
noTitle
label={t("settings.invites.approve")}
size="sm"
variant="outline-accent"
onClick={onApprove}
disabled={busy}
>
<Check className="w-4 h-4" />
</IconButton>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<IconButton
noTitle
label={t("settings.invites.deny")}
size="sm"
variant="outline-danger"
onClick={onDeny}
disabled={busy}
>
<X className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* 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.
<IconButton
variant="outline"
aria-disabled
onClick={(e) => e.preventDefault()}
label={t("settings.invites.deleteHasAccount")}
>
<Trash2 />
</IconButton>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<IconButton
noTitle
label={t("settings.invites.deleteLabel", { email: inv.email })}
size="sm"
variant="outline-danger"
onClick={onDelete}
disabled={busy}
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
)}
</div>
);
}