feat(admin): user management — roles, suspend, delete + lifecycle emails

- New Users page tabs (Users & roles / Access requests / Demo) and a tab-ified Configuration
  page, both via a reusable Tabs component (persisted active tab).
- Admin can suspend/unsuspend (migration 0023 adds users.is_suspended) and delete accounts
  from the Users & roles tab, with guards (demo / self / last admin).
- Email notifications to the affected user: suspended (reactive, on a blocked sign-in),
  reinstated, role changed, and account deleted; the approval email now carries a clickable
  app link. The scheduled/manual demo reset also seeds sample channel subscriptions.
- Hide the 'unverified email' chip for Google accounts (Google attests the email).
This commit is contained in:
npeter83 2026-06-19 19:52:12 +02:00
parent 2aa13a6433
commit 4571800991
10 changed files with 461 additions and 32 deletions

View file

@ -1,21 +1,31 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Moved out of Settings → Account into its own admin page; the Invite
// and demo data are unchanged (same tables) — only the UI relocated.
// 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.
export default function AdminUsers({ me }: { me: Me }) {
const { t } = useTranslation();
const [tab, setTab] = usePersistedTab("siftlode.adminUsersTab", "roles");
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
{ id: "demo", label: t("users.tabs.demo") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<UsersRoles me={me} />
<AdminInvites />
<AdminDemo />
<Tabs tabs={tabs} active={active} onChange={setTab} />
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div>
);
}
@ -47,14 +57,37 @@ function UsersRoles({ me }: { me: Me }) {
const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
const setRole = useMutation({
mutationFn: ({ id, role }: { id: number; role: "user" | "admin" }) => api.setUserRole(id, role),
onSuccess: () => {
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
api.setUserRole(id, role),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.roles.updated") });
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
},
// 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),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({
level: "success",
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
email: vars.email,
}),
});
},
// Guards (demo / self / last admin) surface via the global error dialog.
});
const del = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
},
});
const onToggle = async (u: AdminUserRow) => {
const makeAdmin = u.role !== "admin";
const ok = await confirm({
@ -65,7 +98,30 @@ function UsersRoles({ me }: { me: Me }) {
confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"),
danger: !makeAdmin,
});
if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user" });
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 ?? [];
@ -90,8 +146,11 @@ function UsersRoles({ me }: { me: Me }) {
</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>}
{u.is_active && !u.email_verified && (
{/* 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
@ -114,6 +173,40 @@ function UsersRoles({ me }: { me: Me }) {
{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 || suspend.isPending}
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-amber-500/40 text-amber-500 hover:bg-amber-500/10"
: "border-border text-muted hover:text-amber-500"
}`}
>
{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 || del.isPending}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
>
<Trash2 className="w-4 h-4" />
</button>
</Tooltip>
</div>
);
})}
@ -135,10 +228,10 @@ function AdminInvites() {
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
};
const approve = useMutation({
mutationFn: (id: number) => api.approveInvite(id),
onSuccess: () => {
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
onSuccess: (_d, vars) => {
refresh();
notify({ level: "success", message: t("settings.invites.approved") });
notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
},
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
});
@ -171,7 +264,7 @@ function AdminInvites() {
<InviteRow
key={i.id}
inv={i}
onApprove={() => approve.mutate(i.id)}
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => deny.mutate(i.id)}
busy={approve.isPending || deny.isPending}
/>

View file

@ -5,6 +5,7 @@ import { Check, RotateCcw, Save, Send } from "lucide-react";
import { api, type ConfigItem } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@ -35,6 +36,9 @@ export default function ConfigPanel() {
// clearing the field, since an empty secret field means "leave unchanged").
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
const [saveState, setSaveState] = useState<SaveState>("idle");
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab("siftlode.configTab");
// Re-seed the draft whenever the server data changes (initial load + after a save/reset).
useEffect(() => {
@ -94,18 +98,26 @@ export default function ConfigPanel() {
const groupKeys = Object.keys(data.groups).sort(
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99)
);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean));
const groupTabs = groupKeys.map((g) => ({
id: g,
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
}));
return (
<div className="p-4 max-w-3xl w-full mx-auto pb-24">
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
{groupKeys.map((g) => (
<div key={g} className="glass rounded-2xl p-4 mb-4">
<div className="text-xs uppercase tracking-wide text-muted mb-3">
{t(`config.groups.${g}`, g)}
</div>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div className="divide-y divide-border/60">
{data.groups[g].map((item) => (
{data.groups[activeGroup].map((item) => (
<Field
key={item.key}
item={item}
@ -121,7 +133,7 @@ export default function ConfigPanel() {
))}
</div>
{g === "email" && (
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.testEmailHint")}>
<button
@ -136,7 +148,7 @@ export default function ConfigPanel() {
</div>
)}
</div>
))}
)}
{(dirty || saveState !== "idle") && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]">

View file

@ -0,0 +1,62 @@
import { useState } from "react";
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
// per the app's "persist UI state across reload" convention.
export interface TabDef {
id: string;
label: string;
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
}
/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at
* render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */
export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] {
const [tab, setTabState] = useState<string>(() => localStorage.getItem(storageKey) ?? fallback);
const setTab = (id: string) => {
setTabState(id);
localStorage.setItem(storageKey, id);
};
return [tab, setTab];
}
export default function Tabs({
tabs,
active,
onChange,
}: {
tabs: TabDef[];
active: string;
onChange: (id: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1 mb-4">
{tabs.map((tb) => {
const on = tb.id === active;
return (
<button
key={tb.id}
onClick={() => onChange(tb.id)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${
on
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
{tb.label}
{tb.badge != null && tb.badge > 0 && (
<span
className={`text-[11px] leading-none px-1.5 py-0.5 rounded-full ${
on ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
}`}
>
{tb.badge}
</span>
)}
</button>
);
})}
</div>
);
}

View file

@ -1,4 +1,9 @@
{
"tabs": {
"roles": "Benutzer & Rollen",
"access": "Zugriffsanfragen",
"demo": "Demo"
},
"roles": {
"title": "Benutzer & Rollen",
"intro": "Alle, die sich anmelden können. Mache einen Benutzer zum Admin oder wieder zum normalen Benutzer.",
@ -6,16 +11,36 @@
"you": "du",
"admin": "Admin",
"demo": "Demo",
"suspended": "Gesperrt",
"pending": "Wartet auf Freigabe",
"unverified": "E-Mail nicht bestätigt",
"demoLocked": "Die Rolle des Demo-Kontos kann nicht geändert werden.",
"selfLocked": "Du kannst deine eigene Rolle nicht ändern.",
"makeAdmin": "Zum Admin machen",
"makeUser": "Zum Benutzer machen",
"updated": "Rolle aktualisiert",
"updated": "Rolle aktualisiert für {{email}}",
"promoteTitle": "Zum Admin machen?",
"demoteTitle": "Admin entfernen?",
"promoteConfirm": "{{email}} vollen Admin-Zugriff geben (Einstellungen, Planer, Benutzerverwaltung)?",
"demoteConfirm": "{{email}} den Admin-Zugriff entziehen? Wird zum normalen Benutzer."
},
"suspend": {
"action": "Sperren",
"undoAction": "Entsperren",
"title": "Konto sperren?",
"undoTitle": "Konto entsperren?",
"confirm": "{{email}} sperren? Eine Anmeldung (per Passwort oder Google) ist erst nach dem Entsperren wieder möglich, und jede aktive Sitzung endet.",
"undoConfirm": "{{email}} entsperren? Eine Anmeldung ist dann wieder möglich.",
"done": "{{email}} gesperrt",
"undone": "{{email}} entsperrt",
"demoLocked": "Das Demo-Konto kann nicht gesperrt werden.",
"selfLocked": "Du kannst dein eigenes Konto nicht sperren."
},
"delete": {
"action": "Löschen",
"locked": "Dieses Konto kann hier nicht gelöscht werden.",
"title": "Konto löschen?",
"confirm": "{{email}} und alle zugehörigen Daten endgültig löschen — Abos, Tags, Wiedergabeverlauf, Playlists und Einstellungen? Das kann nicht rückgängig gemacht werden.",
"done": "{{email}} gelöscht"
}
}

View file

@ -1,4 +1,9 @@
{
"tabs": {
"roles": "Users & roles",
"access": "Access requests",
"demo": "Demo"
},
"roles": {
"title": "Users & roles",
"intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.",
@ -6,16 +11,36 @@
"you": "you",
"admin": "Admin",
"demo": "Demo",
"suspended": "Suspended",
"pending": "Pending approval",
"unverified": "Unverified email",
"demoLocked": "The demo account's role can't be changed.",
"selfLocked": "You can't change your own role.",
"makeAdmin": "Make admin",
"makeUser": "Make user",
"updated": "Role updated",
"updated": "Role updated for {{email}}",
"promoteTitle": "Make admin?",
"demoteTitle": "Remove admin?",
"promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?",
"demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user."
},
"suspend": {
"action": "Suspend",
"undoAction": "Unsuspend",
"title": "Suspend account?",
"undoTitle": "Unsuspend account?",
"confirm": "Suspend {{email}}? They won't be able to sign in (by password or Google) until you unsuspend them, and any active session ends.",
"undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.",
"done": "{{email}} suspended",
"undone": "{{email}} unsuspended",
"demoLocked": "The demo account can't be suspended.",
"selfLocked": "You can't suspend your own account."
},
"delete": {
"action": "Delete",
"locked": "This account can't be deleted here.",
"title": "Delete account?",
"confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.",
"done": "{{email}} deleted"
}
}

View file

@ -1,4 +1,9 @@
{
"tabs": {
"roles": "Felhasználók és szerepek",
"access": "Hozzáférési kérések",
"demo": "Demo"
},
"roles": {
"title": "Felhasználók és szerepek",
"intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.",
@ -6,16 +11,36 @@
"you": "te",
"admin": "Admin",
"demo": "Demo",
"suspended": "Felfüggesztve",
"pending": "Jóváhagyásra vár",
"unverified": "Nem igazolt e-mail",
"demoLocked": "A demo fiók szerepe nem módosítható.",
"selfLocked": "A saját szerepedet nem módosíthatod.",
"makeAdmin": "Adminná tesz",
"makeUser": "Felhasználóvá tesz",
"updated": "Szerep frissítve",
"updated": "Szerep frissítve: {{email}}",
"promoteTitle": "Adminná teszed?",
"demoteTitle": "Elveszed az admin jogot?",
"promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?",
"demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz."
},
"suspend": {
"action": "Felfüggeszt",
"undoAction": "Feloldás",
"title": "Felfüggeszted a fiókot?",
"undoTitle": "Feloldod a felfüggesztést?",
"confirm": "Felfüggeszted: {{email}}? Nem fog tudni belépni (sem jelszóval, sem Google-lel), amíg fel nem oldod, és az aktív munkamenete megszűnik.",
"undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.",
"done": "{{email}} felfüggesztve",
"undone": "{{email}} felfüggesztése feloldva",
"demoLocked": "A demo fiók nem függeszthető fel.",
"selfLocked": "A saját fiókodat nem függesztheted fel."
},
"delete": {
"action": "Törlés",
"locked": "Ez a fiók itt nem törölhető.",
"title": "Törlöd a fiókot?",
"confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.",
"done": "{{email}} törölve"
}
}