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:
parent
8c727dd99e
commit
3f9c395b17
10 changed files with 461 additions and 32 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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))]">
|
||||
|
|
|
|||
62
frontend/src/components/Tabs.tsx
Normal file
62
frontend/src/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue