Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5.
A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives
once, in whatever language we picked, and stays that way. That made the epic's own headline mail —
"one mail, one click" — English for a Hungarian reader.
The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity
rule is now a test rather than a habit: every key must carry both languages AND the same
`{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents
a placeholder raises inside a background task nobody is watching. Both are silent in production.
Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never
inside the mail — these all run as background tasks, after the request's session is gone, which is
the same reason the approval mail computes its link in the handler.
Where the language comes from, in order: the account's own preference; failing that, the language the
person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` —
because the mails an access request produces go to an address that usually has no account yet, and
the signal does exist: the page their form was submitted from, or the locale Google reported for a
sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in
S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment
cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address
they typed in themselves, where nobody has ever told us anything.
That send is now a dialog rather than a bare click, which is also where the "you already wrote to
them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one
action whose failure mode is mailing a real person twice.
S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the
frontend never sent it, so every account has been storing nothing. The request builders now stamp the
UI language themselves — a signal that has to be passed in by every caller is one every caller can
forget, which is exactly how it shipped unset.
Admins are notified one mail per language group instead of one blast to everyone. They are people
with their own UI language; a single message would have to pick one of them and be wrong for the rest.
Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions
landed in S6).
958 lines
37 KiB
TypeScript
958 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 { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
|
import Tooltip from "./Tooltip";
|
|
import InlineNotice from "./InlineNotice";
|
|
import { Section } from "./ui/form";
|
|
import SegmentedToggle from "./SegmentedToggle";
|
|
import { LANGUAGES, type LangCode } from "../i18n";
|
|
import { LoadingState, StateMessage } from "./QueryState";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import Tabs, { usePersistedTab } from "./Tabs";
|
|
import { PageToolbar } from "./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());
|
|
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")}>
|
|
<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">
|
|
<div className="min-w-0 flex-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>
|
|
</div>
|
|
{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
|
|
onClick={() => onToggle(u)}
|
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
|
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
|
|
>
|
|
<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
|
|
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")
|
|
}
|
|
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
|
u.is_suspended
|
|
? "border-warn/40 text-warn hover:bg-warn/10"
|
|
: "border-border text-muted hover:text-warn"
|
|
}`}
|
|
>
|
|
{u.is_suspended ? (
|
|
<RotateCcw className="w-4 h-4" />
|
|
) : (
|
|
<Ban className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip
|
|
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
|
>
|
|
<button
|
|
onClick={() => onDelete(u)}
|
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
|
aria-label={t("users.delete.action")}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</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 both or the number lies until reload.
|
|
const refresh = () => {
|
|
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
|
qc.invalidateQueries({ queryKey: qk.me() });
|
|
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
|
};
|
|
|
|
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:
|
|
err instanceof HttpError && 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>
|
|
);
|
|
}
|
|
|
|
/** Did the resend fail because this instance has no working SMTP? */
|
|
function isEmailOff(err: HttpError): boolean {
|
|
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
|
return d?.code === "resend" && d.reason === "email_off";
|
|
}
|
|
|
|
// 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
|
|
onClick={onResend}
|
|
disabled={busy}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
|
aria-label={t("users.pendingAccess.resend")}
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
{row.can_activate && (
|
|
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
|
<button
|
|
onClick={onActivate}
|
|
disabled={busy}
|
|
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
|
>
|
|
{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
|
|
};
|
|
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"
|
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
|
>
|
|
<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
|
|
onClick={() => onRemove(r)}
|
|
disabled={remove.isPending}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
|
aria-label={t("settings.demo.remove")}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</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
|
|
type="submit"
|
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
|
>
|
|
<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
|
|
onClick={onApprove}
|
|
disabled={busy}
|
|
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
|
aria-label={t("settings.invites.approve")}
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip hint={t("settings.invites.denyHint")}>
|
|
<button
|
|
onClick={onDeny}
|
|
disabled={busy}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
|
aria-label={t("settings.invites.deny")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</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
|
|
onClick={onDelete}
|
|
disabled={busy}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
|
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|