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
|
|
@ -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))]">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue