Third sweep, 20 more sites: the rest of AdminUsers (the access-request and invite clusters), Scheduler, SettingsPanel, SetupWizard, About, Feed, KeyGate, OnboardingWizard -- and, importantly, four platform components that were still hand-rolling their own buttons: Modal's close, Banner's dismiss, SidePanel's two toggles and QueryState's retry. A platform layer that bypasses its own primitive is the same defect one level up. Hand-rolled buttons with their own geometry: 110 -> 39; Button call sites 37 -> 106. What is left is deliberate, and it is the list Button's own docstring excludes plus two families this pass refused to force: video overlay controls painted on white/N, drag handles, chip removers, accent-solid pills, the bg-card family on the signed-out screen, and sites whose paint is conditional (`suspended ? warn : neutral`) or otherwise has no variant yet. Forcing those through a className override would be unsafe without tailwind-merge, which is exactly the hazard this sprint documented. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Settings and the About dialog (its close button and full-width CTA are both migrated), clean console.
971 lines
37 KiB
TypeScript
971 lines
37 KiB
TypeScript
import { useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Ban,
|
|
Check,
|
|
Clock,
|
|
MailWarning,
|
|
Mails,
|
|
RotateCcw,
|
|
Send,
|
|
Shield,
|
|
Trash2,
|
|
UserPlus,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
api,
|
|
HttpError,
|
|
type AdminUserRow,
|
|
type Invite,
|
|
type Me,
|
|
type PendingAccessRow,
|
|
} from "../lib/api";
|
|
import { notify } from "../lib/notifications";
|
|
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
|
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
|
import { Button } from "./ui/Button";
|
|
import Tooltip from "./ui/Tooltip";
|
|
import InlineNotice from "./ui/InlineNotice";
|
|
import { Section } from "./ui/form";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import { LANGUAGES, type LangCode } from "../i18n";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import AccountPanel from "./AccountPanel";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
|
import { PageToolbar } from "./ui/PageShell";
|
|
import { useMe } from "../lib/useMe";
|
|
|
|
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
|
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
|
// carries a badge with the count of pending requests so it's visible without switching to it.
|
|
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
|
|
// pre-select the tab without importing this lazy-loaded page — see that file.)
|
|
|
|
export default function AdminUsers() {
|
|
const me = useMe();
|
|
const { t } = useTranslation();
|
|
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
|
|
const tabs = [
|
|
{ id: "roles", label: t("users.tabs.roles") },
|
|
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
|
|
// address was approved BEFORE they registered — the second never reaches the invite queue,
|
|
// so the badge used to read zero while somebody sat locked out (roadmap UX-25).
|
|
{
|
|
id: "access",
|
|
label: t("users.tabs.access"),
|
|
badge: me.pending_invites + me.pending_activations,
|
|
},
|
|
{ id: "demo", label: t("users.tabs.demo") },
|
|
];
|
|
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
|
return (
|
|
<>
|
|
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
|
|
<PageToolbar>
|
|
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
|
|
<Tabs tabs={tabs} active={active} onChange={setTab} />
|
|
</div>
|
|
</PageToolbar>
|
|
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
|
|
{active === "roles" && <UsersRoles me={me} />}
|
|
{active === "access" && (
|
|
<>
|
|
<AdminInvites />
|
|
<PendingAccess />
|
|
</>
|
|
)}
|
|
{active === "demo" && <AdminDemo />}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Badge({
|
|
tone,
|
|
children,
|
|
}: {
|
|
tone: "accent" | "muted" | "warning";
|
|
children: React.ReactNode;
|
|
}) {
|
|
const cls =
|
|
tone === "accent"
|
|
? "border-accent/40 text-accent"
|
|
: tone === "warning"
|
|
? "border-warn/40 text-warn"
|
|
: "border-border text-muted";
|
|
return (
|
|
<span className={`shrink-0 text-2xs px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
|
|
);
|
|
}
|
|
|
|
function UsersRoles({ me }: { me: Me }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
|
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
|
|
// with per-row disabling the admin can now start actions on several rows concurrently.
|
|
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
|
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
|
|
// only one is ever mounted.
|
|
const [panelFor, setPanelFor] = useState<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;
|
|
});
|
|
const setRole = useMutation({
|
|
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
|
|
api.setUserRole(id, role),
|
|
onMutate: (vars) => startPending(vars.id),
|
|
onSuccess: (_d, vars) => {
|
|
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
|
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
|
|
},
|
|
onSettled: (_d, _e, vars) => endPending(vars.id),
|
|
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
|
|
});
|
|
|
|
const suspend = useMutation({
|
|
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
|
|
api.setUserSuspended(id, suspended),
|
|
onMutate: (vars) => startPending(vars.id),
|
|
onSuccess: (_d, vars) => {
|
|
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
|
notify({
|
|
level: "success",
|
|
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
|
|
email: vars.email,
|
|
}),
|
|
});
|
|
},
|
|
onSettled: (_d, _e, vars) => endPending(vars.id),
|
|
// Guards (demo / self / last admin) surface via the global error dialog.
|
|
});
|
|
const del = useMutation({
|
|
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
|
|
onMutate: (vars) => startPending(vars.id),
|
|
onSuccess: (_d, vars) => {
|
|
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
|
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
|
},
|
|
onSettled: (_d, _e, vars) => endPending(vars.id),
|
|
});
|
|
|
|
const onToggle = async (u: AdminUserRow) => {
|
|
const makeAdmin = u.role !== "admin";
|
|
const ok = await confirm({
|
|
title: makeAdmin ? t("users.roles.promoteTitle") : t("users.roles.demoteTitle"),
|
|
message: t(makeAdmin ? "users.roles.promoteConfirm" : "users.roles.demoteConfirm", {
|
|
email: u.email,
|
|
}),
|
|
confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"),
|
|
danger: !makeAdmin,
|
|
});
|
|
if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user", email: u.email });
|
|
};
|
|
|
|
const onSuspend = async (u: AdminUserRow) => {
|
|
const willSuspend = !u.is_suspended;
|
|
const ok = await confirm({
|
|
title: t(willSuspend ? "users.suspend.title" : "users.suspend.undoTitle"),
|
|
message: t(willSuspend ? "users.suspend.confirm" : "users.suspend.undoConfirm", {
|
|
email: u.email,
|
|
}),
|
|
confirmLabel: t(willSuspend ? "users.suspend.action" : "users.suspend.undoAction"),
|
|
danger: willSuspend,
|
|
});
|
|
if (ok) suspend.mutate({ id: u.id, suspended: willSuspend, email: u.email });
|
|
};
|
|
|
|
const onDelete = async (u: AdminUserRow) => {
|
|
const ok = await confirm({
|
|
title: t("users.delete.title"),
|
|
message: t("users.delete.confirm", { email: u.email }),
|
|
confirmLabel: t("users.delete.action"),
|
|
danger: true,
|
|
});
|
|
if (ok) del.mutate({ id: u.id, email: u.email });
|
|
};
|
|
|
|
const rows = q.data ?? [];
|
|
|
|
return (
|
|
<Section card title={t("users.roles.title")}>
|
|
{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 />
|
|
) : 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 (
|
|
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
|
<button
|
|
onClick={() => setPanelFor(u)}
|
|
title={t("users.account.open")}
|
|
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
|
>
|
|
<div className="text-sm truncate">
|
|
{u.display_name ?? u.email.split("@")[0]}
|
|
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
|
</div>
|
|
<div className="text-2xs text-muted truncate">{u.email}</div>
|
|
</button>
|
|
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
|
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
|
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
|
{!u.is_active && <Badge tone="warning">{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="warning">{t("users.roles.unverified")}</Badge>
|
|
)}
|
|
<Tooltip
|
|
hint={
|
|
u.is_demo
|
|
? t("users.roles.demoLocked")
|
|
: self
|
|
? t("users.roles.selfLocked")
|
|
: u.role === "admin"
|
|
? t("users.roles.makeUser")
|
|
: t("users.roles.makeAdmin")
|
|
}
|
|
>
|
|
<Button
|
|
variant="glass"
|
|
size="xs"
|
|
className="shrink-0"
|
|
onClick={() => onToggle(u)}
|
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
|
icon={<Shield className="w-3.5 h-3.5" />}
|
|
>
|
|
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip
|
|
hint={
|
|
u.is_demo
|
|
? t("users.suspend.demoLocked")
|
|
: self
|
|
? t("users.suspend.selfLocked")
|
|
: u.is_suspended
|
|
? t("users.suspend.undoAction")
|
|
: t("users.suspend.action")
|
|
}
|
|
>
|
|
<Button
|
|
variant={u.is_suspended ? "outline-warn" : "outline"}
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
onClick={() => onSuspend(u)}
|
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
|
aria-label={
|
|
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
|
}
|
|
icon={
|
|
u.is_suspended ? (
|
|
<RotateCcw className="w-4 h-4" />
|
|
) : (
|
|
<Ban className="w-4 h-4" />
|
|
)
|
|
}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip
|
|
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
|
>
|
|
<Button
|
|
variant="outline-danger"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
onClick={() => onDelete(u)}
|
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
|
aria-label={t("users.delete.action")}
|
|
icon={<Trash2 className="w-4 h-4" />}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/** Did the invitation fail because this instance has no working SMTP? */
|
|
function inviteEmailOff(err: HttpError): boolean {
|
|
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
|
return d?.code === "invite_send" && d.reason === "email_off";
|
|
}
|
|
|
|
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
|
|
//
|
|
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
|
|
// queue row at all — `_decide` only takes a pending invite — so the Access-requests screen above was
|
|
// empty while they sat locked out. The only working move was re-typing the address into the
|
|
// whitelist box, which nobody would guess. This section makes the state visible and the fix one
|
|
// click, WITHOUT changing who may cause what: the button is admin-authenticated and explicit. Two
|
|
// earlier attempts moved authority instead (auto-activating on registration, reopening the approved
|
|
// invite) and were reverted after review.
|
|
function PendingAccess() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const rows = useQuery({
|
|
queryKey: qk.adminPendingAccess(),
|
|
queryFn: () => api.adminPendingAccess(),
|
|
});
|
|
// Activating changes /api/me's badge count too, so refresh all of them or the number lies until
|
|
// reload. Shared with the account panel's copy of this mutation (see lib/adminUsers).
|
|
const refresh = () => invalidateUserAccess(qc);
|
|
|
|
const activate = useMutation({
|
|
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
|
|
onSuccess: (_r, u) => {
|
|
refresh();
|
|
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
|
});
|
|
|
|
const resend = useMutation({
|
|
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
|
|
onSuccess: (_r, u) => {
|
|
refresh();
|
|
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
|
|
},
|
|
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
|
|
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
|
|
onError: (err) =>
|
|
notify({
|
|
level: "error",
|
|
message: isEmailOff(err)
|
|
? t("users.pendingAccess.resendEmailOff")
|
|
: t("users.pendingAccess.resendFailed"),
|
|
}),
|
|
});
|
|
|
|
const list = rows.data ?? [];
|
|
const busy = activate.isPending || resend.isPending;
|
|
if (rows.isSuccess && list.length === 0) return null; // nothing stuck: no empty box to explain
|
|
|
|
return (
|
|
<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 />
|
|
) : (
|
|
<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 } = useTranslation();
|
|
return (
|
|
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
<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: new Date(row.created_at).toLocaleString(),
|
|
})}
|
|
</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")}>
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
onClick={onResend}
|
|
disabled={busy}
|
|
aria-label={t("users.pendingAccess.resend")}
|
|
icon={<Send className="w-4 h-4" />}
|
|
/>
|
|
</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 qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const me = useMe();
|
|
const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() });
|
|
const [newEmail, setNewEmail] = useState("");
|
|
const refresh = () => {
|
|
qc.invalidateQueries({ queryKey: qk.adminInvites() });
|
|
qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
|
|
// The stuck-sign-ups section below describes the same backlog and changes with every decision
|
|
// here: approving an address moves a registrant INTO it (active, still unverified), whitelisting
|
|
// one takes a row out. Leaving it stale hides the resend button this epic added for them.
|
|
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
|
};
|
|
const approve = useMutation({
|
|
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
|
|
onSuccess: (_d, vars) => {
|
|
refresh();
|
|
notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
|
|
});
|
|
const deny = useMutation({
|
|
mutationFn: (id: number) => api.denyInvite(id),
|
|
onSuccess: (_d, _id) => {
|
|
refresh();
|
|
notify({ level: "success", message: t("settings.invites.denied") });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
|
|
});
|
|
const onDeny = async (i: { id: number; email: string }) => {
|
|
const ok = await confirm({
|
|
title: t("settings.invites.denyTitle"),
|
|
message: t("settings.invites.denyConfirm", { email: i.email }),
|
|
confirmLabel: t("settings.invites.deny"),
|
|
danger: true,
|
|
});
|
|
if (ok) deny.mutate(i.id);
|
|
};
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => api.deleteInvite(id),
|
|
onSuccess: (d) => {
|
|
refresh();
|
|
// Removing the row is not always a revoke: is_allowed falls back to the env allow-lists, so
|
|
// for an address on those the person keeps access. Saying "removed" alone would mislead.
|
|
if (d.still_allowed_by_env)
|
|
notify({ level: "warning", message: t("settings.invites.deletedStillAllowed") });
|
|
else notify({ level: "success", message: t("settings.invites.deleted") });
|
|
},
|
|
onError: (e) => {
|
|
// A 409 means the list was stale — someone registered on that address since it loaded. Say
|
|
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
|
|
const conflict = e instanceof HttpError && e.status === 409;
|
|
notify({
|
|
level: "error",
|
|
message: t(
|
|
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
|
|
),
|
|
});
|
|
if (conflict) refresh();
|
|
},
|
|
});
|
|
const onDelete = async (i: Invite) => {
|
|
const ok = await confirm({
|
|
title: t("settings.invites.deleteTitle"),
|
|
// Removing the row is not a revoke when the address is also in the server's env allow-lists —
|
|
// say so while the admin can still change their mind, not in a toast after the fact.
|
|
message: t(
|
|
i.still_allowed_by_env
|
|
? "settings.invites.deleteConfirmStillAllowed"
|
|
: "settings.invites.deleteConfirm",
|
|
{ email: i.email },
|
|
),
|
|
confirmLabel: t("settings.invites.delete"),
|
|
danger: true,
|
|
});
|
|
if (ok) remove.mutate(i.id);
|
|
};
|
|
// Sending is its OWN act, never a side effect of approving an address: the operator opens a door
|
|
// as a possibility (often already agreed in person), and a mail fired automatically would be
|
|
// nudging someone who never asked. `invited_at` comes back on the row so a second click is a
|
|
// decision rather than an accident.
|
|
const invite = useMutation({
|
|
mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang),
|
|
onSuccess: (_r, v) => {
|
|
refresh();
|
|
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
|
|
},
|
|
onError: (err) =>
|
|
notify({
|
|
level: "error",
|
|
message:
|
|
err instanceof HttpError && inviteEmailOff(err)
|
|
? t("settings.invites.inviteEmailOff")
|
|
: t("settings.invites.inviteFailed"),
|
|
}),
|
|
});
|
|
// The one recipient the app has no language signal about: the admin typed this address in, so
|
|
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
|
|
// is also where the "you already wrote to them" warning belongs, next to the button that would
|
|
// do it a second time, rather than only in a tooltip the admin may never hover.
|
|
const onInvite = async (inv: Invite) => {
|
|
let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
|
|
? (inv.lang as LangCode)
|
|
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
|
|
const ok = await confirm({
|
|
title: t("settings.invites.inviteTitle"),
|
|
message: (
|
|
<>
|
|
<p>
|
|
{t(
|
|
inv.invited_at
|
|
? "settings.invites.inviteConfirmAgain"
|
|
: "settings.invites.inviteConfirm",
|
|
{ email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
|
|
)}
|
|
</p>
|
|
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
|
|
</>
|
|
),
|
|
confirmLabel: t("settings.invites.inviteSend"),
|
|
});
|
|
if (ok) invite.mutate({ inv, lang });
|
|
};
|
|
|
|
const add = useMutation({
|
|
mutationFn: (email: string) => api.addInvite(email),
|
|
onSuccess: (_d, email) => {
|
|
setNewEmail("");
|
|
refresh();
|
|
notify({ level: "success", message: t("settings.invites.addedToWhitelist", { email }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
|
|
});
|
|
|
|
const list = invites.data ?? [];
|
|
const pending = list.filter((i) => i.status === "pending");
|
|
const decided = list.filter((i) => i.status !== "pending");
|
|
|
|
return (
|
|
<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 />
|
|
) : 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
|
|
type="email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
placeholder={t("settings.invites.addPlaceholder")}
|
|
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
<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"}>
|
|
{i.status}
|
|
</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: new Date(i.invited_at).toLocaleString(),
|
|
})
|
|
: t("settings.invites.inviteHint")
|
|
}
|
|
>
|
|
<button
|
|
onClick={() => onInvite(i)}
|
|
disabled={invite.isPending}
|
|
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
|
|
i.invited_at
|
|
? "text-muted/50 hover:text-fg"
|
|
: "text-muted hover:text-accent"
|
|
}`}
|
|
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
|
|
>
|
|
<Mails className="w-3.5 h-3.5" />
|
|
</button>
|
|
</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.
|
|
<button
|
|
type="button"
|
|
aria-disabled
|
|
onClick={(e) => e.preventDefault()}
|
|
title={t("settings.invites.deleteHasAccount")}
|
|
aria-label={t("settings.invites.deleteHasAccount")}
|
|
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
) : (
|
|
<Tooltip hint={t("settings.invites.deleteHint")}>
|
|
<button
|
|
onClick={() => onDelete(i)}
|
|
disabled={remove.isPending}
|
|
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
|
|
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function AdminDemo() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const list = useQuery({ queryKey: qk.demoWhitelist(), queryFn: () => api.adminDemoWhitelist() });
|
|
const [newEmail, setNewEmail] = useState("");
|
|
|
|
const add = useMutation({
|
|
mutationFn: (email: string) => api.addDemoWhitelist(email),
|
|
onSuccess: (_d, email) => {
|
|
setNewEmail("");
|
|
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
|
|
notify({ level: "success", message: t("settings.demo.added", { email }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
|
|
notify({ level: "success", message: t("settings.demo.removed") });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
|
|
});
|
|
const onRemove = async (r: { id: number; email: string }) => {
|
|
const ok = await confirm({
|
|
title: t("settings.demo.removeTitle"),
|
|
message: t("settings.demo.removeConfirm", { email: r.email }),
|
|
confirmLabel: t("settings.demo.remove"),
|
|
danger: true,
|
|
});
|
|
if (ok) remove.mutate(r.id);
|
|
};
|
|
const reset = useMutation({
|
|
mutationFn: () => api.resetDemo(),
|
|
onSuccess: (r: { playlists_seeded: number }) =>
|
|
notify({
|
|
level: "success",
|
|
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
|
|
}),
|
|
onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
|
|
});
|
|
|
|
const rows = list.data ?? [];
|
|
|
|
return (
|
|
<Section card title={t("settings.demo.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
|
|
|
{rows.length > 0 && (
|
|
<div className="space-y-1.5 mb-3">
|
|
{rows.map((r) => (
|
|
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm truncate">{r.email}</div>
|
|
{r.added_by && (
|
|
<div className="text-2xs text-muted truncate">
|
|
{t("settings.demo.addedBy", { who: r.added_by })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Tooltip hint={t("settings.demo.removeHint")}>
|
|
<Button
|
|
variant="outline-danger"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
onClick={() => onRemove(r)}
|
|
disabled={remove.isPending}
|
|
aria-label={t("settings.demo.remove")}
|
|
icon={<Trash2 className="w-4 h-4" />}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (newEmail.trim()) add.mutate(newEmail.trim());
|
|
}}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<input
|
|
type="email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
placeholder={t("settings.demo.addPlaceholder")}
|
|
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
<Button variant="glass" size="md" className="shrink-0" type="submit">
|
|
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="mt-4 pt-3 border-t border-border">
|
|
<Tooltip hint={t("settings.demo.resetHint")}>
|
|
<button
|
|
onClick={async () => {
|
|
if (
|
|
await confirm({
|
|
title: t("settings.demo.resetTitle"),
|
|
message: t("settings.demo.resetConfirm"),
|
|
confirmLabel: t("settings.demo.reset"),
|
|
danger: true,
|
|
})
|
|
)
|
|
reset.mutate();
|
|
}}
|
|
disabled={reset.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
|
{t("settings.demo.reset")}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function InviteRow({
|
|
inv,
|
|
onApprove,
|
|
onDeny,
|
|
onDelete,
|
|
busy,
|
|
}: {
|
|
inv: Invite;
|
|
onApprove: () => void;
|
|
onDeny: () => void;
|
|
onDelete: () => void;
|
|
busy: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
<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: new Date(inv.requested_at).toLocaleString(),
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Tooltip hint={t("settings.invites.approveHint")}>
|
|
<Button
|
|
variant="outline-accent"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
onClick={onApprove}
|
|
disabled={busy}
|
|
aria-label={t("settings.invites.approve")}
|
|
icon={<Check className="w-4 h-4" />}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip hint={t("settings.invites.denyHint")}>
|
|
<Button
|
|
variant="outline-danger"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
onClick={onDeny}
|
|
disabled={busy}
|
|
aria-label={t("settings.invites.deny")}
|
|
icon={<X className="w-4 h-4" />}
|
|
/>
|
|
</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.
|
|
<button
|
|
type="button"
|
|
aria-disabled
|
|
onClick={(e) => e.preventDefault()}
|
|
title={t("settings.invites.deleteHasAccount")}
|
|
aria-label={t("settings.invites.deleteHasAccount")}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
) : (
|
|
<Tooltip hint={t("settings.invites.deleteHint")}>
|
|
<Button
|
|
variant="outline-danger"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
onClick={onDelete}
|
|
disabled={busy}
|
|
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
|
icon={<Trash2 className="w-4 h-4" />}
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|