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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue