refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } from "lucide-react";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { api } from "../../lib/api";
|
||||
import { displayName } from "../../lib/api/account";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { invalidateUserAccess } from "../../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../../lib/api/admin";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { SectionLabel } from "../../components/ui/Headings";
|
||||
import { useCopyFeedback } from "../../components/ui/useCopyFeedback";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives.
|
||||
//
|
||||
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
|
||||
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
|
||||
// cleared by SEC-10, or which Google account was attached. Every support conversation started by
|
||||
// guessing. The recovery actions sit under that readout rather than in the row, so the admin picks
|
||||
// one having read what is actually wrong.
|
||||
|
||||
/** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */
|
||||
function AccessRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = "muted",
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "muted" | "warn" | "ok";
|
||||
}) {
|
||||
const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted";
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Icon className={`w-3.5 h-3.5 shrink-0 ${toneCls}`} />
|
||||
<span className="text-muted w-28 shrink-0">{label}</span>
|
||||
<span className={`${toneCls} min-w-0 truncate`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPanel({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUserRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
||||
const { copied, copy: copyValue } = useCopyFeedback();
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: qk.adminUserAccess(user.id),
|
||||
queryFn: () => api.adminUserAccess(user.id),
|
||||
});
|
||||
|
||||
// Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error
|
||||
// dialog on the promise that the caller reports the failure inline. Without an `onError` that
|
||||
// promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this
|
||||
// panel exists for, the admin would read silence as "the mail went out".
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.createSetPasswordLink(user.id),
|
||||
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
||||
onError: (err) => notifyActionError(err, t("users.account.linkFailed")),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api.resendUserVerification(user.id),
|
||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: () => api.activateUser(user.id),
|
||||
onSuccess: () => invalidateUserAccess(qc, user.id),
|
||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
|
||||
});
|
||||
|
||||
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
|
||||
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
||||
// minimal self-host this no-email link was built for.
|
||||
const copy = () => {
|
||||
if (link) void copyValue(link.url);
|
||||
};
|
||||
|
||||
const a: AdminUserAccess | undefined = q.data;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="close"
|
||||
title={displayName(user)}
|
||||
onClose={onClose}
|
||||
placement="drawer"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="text-2xs text-muted truncate mb-4">{user.email}</div>
|
||||
|
||||
{q.isError && !a ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : !a ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("users.account.access")}
|
||||
</SectionLabel>
|
||||
<div className={`${SURFACE.card} p-3 space-y-2 mb-5`}>
|
||||
<AccessRow
|
||||
icon={Mail}
|
||||
label={t("users.account.email")}
|
||||
value={
|
||||
a.email_verified
|
||||
? t("users.account.emailConfirmed")
|
||||
: t("users.account.emailUnconfirmed")
|
||||
}
|
||||
tone={a.email_verified ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Clock}
|
||||
label={t("users.account.approval")}
|
||||
value={
|
||||
a.is_active
|
||||
? t("users.account.approvalDone")
|
||||
: a.can_activate
|
||||
? t("users.account.approvalWaitingOnYou")
|
||||
: t("users.account.approvalNotRequested")
|
||||
}
|
||||
tone={a.is_active ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={KeyRound}
|
||||
label={t("users.account.password")}
|
||||
value={
|
||||
a.has_password
|
||||
? t("users.account.passwordSet")
|
||||
: a.password_revoked_at
|
||||
? t("users.account.passwordRevoked")
|
||||
: t("users.account.passwordNone")
|
||||
}
|
||||
// Not having a password is only a PROBLEM when it was taken away; a Google-only
|
||||
// account never had one and is working exactly as intended.
|
||||
tone={a.has_password ? "ok" : a.password_revoked_at ? "warn" : "muted"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Globe}
|
||||
label={t("users.account.google")}
|
||||
value={
|
||||
!a.has_google
|
||||
? t("users.account.googleNone")
|
||||
: a.google_email
|
||||
? t("users.account.googleAs", { email: a.google_email })
|
||||
: t("users.account.googleConnected")
|
||||
}
|
||||
tone={a.has_google ? "ok" : "muted"}
|
||||
/>
|
||||
{/* The exact moment; the list row beside it only says how long ago. */}
|
||||
<AccessRow
|
||||
icon={LogIn}
|
||||
label={t("users.account.lastLogin")}
|
||||
value={
|
||||
a.last_login_at
|
||||
? formatDateTime(a.last_login_at, lang)
|
||||
: t("users.account.lastLoginNone")
|
||||
}
|
||||
/>
|
||||
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
|
||||
{!a.is_allowed && (
|
||||
<AccessRow
|
||||
icon={ShieldAlert}
|
||||
label={t("users.account.invite")}
|
||||
value={t("users.account.inviteMissing")}
|
||||
tone="warn"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("users.account.recovery")}
|
||||
</SectionLabel>
|
||||
<div className="space-y-2">
|
||||
{a.can_activate && (
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<Check />}
|
||||
loading={activate.isPending}
|
||||
onClick={() => activate.mutate()}
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<Send />}
|
||||
loading={resend.isPending}
|
||||
onClick={() => resend.mutate()}
|
||||
disabled={!a.email_configured || a.email_verified}
|
||||
>
|
||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
||||
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
||||
whole reason the link below exists, so naming it here is the explanation. */}
|
||||
{!a.email_configured && (
|
||||
<span className="text-muted">{t("users.account.mailOff")}</span>
|
||||
)}
|
||||
{a.email_configured && a.email_verified && (
|
||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<KeyRound />}
|
||||
loading={issue.isPending}
|
||||
onClick={() => issue.mutate()}
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
<span className="flex-1 text-left">
|
||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
<div className="mt-4 pt-4 border-t border-border space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
||||
{link.url}
|
||||
</div>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={copied ? <Check /> : <Copy />}
|
||||
onClick={copy}
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A failed user action is said once, and a user who is gone is not left listed.
|
||||
// setRole / suspend / delete had no onError at all — "the global error dialog says it" — but a 404
|
||||
// (the user was deleted in another tab) gets no dialog, so the click did nothing and the row stayed.
|
||||
// And the invite approve/deny toasted unconditionally on top of the dialog `req` opens for a 409.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/useMe", () => ({
|
||||
useMe: () => ({
|
||||
id: 1,
|
||||
email: "admin@x",
|
||||
role: "admin",
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
}));
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
vi.mock("../../lib/errorDialog", () => ({ reportError: vi.fn() }));
|
||||
|
||||
import AdminUsers from "./AdminUsers";
|
||||
import { api, type AdminUserRow } from "../../lib/api";
|
||||
import { relativeTime } from "../../lib/format";
|
||||
import i18n from "../../i18n";
|
||||
|
||||
const row = {
|
||||
id: 2,
|
||||
email: "gone@x",
|
||||
role: "user",
|
||||
is_suspended: false,
|
||||
email_verified: true,
|
||||
is_active: true,
|
||||
} as unknown as AdminUserRow;
|
||||
|
||||
function answer(status: number, detail: unknown) {
|
||||
return new Response(JSON.stringify({ detail }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notify.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const users = vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return users;
|
||||
}
|
||||
|
||||
describe("the last sign-in column", () => {
|
||||
it("says how long ago each account signed in, and a dash where nothing is recorded", async () => {
|
||||
const at = new Date(Date.now() - 3 * 86_400_000).toISOString();
|
||||
vi.spyOn(api, "adminUsers").mockResolvedValue([
|
||||
{ ...row, id: 3, email: "seen@x", last_login_at: at },
|
||||
{ ...row, id: 4, email: "never@x", last_login_at: null },
|
||||
] as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(relativeTime(at, "en", i18n.t))).toBeTruthy();
|
||||
expect(screen.getAllByText("—")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The hover ended wherever the row's first badge or button began, because the layer was
|
||||
// the name button's own. The card is the row: it paints the layer under its content, and the name
|
||||
// button's hit layer spans the card, under the actions that sit above it.
|
||||
describe("a user row", () => {
|
||||
it("answers as a whole card, with its actions above the card's hit layer", async () => {
|
||||
mount();
|
||||
const open = await screen.findByTitle("users.account.open");
|
||||
const card = open.parentElement!;
|
||||
expect(card.className).toMatch(/(^|\s)isolate(\s|$)/);
|
||||
expect(card.className).toMatch(/(^|\s)hover:before:bg-\(--hover\)(\s|$)/);
|
||||
expect(open.className).toMatch(/(^|\s)after:inset-0(\s|$)/);
|
||||
expect(open.className).not.toMatch(/selectable|hover:/);
|
||||
const actions = [...card.querySelectorAll("button")].filter((b) => b !== open);
|
||||
expect(actions.length).toBeGreaterThan(0);
|
||||
for (const b of actions) expect(b.className).toMatch(/(^|\s)relative(\s|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
/** The open menu's rows (a closed popover's are hidden from the accessibility tree in jsdom). */
|
||||
const menuRows = () => screen.queryAllByRole("menuitem", { hidden: true });
|
||||
|
||||
/** The row's actions live in its "…" menu: open it, then pick the row. */
|
||||
async function chooseInRowMenu(label: string) {
|
||||
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
|
||||
const item = await waitFor(() => {
|
||||
const found = menuRows().find((m) => m.textContent?.startsWith(label));
|
||||
if (!found) throw new Error(`no menu row "${label}"`);
|
||||
return found;
|
||||
});
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
// DESIGN § Action placement: the role, the suspension and the delete moved off the row into
|
||||
// ONE menu — the row's "…", which a right-click on the row opens too. A locked one stays listed with
|
||||
// its reason.
|
||||
describe("a user row's menu", () => {
|
||||
it("holds the role, the suspension and the delete; the row shows no action button", async () => {
|
||||
mount();
|
||||
const open = await screen.findByTitle("users.account.open");
|
||||
const card = open.parentElement!;
|
||||
const buttons = [...card.querySelectorAll("button")].filter((b) => b !== open);
|
||||
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual(["users.rowMenu"]);
|
||||
fireEvent.contextMenu(card, { clientX: 5, clientY: 5 });
|
||||
await waitFor(() => expect(menuRows().length).toBe(3));
|
||||
expect(menuRows().map((i) => i.textContent)).toEqual([
|
||||
"users.roles.makeAdmin",
|
||||
"users.suspend.action",
|
||||
"users.delete.action",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists your own row's actions as locked, with the reason", async () => {
|
||||
vi.spyOn(api, "adminUsers").mockResolvedValue([{ ...row, id: 1, email: "admin@x" }] as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
|
||||
await waitFor(() => expect(menuRows().length).toBe(3));
|
||||
const [role, suspend, del] = menuRows();
|
||||
expect(role!.hasAttribute("disabled")).toBe(true);
|
||||
expect(role!.textContent).toBe("users.roles.makeAdminusers.roles.selfLocked");
|
||||
expect(suspend!.textContent).toBe("users.suspend.actionusers.suspend.selfLocked");
|
||||
expect(del!.textContent).toBe("users.delete.actionusers.delete.locked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a user action that fails", () => {
|
||||
it("says a user who is gone, and refetches the list", async () => {
|
||||
const users = mount();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
answer(404, { code: "not_found", reason: "user" }),
|
||||
);
|
||||
await chooseInRowMenu("users.roles.makeAdmin");
|
||||
await waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "users.roles.failed", // what failed…
|
||||
message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("is not toasted on top of the error dialog the request already opened", async () => {
|
||||
mount();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
|
||||
await chooseInRowMenu("users.roles.makeAdmin");
|
||||
const { reportError } = await import("../../lib/errorDialog");
|
||||
await waitFor(() => expect(reportError).toHaveBeenCalled());
|
||||
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,866 @@
|
||||
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 "../../components/ui/Badge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import InlineNotice from "../../components/ui/InlineNotice";
|
||||
import { Section } from "../../components/ui/form";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { LANGUAGES, type LangCode } from "../../i18n";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import Tabs from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import { useTabPersistedState } from "../../lib/storage";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { notifyActionError, notifyKnownFailure } from "../../lib/actionErrors";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import type { ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin account panel's RECOVERY actions, and specifically that a refusal is visible.
|
||||
//
|
||||
// All three call `quiet: true` endpoints, which suppress the global error dialog on the promise that
|
||||
// the caller reports the failure inline. Without an `onError` that promise is broken and a refusal
|
||||
// renders as nothing at all — and on the broken-SMTP instance this panel exists for, an admin reads
|
||||
// silence as "the mail went out". These pin that each failure says something, and that the resend
|
||||
// distinguishes "this instance has no working mail" from a genuine send failure.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
||||
// stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const access = {
|
||||
email_verified: false,
|
||||
email_configured: true,
|
||||
is_active: false,
|
||||
is_allowed: true,
|
||||
can_activate: true,
|
||||
has_password: false,
|
||||
google_email: null,
|
||||
invite_status: null,
|
||||
};
|
||||
|
||||
const apiMock = {
|
||||
adminUserAccess: vi.fn(async () => access),
|
||||
createSetPasswordLink: vi.fn(),
|
||||
resendUserVerification: vi.fn(),
|
||||
activateUser: vi.fn(),
|
||||
};
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return { ...actual, api: apiMock };
|
||||
});
|
||||
|
||||
const { HttpError } = await import("../../lib/api");
|
||||
const AccountPanel = (await import("./AccountPanel")).default;
|
||||
|
||||
const user = {
|
||||
id: 7,
|
||||
email: "stuck@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_active: false,
|
||||
is_suspended: false,
|
||||
};
|
||||
|
||||
function mount(u: typeof user = user) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{/* a partial user fixture: `never` stands in for the full type without an `any` */}
|
||||
<AccountPanel user={u as never} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Click the recovery button whose label is `key`, once the readout has loaded. */
|
||||
async function clickAction(key: string) {
|
||||
mount();
|
||||
await screen.findByText("users.account.recovery");
|
||||
fireEvent.click(screen.getByText(key));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notifyMock.mockClear();
|
||||
Object.values(apiMock).forEach((m) => m.mockClear());
|
||||
apiMock.adminUserAccess.mockResolvedValue(access);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
// What each error toast names: its title when it has one (a failure reported with its reason under
|
||||
// it), else the specific sentence (the no-mail case).
|
||||
const errorMessages = () =>
|
||||
notifyMock.mock.calls
|
||||
.map(([a]) => a as { level?: string; title?: string; message?: string })
|
||||
.filter((a) => a.level === "error")
|
||||
.map((a) => a.title ?? a.message);
|
||||
|
||||
describe("the account panel reports a refused recovery action", () => {
|
||||
it("says the link could not be minted instead of leaving the button inert", async () => {
|
||||
apiMock.createSetPasswordLink.mockRejectedValue(new HttpError(404, "gone", null));
|
||||
|
||||
await clickAction("users.account.setPassword");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.account.linkFailed"));
|
||||
// …and no link block was rendered, so the toast is the only thing that happened.
|
||||
expect(screen.queryByText("users.account.copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
||||
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "users.pendingAccess.resendFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(422, "send failed", { code: "resend", reason: "send_failed" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendFailed"));
|
||||
// The success toast must NOT also fire — that is the exact lie the backend guards against.
|
||||
expect(notifyMock.mock.calls.map(([a]) => (a as { message?: string }).message)).not.toContain(
|
||||
"users.account.resendSent",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a refused activation", async () => {
|
||||
apiMock.activateUser.mockRejectedValue(
|
||||
new HttpError(409, "not allowed", { code: "activate", reason: "not_allowed" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.activate");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Access-requests panel's delete affordance and its three outcomes. The control's whole point is
|
||||
// that it explains itself: an entry backed by an account keeps a focusable, labelled control instead
|
||||
// of quietly missing one, and an address the server's env still allow-lists gets a warning BEFORE the
|
||||
// irreversible click, not a correction after it.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the confirm/notify
|
||||
// singletons are stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
(vars?.email ? `${key}:${String(vars.email)}` : key) +
|
||||
(vars?.time ? `@${String(vars.time)}` : ""),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
type ConfirmOpts = {
|
||||
title?: string;
|
||||
message?: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
};
|
||||
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
|
||||
vi.mock("../../components/ui/confirmContext", () => ({
|
||||
useConfirm: () => confirmMock,
|
||||
}));
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = {
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
email_configured: true,
|
||||
id: 1,
|
||||
role: "admin",
|
||||
};
|
||||
vi.mock("../../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
|
||||
// active tab directly — this file is about the Access-requests panel, not the tab chrome.
|
||||
vi.mock("../../components/ui/Tabs", () => ({ default: () => null }));
|
||||
vi.mock("../../lib/storage", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/storage")>()),
|
||||
useTabPersistedState: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
const listMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const inviteMock = vi.fn();
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminInvites: () => listMock(),
|
||||
deleteInvite: (id: number) => deleteMock(id),
|
||||
sendInvitation: (id: number, lang: string) => inviteMock(id, lang),
|
||||
// The access tab also renders the stuck-sign-ups section; nothing stuck here, so it
|
||||
// renders nothing and this file stays about the delete affordance.
|
||||
adminPendingAccess: () => Promise.resolve([]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { HttpError } from "../../lib/api";
|
||||
import AdminUsers from "./AdminUsers";
|
||||
|
||||
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
|
||||
id: 1,
|
||||
email: "asker@example.com",
|
||||
status: "pending",
|
||||
note: null,
|
||||
requested_at: null,
|
||||
decided_at: null,
|
||||
decided_by: null,
|
||||
has_account: false,
|
||||
still_allowed_by_env: false,
|
||||
invited_at: null,
|
||||
lang: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const renderPanel = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
confirmMock.mockClear().mockResolvedValue(true);
|
||||
notifyMock.mockClear();
|
||||
deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false });
|
||||
listMock.mockReset().mockResolvedValue([invite()]);
|
||||
inviteMock
|
||||
.mockReset()
|
||||
.mockResolvedValue(invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }));
|
||||
meMock.email_configured = true;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const openAccessTab = async () => {
|
||||
await screen.findByText("settings.invites.title");
|
||||
};
|
||||
|
||||
describe("Access requests — the delete affordance", () => {
|
||||
it("offers a labelled delete on an entry with no account behind it", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
const btn = await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com");
|
||||
expect(btn.getAttribute("aria-disabled")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the control present and focusable — not missing — when the address has an account", async () => {
|
||||
listMock.mockResolvedValue([invite({ has_account: true })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
|
||||
// aria-disabled, not `disabled`: a disabled button leaves the tab order and takes its
|
||||
// explanation with it, which is the whole reason the control is shown at all.
|
||||
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(btn.getAttribute("title")).toBe("settings.invites.deleteHasAccount");
|
||||
expect(screen.queryByLabelText(/deleteLabel/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the same control on the previously-decided list, where the case is commonest", async () => {
|
||||
// The affordance exists twice — the pending rows and the collapsed "decided" list — and an
|
||||
// approved address that has since registered lands in the second one. Both must behave alike.
|
||||
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
|
||||
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
|
||||
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("offers a working delete on a decided entry with no account", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "denied" })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
|
||||
// The decision is named, not the raw status word ("denied").
|
||||
expect(screen.getByText("settings.invites.status.denied")).toBeTruthy();
|
||||
expect(screen.queryByText("denied")).toBeNull();
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1));
|
||||
});
|
||||
|
||||
it("warns in the confirmation — before the click is irreversible — when the env still allows it", async () => {
|
||||
listMock.mockResolvedValue([invite({ still_allowed_by_env: true })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
|
||||
message: "settings.invites.deleteConfirmStillAllowed:asker@example.com",
|
||||
danger: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the plain confirmation otherwise", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
|
||||
message: "settings.invites.deleteConfirm:asker@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("says the entry kept its access when the server reports the env allow-list", async () => {
|
||||
deleteMock.mockResolvedValue({ deleted: 1, still_allowed_by_env: true });
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "warning",
|
||||
message: "settings.invites.deletedStillAllowed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("turns a 409 into the actionable reason rather than a generic failure", async () => {
|
||||
// The stale-list case: someone registered on the address after the page loaded.
|
||||
deleteMock.mockRejectedValue(new HttpError(409, "That address already has an account"));
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
message: "settings.invites.deleteHasAccount",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the mail-is-off warning only while email is unconfigured", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
expect(screen.queryByText("settings.invites.emailOff")).toBeNull();
|
||||
cleanup();
|
||||
|
||||
meMock.email_configured = false;
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
expect(await screen.findByText("settings.invites.emailOff")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// Whitelisting an address is opening a door as a possibility, not knocking on one — the operator has
|
||||
// usually already agreed it with the person, and a row can sit approved for weeks. So adding stays
|
||||
// silent and telling them is its own act, on its own button.
|
||||
describe("inviting someone the admin whitelisted", () => {
|
||||
// The decided rows live behind a <details>; await the summary rather than the section title, or
|
||||
// the click races the invite query that populates it.
|
||||
const open = async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(await screen.findByText(/settings.invites.decided/));
|
||||
};
|
||||
|
||||
it("offers a send button on an approved row", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// Falls back to the admin's own UI language: nobody ever told us anything about this mailbox.
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks first, and writes to nobody when the admin backs out", async () => {
|
||||
confirmMock.mockResolvedValue(false);
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1));
|
||||
expect(inviteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to the language this person asked us in", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved", lang: "hu" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
|
||||
});
|
||||
|
||||
it("sends in the language picked in the dialog, not the default", async () => {
|
||||
// The dialog is the only place this decision can be made — and the pick has to survive back to
|
||||
// the caller, because the confirmation resolves to a plain boolean.
|
||||
let release: (ok: boolean) => void = () => {};
|
||||
confirmMock.mockImplementation(() => new Promise<boolean>((r) => (release = r)));
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
fireEvent.click(dialog.getByText("Magyar"));
|
||||
release(true);
|
||||
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
|
||||
});
|
||||
|
||||
it("warns in the dialog when this person has already been written to", async () => {
|
||||
// The warning used to live only in a tooltip, which the admin may never hover. It belongs next
|
||||
// to the button that would mail a real person a second time.
|
||||
listMock.mockResolvedValue([
|
||||
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }),
|
||||
]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
|
||||
});
|
||||
|
||||
it("dates the earlier invite in the admin's language, not the invitee's", async () => {
|
||||
listMock.mockResolvedValue([
|
||||
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z", lang: "hu" }),
|
||||
]);
|
||||
await open();
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
const at = "2026-09-05T09:00:00Z";
|
||||
expect(dialog.container.textContent).toContain(`@${formatDateTime(at, "en")}`);
|
||||
expect(formatDateTime(at, "en")).not.toBe(formatDateTime(at, "hu"));
|
||||
});
|
||||
|
||||
it("offers none on a denied row, which would contradict the decision", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "denied" })]);
|
||||
await open();
|
||||
|
||||
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers none once somebody is already behind that address", async () => {
|
||||
// "The door is open, come in" is meaningless to someone already inside, and most of a mature
|
||||
// whitelist is existing users. An account that exists but is stuck has its own row above.
|
||||
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
|
||||
await open();
|
||||
|
||||
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
inviteMock.mockRejectedValue(
|
||||
new HttpError(409, "errors.admin.email_off", { code: "invite_send", reason: "email_off" }),
|
||||
);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// A button the admin pressed must not answer with a success toast for a mail nobody received.
|
||||
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "settings.invites.inviteFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin lifeline for a sign-up that is stuck outside (roadmap UX-25). Approve an address
|
||||
// BEFORE the person registers and their sign-up produces no queue row at all, so the Access-requests
|
||||
// screen was empty while they sat locked out and the only working move — re-typing the address into
|
||||
// the whitelist box — was undiscoverable. These pin what the section shows and, just as importantly,
|
||||
// what it does NOT show: the section is a discovery surface, and a person the queue above already
|
||||
// covers must not appear twice.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
||||
// stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars?.email ? `${key}:${String(vars.email)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../components/ui/confirmContext", () => ({
|
||||
useConfirm: () => vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = {
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
email_configured: true,
|
||||
id: 1,
|
||||
role: "admin",
|
||||
};
|
||||
vi.mock("../../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab.
|
||||
vi.mock("../../components/ui/Tabs", () => ({ default: () => null }));
|
||||
vi.mock("../../lib/storage", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/storage")>()),
|
||||
useTabPersistedState: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
const pendingMock = vi.fn();
|
||||
const activateMock = vi.fn();
|
||||
const resendMock = vi.fn();
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminInvites: () => Promise.resolve([]),
|
||||
adminPendingAccess: () => pendingMock(),
|
||||
activateUser: (id: number) => activateMock(id),
|
||||
resendUserVerification: (id: number) => resendMock(id),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import AdminUsers from "./AdminUsers";
|
||||
|
||||
/** A row as /api/admin/pending-access serves it. */
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
id: 7,
|
||||
email: "later@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_active: false,
|
||||
is_suspended: false,
|
||||
email_verified: true,
|
||||
has_password: true,
|
||||
has_google: false,
|
||||
created_at: "2026-09-01T10:00:00Z",
|
||||
can_activate: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pendingMock.mockReset();
|
||||
activateMock.mockReset().mockResolvedValue({});
|
||||
resendMock.mockReset().mockResolvedValue({ ok: true, email: "later@example.com" });
|
||||
notifyMock.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("the stuck sign-ups section", () => {
|
||||
it("offers Activate for a registrant whose address was already approved", async () => {
|
||||
pendingMock.mockResolvedValue([row()]);
|
||||
mount();
|
||||
|
||||
const button = await screen.findByText("users.pendingAccess.activate");
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.activated:later@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no Activate when the address is not allowed, since that would be a different decision", async () => {
|
||||
// Activating an address with no approved invite makes an account that signs in with a password
|
||||
// but is turned away by Google — the whitelist box is the honest move for that.
|
||||
pendingMock.mockResolvedValue([row({ can_activate: false, email_verified: false })]);
|
||||
mount();
|
||||
|
||||
await screen.findByText("users.pendingAccess.emailUnconfirmed");
|
||||
expect(screen.queryByText("users.pendingAccess.activate")).toBeNull();
|
||||
});
|
||||
|
||||
it("names which gate is shut, so an admin sees an unconfirmed address before letting it in", async () => {
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
mount();
|
||||
|
||||
// On a no-SMTP instance this is the difference between the owner and whoever registered the
|
||||
// address first (roadmap SEC-15), so it belongs on the row, not in a tooltip.
|
||||
expect(await screen.findByText("users.pendingAccess.emailUnconfirmed")).toBeTruthy();
|
||||
expect(screen.getByText("users.pendingAccess.inactive")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-sends the confirmation link for an unconfirmed account", async () => {
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
await waitFor(() => expect(resendMock).toHaveBeenCalledWith(7));
|
||||
});
|
||||
|
||||
it("offers no resend once the address is confirmed", async () => {
|
||||
pendingMock.mockResolvedValue([row()]);
|
||||
mount();
|
||||
|
||||
await screen.findByText("users.pendingAccess.activate");
|
||||
expect(screen.queryByLabelText("users.pendingAccess.resend")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
const { HttpError } = await import("../../lib/api");
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
resendMock.mockRejectedValue(
|
||||
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
|
||||
// back on. A button the admin pressed must not answer with a success toast. `req` has already
|
||||
// localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "users.pendingAccess.resendFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing at all when nobody is stuck", async () => {
|
||||
pendingMock.mockResolvedValue([]);
|
||||
mount();
|
||||
|
||||
// The section withdraws itself rather than leaving an empty box to explain — so wait for it to
|
||||
// GO, not merely for the request to have been made.
|
||||
await waitFor(() => expect(screen.queryByText("users.pendingAccess.title")).toBeNull());
|
||||
expect(pendingMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin's download-quota editor rendered NOTHING while its quota loaded and nothing when
|
||||
// the load failed, so the click looked dead. It now opens at once and says which it is. The save
|
||||
// turns the GB field back into bytes; the reset drops the override.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import { QuotaModal } from "./downloadAdmin";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
const QUOTA = { max_bytes: 5 * GiB, max_jobs: 10, max_concurrent: 2, unlimited: false };
|
||||
let qc: QueryClient;
|
||||
const wrap = (ui: ReactNode) => <QueryClientProvider client={qc}>{ui}</QueryClientProvider>;
|
||||
const open = (onClose = () => {}) =>
|
||||
render(wrap(<QuotaModal userId={4} email="a@b.c" onClose={onClose} />));
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("the download-quota editor", () => {
|
||||
it("opens at once and says it is loading", () => {
|
||||
vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
|
||||
open();
|
||||
expect(screen.getByText("downloads.admin.quotaTitle")).toBeTruthy();
|
||||
expect(screen.getAllByRole("status").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("says a failed load failed, and retries it", async () => {
|
||||
const get = vi
|
||||
.spyOn(api, "adminGetDownloadQuota")
|
||||
.mockRejectedValueOnce(new Error("down"))
|
||||
.mockResolvedValueOnce(QUOTA as never);
|
||||
open();
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("common.loadError");
|
||||
fireEvent.click(screen.getByText("common.retry"));
|
||||
await screen.findByDisplayValue("5");
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("gives the first field the focus when the form arrives after a retry", async () => {
|
||||
// jsdom lays nothing out, so every element reads as hidden to the dialog's focus rules.
|
||||
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body);
|
||||
vi.spyOn(api, "adminGetDownloadQuota")
|
||||
.mockRejectedValueOnce(new Error("down"))
|
||||
.mockResolvedValueOnce(QUOTA as never);
|
||||
open();
|
||||
await screen.findByRole("alert");
|
||||
const retry = screen.getByText("common.retry").closest("button")!;
|
||||
await waitFor(() => expect(document.activeElement).toBe(retry));
|
||||
fireEvent.click(retry); // the Retry gives way to the spinner: its focus must not fall to <body>
|
||||
const first = await screen.findByDisplayValue("5");
|
||||
await waitFor(() => expect(document.activeElement).toBe(first));
|
||||
});
|
||||
|
||||
it("saves the GB field as bytes, and the reset drops the override", async () => {
|
||||
vi.spyOn(api, "adminGetDownloadQuota").mockResolvedValue(QUOTA as never);
|
||||
const set = vi.spyOn(api, "adminSetDownloadQuota").mockResolvedValue(QUOTA as never);
|
||||
const reset = vi.spyOn(api, "adminResetDownloadQuota").mockResolvedValue(QUOTA as never);
|
||||
const onClose = vi.fn();
|
||||
open(onClose);
|
||||
fireEvent.change(await screen.findByDisplayValue("5"), { target: { value: "1.5" } });
|
||||
fireEvent.click(screen.getByText("downloads.admin.save"));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(set).toHaveBeenCalledWith(4, {
|
||||
max_bytes: Math.round(1.5 * GiB),
|
||||
max_jobs: 10,
|
||||
max_concurrent: 2,
|
||||
unlimited: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("downloads.admin.reset"));
|
||||
await waitFor(() => expect(reset).toHaveBeenCalledWith(4));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin Users page's Storage tab: every user's footprint against their OWN limit (or
|
||||
// "Unlimited") with their file count, and every user's downloads — each list's row has one menu.
|
||||
// A user's: Edit quota, Reset (behind a confirm). A download's: Open source, the copies, and Edit
|
||||
// this user's quota — which opens the quota of the download's OWNER. i18n is key-passthrough.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { api, type AdminStorage, type DownloadJob } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
storage: undefined as unknown,
|
||||
jobs: undefined as unknown,
|
||||
confirm: vi.fn(async (_o?: unknown) => true),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: { size?: string; email?: string }) =>
|
||||
`${key}${o?.size ? `:${o.size}` : ""}${o?.email ? `:${o.email}` : ""}`,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||
vi.mock("../../lib/useLiveQuery", () => ({
|
||||
useLiveQuery: (key: readonly unknown[]) => ({
|
||||
data: key[0] === qk.adminStorage()[0] ? mocks.storage : mocks.jobs,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { StorageTab } from "./downloadAdmin";
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
const storage = (): AdminStorage => ({
|
||||
files_on_disk: 3,
|
||||
total_bytes: 3 * GiB,
|
||||
total_assets: 3,
|
||||
total_cap_bytes: 0,
|
||||
per_user: [
|
||||
{
|
||||
user_id: 4,
|
||||
email: "anna@x",
|
||||
footprint_bytes: GiB,
|
||||
files: 27,
|
||||
max_bytes: 5 * GiB,
|
||||
unlimited: false,
|
||||
},
|
||||
{
|
||||
user_id: 1,
|
||||
email: "peter@x",
|
||||
footprint_bytes: 2 * GiB,
|
||||
files: 180,
|
||||
// Unlimited wins over the limit the row still carries.
|
||||
max_bytes: 10 * GiB,
|
||||
unlimited: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const job = (id: number, extra: Partial<DownloadJob> = {}) =>
|
||||
({
|
||||
id,
|
||||
status: "done",
|
||||
display_name: `Clip ${id}`,
|
||||
source_url: `https://example.com/${id}`,
|
||||
user_id: 4,
|
||||
user_email: "anna@x",
|
||||
asset: { size_bytes: 1024 },
|
||||
...extra,
|
||||
}) as unknown as DownloadJob;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
mocks.confirm.mockClear();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
mocks.storage = storage();
|
||||
mocks.jobs = [job(7), job(8, { source_url: null, user_id: 1, user_email: "peter@x" })];
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<StorageTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const rowOf = (text: string) =>
|
||||
screen.getAllByText(text)[0]!.closest<HTMLElement>("[data-nav-index]")!;
|
||||
const menuRows = () =>
|
||||
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||
const pick = (label: string) =>
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
|
||||
);
|
||||
async function openMenu(row: HTMLElement) {
|
||||
act(() => {
|
||||
fireEvent.contextMenu(row);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||
);
|
||||
}
|
||||
|
||||
describe("StorageTab", () => {
|
||||
it("names each user's use against their own limit, and their files", () => {
|
||||
mount();
|
||||
const anna = rowOf("anna@x");
|
||||
expect(anna.textContent).toMatch(/downloads\.admin\.ofQuota:5(\.0)? GB/);
|
||||
expect(anna.textContent).toContain("27");
|
||||
expect(anna.querySelector('[role="meter"]')?.getAttribute("aria-valuenow")).toBe("20");
|
||||
expect(rowOf("peter@x").textContent).toContain("downloads.admin.unlimitedShort");
|
||||
});
|
||||
|
||||
it("a user's menu edits their quota, and resets it only through the confirm", async () => {
|
||||
vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
|
||||
const reset = vi.spyOn(api, "adminResetDownloadQuota").mockResolvedValue({} as never);
|
||||
mount();
|
||||
await openMenu(rowOf("anna@x"));
|
||||
expect(menuRows()).toEqual(["downloads.admin.editQuota", "downloads.admin.reset"]);
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
pick("downloads.admin.reset");
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
await openMenu(rowOf("anna@x"));
|
||||
pick("downloads.admin.reset");
|
||||
await waitFor(() => expect(reset).toHaveBeenCalledWith(4));
|
||||
await openMenu(rowOf("anna@x"));
|
||||
pick("downloads.admin.editQuota");
|
||||
expect(await screen.findByText("downloads.admin.quotaTitle:anna@x")).toBeTruthy();
|
||||
expect(api.adminGetDownloadQuota).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it("a download's menu opens its source, copies, and opens its OWNER's quota", async () => {
|
||||
vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
mount();
|
||||
await openMenu(rowOf("Clip 7"));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.admin.openSource",
|
||||
"common.copy.title",
|
||||
"common.copy.link",
|
||||
"downloads.admin.editUserQuota",
|
||||
]);
|
||||
pick("downloads.admin.openSource");
|
||||
expect(open).toHaveBeenCalledWith("https://example.com/7", "_blank", "noopener,noreferrer");
|
||||
// One with no source link offers neither the link nor its copy.
|
||||
await openMenu(rowOf("Clip 8"));
|
||||
expect(menuRows()).toEqual(["common.copy.title", "downloads.admin.editUserQuota"]);
|
||||
pick("downloads.admin.editUserQuota");
|
||||
await waitFor(() => expect(api.adminGetDownloadQuota).toHaveBeenCalledWith(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useState } from "react";
|
||||
import { Meter } from "../../components/ui/Meter";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, RotateCcw, Settings2 } from "lucide-react";
|
||||
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import { api, type AdminStorageUser, type DownloadJob } from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { StatusPill } from "../../features/downloads/downloadRow";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import DataTable, { type Column } from "../../components/ui/DataTable";
|
||||
import { Section } from "../../components/ui/form";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { CheckRow } from "../../components/ui/NativeControls";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The admin Users page's "Storage" tab: the disk in three numbers, every user's footprint against
|
||||
// their own limit (the quota editor), and every user's downloads. Both lists are tables with aligned
|
||||
// columns and one menu per row (its "…", a right-click, a long-press, Shift+F10).
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
export function QuotaModal({
|
||||
userId,
|
||||
email,
|
||||
onClose,
|
||||
}: {
|
||||
userId: number;
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: qk.dlQuota(userId),
|
||||
queryFn: () => api.adminGetDownloadQuota(userId),
|
||||
});
|
||||
const [form, setForm] = useState<{
|
||||
gb: number;
|
||||
jobs: number;
|
||||
conc: number;
|
||||
unlimited: boolean;
|
||||
} | null>(null);
|
||||
const cur = q.data;
|
||||
const state =
|
||||
form ??
|
||||
(cur
|
||||
? {
|
||||
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
|
||||
jobs: cur.max_jobs,
|
||||
conc: cur.max_concurrent,
|
||||
unlimited: cur.unlimited,
|
||||
}
|
||||
: null);
|
||||
const done = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
||||
qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
||||
onClose();
|
||||
};
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.adminSetDownloadQuota(userId, {
|
||||
max_bytes: Math.round(state!.gb * GiB),
|
||||
max_jobs: state!.jobs,
|
||||
max_concurrent: state!.conc,
|
||||
unlimited: state!.unlimited,
|
||||
}),
|
||||
onSuccess: done,
|
||||
meta: { failTitle: "errors.writeFailed.downloadQuota" },
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: () => api.adminResetDownloadQuota(userId),
|
||||
onSuccess: done,
|
||||
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
|
||||
});
|
||||
const upd = (p: Partial<NonNullable<typeof state>>) => {
|
||||
if (state) setForm({ ...state, ...p });
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="field"
|
||||
title={t("downloads.admin.quotaTitle", { email })}
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* The modal opens at once: while the quota loads it says so, and a failed load says THAT,
|
||||
with a retry — it used to render nothing at all, so the click looked dead. The
|
||||
first field still takes focus when the form arrives (Modal's initialFocus="field"). */}
|
||||
{!state ? (
|
||||
q.isError ? (
|
||||
<StateMessage tone="error" onRetry={() => void q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : (
|
||||
<LoadingState />
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxBytes")}
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
value={state.gb}
|
||||
onChange={(e) => upd({ gb: Number(e.target.value) })}
|
||||
disabled={state.unlimited}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxJobs")}
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
value={state.jobs}
|
||||
onChange={(e) => upd({ jobs: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxConcurrent")}
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
value={state.conc}
|
||||
onChange={(e) => upd({ conc: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<CheckRow
|
||||
checked={state.unlimited}
|
||||
onChange={(checked) => upd({ unlimited: checked })}
|
||||
className="text-sm"
|
||||
>
|
||||
{t("downloads.admin.unlimited")}
|
||||
</CheckRow>
|
||||
<ModalFooter className="flex justify-between gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => reset.mutate()}>
|
||||
{t("downloads.admin.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
>
|
||||
{t("downloads.admin.save")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function StorageTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const copy = useCopyItems();
|
||||
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
||||
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
||||
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||
const s = storage.data;
|
||||
|
||||
const reset = useMutation({
|
||||
mutationFn: (userId: number) => api.adminResetDownloadQuota(userId),
|
||||
onSuccess: (_d, userId) => {
|
||||
void qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
||||
void qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
||||
},
|
||||
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
|
||||
});
|
||||
const onReset = async (u: AdminStorageUser) => {
|
||||
const ok = await confirm({
|
||||
title: t("downloads.admin.resetTitle"),
|
||||
message: t("downloads.admin.resetBody", { email: u.email ?? "" }),
|
||||
confirmLabel: t("downloads.admin.reset"),
|
||||
});
|
||||
if (ok) reset.mutate(u.user_id);
|
||||
};
|
||||
|
||||
// --- every user ------------------------------------------------------------------------------
|
||||
const userMenu = useContextMenu<AdminStorageUser>({ keyOf: (u) => u.user_id });
|
||||
const userItems = (u: AdminStorageUser): ActionMenuItem[] => [
|
||||
{
|
||||
id: "quota",
|
||||
label: t("downloads.admin.editQuota"),
|
||||
icon: <Settings2 className="w-4 h-4" />,
|
||||
onSelect: () => setQuotaFor({ id: u.user_id, email: u.email ?? "" }),
|
||||
},
|
||||
{
|
||||
id: "reset",
|
||||
label: t("downloads.admin.reset"),
|
||||
icon: <RotateCcw className="w-4 h-4" />,
|
||||
onSelect: () => void onReset(u),
|
||||
},
|
||||
];
|
||||
// The bar sits beside the name and the number after it: a right-aligned "Used" before a
|
||||
// left-aligned bar ran the two headers together into one phrase.
|
||||
const userColumns: Column<AdminStorageUser>[] = [
|
||||
{
|
||||
key: "user",
|
||||
header: t("downloads.admin.columns.user"),
|
||||
cardPrimary: true,
|
||||
sortable: true,
|
||||
sortValue: (u) => u.email ?? "",
|
||||
render: (u) => <span className="block truncate">{u.email}</span>,
|
||||
},
|
||||
{
|
||||
key: "quota",
|
||||
header: t("downloads.admin.columns.quota"),
|
||||
width: "9rem",
|
||||
sortable: true,
|
||||
sortValue: (u) => (u.unlimited || !u.max_bytes ? -1 : u.footprint_bytes / u.max_bytes),
|
||||
render: (u) => <QuotaFill user={u} lang={lang} />,
|
||||
},
|
||||
{
|
||||
key: "used",
|
||||
header: t("downloads.admin.columns.used"),
|
||||
width: "6.5rem",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
sortValue: (u) => u.footprint_bytes,
|
||||
render: (u) => <span className="tabular-nums">{formatBytes(u.footprint_bytes, lang)}</span>,
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
header: t("downloads.admin.columns.files"),
|
||||
width: "4.5rem",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
sortValue: (u) => u.files,
|
||||
render: (u) => <span className="tabular-nums">{u.files}</span>,
|
||||
},
|
||||
{
|
||||
key: "menu",
|
||||
header: "",
|
||||
width: "3rem",
|
||||
align: "right",
|
||||
cardLabel: false,
|
||||
cardRow: true,
|
||||
render: (u) => (
|
||||
<MoreButton
|
||||
label={t("downloads.admin.userMenu")}
|
||||
size="md"
|
||||
expanded={userMenu.open && userMenu.target?.user_id === u.user_id}
|
||||
menuProps={userMenu.buttonProps(u)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// --- every download ----------------------------------------------------------------------------
|
||||
const jobMenu = useContextMenu<DownloadJob>({ keyOf: (j) => j.id });
|
||||
const jobItems = (j: DownloadJob): ActionMenuItem[] => {
|
||||
const source = j.source_url;
|
||||
const owner = j.user_id;
|
||||
return menuGroups(
|
||||
source
|
||||
? [
|
||||
{
|
||||
id: "open",
|
||||
label: t("downloads.admin.openSource"),
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
// A new tab only opens inside the click (a popup blocker refuses one a task later).
|
||||
inGesture: true,
|
||||
onSelect: () => void window.open(source, "_blank", "noopener,noreferrer"),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
copy(jobTitle(j), source),
|
||||
owner != null
|
||||
? [
|
||||
{
|
||||
id: "quota",
|
||||
label: t("downloads.admin.editUserQuota"),
|
||||
icon: <Settings2 className="w-4 h-4" />,
|
||||
onSelect: () => setQuotaFor({ id: owner, email: j.user_email ?? "" }),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
};
|
||||
const jobColumns: Column<DownloadJob>[] = [
|
||||
{
|
||||
key: "title",
|
||||
header: t("downloads.admin.columns.title"),
|
||||
cardPrimary: true,
|
||||
sortable: true,
|
||||
sortValue: (j) => jobTitle(j),
|
||||
render: (j) => (
|
||||
<span className="block truncate" title={jobTitle(j)}>
|
||||
{jobTitle(j)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
header: t("downloads.admin.columns.user"),
|
||||
width: "11rem",
|
||||
sortable: true,
|
||||
sortValue: (j) => j.user_email ?? "",
|
||||
narrowHidden: true,
|
||||
render: (j) => <span className="block truncate text-muted">{j.user_email}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("downloads.admin.columns.status"),
|
||||
width: "7rem",
|
||||
sortable: true,
|
||||
sortValue: (j) => j.status,
|
||||
render: (j) => <StatusPill job={j} />,
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: t("downloads.admin.columns.size"),
|
||||
width: "6rem",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
sortValue: (j) => j.asset?.size_bytes ?? 0,
|
||||
render: (j) => (
|
||||
<span className="tabular-nums text-muted">
|
||||
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "menu",
|
||||
header: "",
|
||||
width: "3rem",
|
||||
align: "right",
|
||||
cardLabel: false,
|
||||
cardRow: true,
|
||||
render: (j) => (
|
||||
<MoreButton
|
||||
label={t("downloads.admin.jobMenu")}
|
||||
size="md"
|
||||
expanded={jobMenu.open && jobMenu.target?.id === j.id}
|
||||
menuProps={jobMenu.buttonProps(j)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{s && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-4">
|
||||
{[
|
||||
[t("downloads.admin.filesOnDisk"), String(s.files_on_disk)],
|
||||
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)],
|
||||
[
|
||||
t("downloads.admin.cap"),
|
||||
s.total_cap_bytes ? formatBytes(s.total_cap_bytes, lang) : t("downloads.admin.noCap"),
|
||||
],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className={`${SURFACE.card} p-3`}>
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
<div className="text-lg font-semibold mt-0.5 tabular-nums">{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section card title={t("downloads.admin.perUser")}>
|
||||
{storage.isError && !s ? (
|
||||
<StateMessage tone="error" onRetry={() => void storage.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : !s ? (
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="@container">
|
||||
<DataTable
|
||||
rows={s.per_user}
|
||||
columns={userColumns}
|
||||
rowKey={(u) => String(u.user_id)}
|
||||
pageSize={25}
|
||||
keyboardNav
|
||||
fixedLayout
|
||||
rowProps={(u) => userMenu.triggerProps(u)}
|
||||
emptyText={t("downloads.admin.noUsers")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section card title={t("downloads.admin.allDownloads")}>
|
||||
{jobs.isError && !jobs.data ? (
|
||||
<StateMessage tone="error" onRetry={() => void jobs.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : !jobs.data ? (
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="@container">
|
||||
<DataTable
|
||||
rows={jobs.data}
|
||||
columns={jobColumns}
|
||||
rowKey={(j) => String(j.id)}
|
||||
pageSize={25}
|
||||
keyboardNav
|
||||
fixedLayout
|
||||
rowProps={(j) => jobMenu.triggerProps(j)}
|
||||
emptyText={t("downloads.empty.admin")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<ContextMenu
|
||||
menu={userMenu}
|
||||
ariaLabel={t("downloads.admin.userMenu")}
|
||||
items={userMenu.target ? userItems(userMenu.target) : []}
|
||||
/>
|
||||
<ContextMenu
|
||||
menu={jobMenu}
|
||||
ariaLabel={t("downloads.admin.jobMenu")}
|
||||
items={jobMenu.target ? jobItems(jobMenu.target) : []}
|
||||
/>
|
||||
{quotaFor && (
|
||||
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How full a user is against their own limit: a bar, and the limit under it. */
|
||||
function QuotaFill({ user, lang }: { user: AdminStorageUser; lang: string }) {
|
||||
const { t } = useTranslation();
|
||||
if (user.unlimited || !user.max_bytes)
|
||||
return <span className="text-xs text-muted">{t("downloads.admin.unlimitedShort")}</span>;
|
||||
return (
|
||||
<span className="flex flex-col gap-1">
|
||||
<Meter
|
||||
value={user.footprint_bytes}
|
||||
max={user.max_bytes}
|
||||
label={t("downloads.admin.columns.quota")}
|
||||
className="h-1.5"
|
||||
/>
|
||||
<span className="text-2xs text-muted tabular-nums">
|
||||
{t("downloads.admin.ofQuota", { size: formatBytes(user.max_bytes, lang) })}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user