"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, MotionConfig } from "motion/react";
import {
CheckCircle2,
ChevronDown,
Columns3,
Eye,
Filter,
Mail,
MailCheck,
Search,
ScrollText,
ShieldAlert,
Trash2,
UserCheck,
UserCog,
UserMinus,
UserPlus,
Users,
UserX,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { AuditLog } from "./audit-log";
import { Avatar, Button, FloatingMenu, IconButton, MenuItem, MenuLabel, MenuSep, ROLE_LABEL, STATUS_LABEL, focusRing, type Anchor } from "./admin-ui";
import { CURRENT_USER_ID, ROLES, ROLE_RANK, SEAT_LIMIT, SEED_AUDIT, SEED_NOW, SEED_USERS, WORKSPACE } from "./data";
import { ConfirmDelete, InviteDialog } from "./dialogs";
import { UserDrawer, type ProfilePatch } from "./user-drawer";
import { COLUMNS, UsersTable } from "./users-table";
import type { AdminUser, AuditAction, AuditEvent, ColumnKey, Invite, Role, Sort, SortKey, UserStatus } from "./types";
export type { AdminUser, AuditEvent, Invite, Role, UserStatus, Plan } from "./types";
export interface AdminUsersAppProps {
/** Members to manage. Defaults to 64 seeded, deterministic users. */
initialUsers?: AdminUser[];
initialAudit?: AuditEvent[];
/** The signed-in admin (can’t suspend, delete or demote themselves). */
currentUserId?: string;
workspaceName?: string;
seatLimit?: number;
pageSize?: number;
/** Reference time for "last active". Defaults to the seed time (demo) or the client clock (your data). */
now?: string | Date;
onUserUpdate?: (user: AdminUser) => void;
onUsersDelete?: (users: AdminUser[]) => void;
onInvite?: (invites: Invite[]) => void;
onSessionRevoke?: (userId: string, sessionId: string) => void;
onAuditEvent?: (event: AuditEvent) => void;
onChange?: (users: AdminUser[]) => void;
className?: string;
}
type MenuKind = "row" | "role" | "bulkRole" | "columns" | "filterRole" | "filterStatus" | "pageSize";
type MenuState = { kind: MenuKind; anchor: Anchor; userId?: string; trigger: HTMLElement | null; align?: "start" | "end" } | null;
type Toast = { id: number; text: string; undo?: () => void; tone?: "ok" | "warn" };
const DEFAULT_COLUMNS: ColumnKey[] = ["role", "status", "plan", "lastActive", "twoFactor"];
const STATUSES: UserStatus[] = ["active", "invited", "suspended"];
let seq = 0;
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${(++seq).toString(36)}`;
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
export function AdminUsersApp({
initialUsers,
initialAudit,
currentUserId = CURRENT_USER_ID,
workspaceName = WORKSPACE,
seatLimit = SEAT_LIMIT,
pageSize: pageSizeProp = 10,
now: nowProp,
onUserUpdate,
onUsersDelete,
onInvite,
onSessionRevoke,
onAuditEvent,
onChange,
className,
}: AdminUsersAppProps) {
const [users, setUsers] = React.useState<AdminUser[]>(initialUsers ?? SEED_USERS);
const [audit, setAudit] = React.useState<AuditEvent[]>(initialAudit ?? (initialUsers ? [] : SEED_AUDIT));
const [now, setNow] = React.useState(() => new Date(nowProp ?? SEED_NOW));
const [section, setSection] = React.useState<"users" | "audit">("users");
const [query, setQuery] = React.useState("");
const [roleFilter, setRoleFilter] = React.useState<Set<Role>>(() => new Set());
const [statusFilter, setStatusFilter] = React.useState<Set<UserStatus>>(() => new Set());
const [sort, setSort] = React.useState<Sort>({ key: "lastActive", dir: "desc" });
const [page, setPage] = React.useState(0);
const [pageSize, setPageSize] = React.useState(pageSizeProp);
const [columns, setColumns] = React.useState<Set<ColumnKey>>(() => new Set(DEFAULT_COLUMNS));
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [lastToggled, setLastToggled] = React.useState<string | null>(null);
const [activeId, setActiveId] = React.useState<string | null>(null);
const [inviteOpen, setInviteOpen] = React.useState(false);
const [confirmIds, setConfirmIds] = React.useState<string[] | null>(null);
const [menu, setMenu] = React.useState<MenuState>(null);
const [toasts, setToasts] = React.useState<Toast[]>([]);
const [bounds, setBounds] = React.useState({ width: 1280, height: 760 });
const rootRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
const drawerReturn = React.useRef<HTMLElement | null>(null);
const mountedAt = React.useRef(0);
React.useEffect(() => {
mountedAt.current = Date.now();
if (!nowProp && initialUsers) setNow(new Date());
}, [nowProp, initialUsers]);
React.useLayoutEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setBounds({ width: el.clientWidth, height: el.clientHeight }));
ro.observe(el);
return () => ro.disconnect();
}, []);
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(users);
}, [users]);
const stamp = () => {
const d = new Date(now.getTime() + (mountedAt.current ? Date.now() - mountedAt.current : 0));
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const me = users.find((u) => u.id === currentUserId);
const myName = me?.name ?? "You";
const byId = React.useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
/* ------------------------------ derived ------------------------------ */
const q = query.trim().toLowerCase();
const filtered = React.useMemo(() => {
const m = sort.dir === "asc" ? 1 : -1;
const statusRank: Record<UserStatus, number> = { active: 0, invited: 1, suspended: 2 };
const planRank = { Starter: 0, Pro: 1, Enterprise: 2 };
return users
.filter((u) => !q || u.name.toLowerCase().includes(q) || u.email.includes(q) || u.team.toLowerCase().includes(q) || u.title.toLowerCase().includes(q))
.filter((u) => !roleFilter.size || roleFilter.has(u.role))
.filter((u) => !statusFilter.size || statusFilter.has(u.status))
.sort((a, b) => {
let c = 0;
switch (sort.key) {
case "name":
c = a.name.localeCompare(b.name);
break;
case "role":
c = ROLE_RANK[a.role] - ROLE_RANK[b.role];
break;
case "status":
c = statusRank[a.status] - statusRank[b.status];
break;
case "plan":
c = planRank[a.plan] - planRank[b.plan];
break;
case "team":
c = a.team.localeCompare(b.team);
break;
case "lastActive":
c = (a.lastActive ?? "").localeCompare(b.lastActive ?? "");
break;
case "joined":
c = a.joined.localeCompare(b.joined);
break;
case "twoFactor":
c = Number(a.twoFactor) - Number(b.twoFactor);
break;
}
return c * m || a.name.localeCompare(b.name);
});
}, [users, q, roleFilter, statusFilter, sort]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, pageCount - 1);
const rows = filtered.slice(safePage * pageSize, safePage * pageSize + pageSize);
const rangeLabel = filtered.length ? `${safePage * pageSize + 1}–${Math.min(filtered.length, (safePage + 1) * pageSize)} of ${filtered.length}` : "0 results";
const sel = React.useMemo(() => new Set([...selected].filter((id) => byId.has(id))), [selected, byId]);
const selUsers = users.filter((u) => sel.has(u.id));
const active = activeId ? (byId.get(activeId) ?? null) : null;
const filtersOn = Boolean(q || roleFilter.size || statusFilter.size);
const stats = React.useMemo(() => {
const weekAgo = now.getTime() - 7 * 86_400_000;
return {
total: users.length,
active7: users.filter((u) => u.status === "active" && u.lastActive && new Date(u.lastActive).getTime() > weekAgo).length,
invited: users.filter((u) => u.status === "invited").length,
suspended: users.filter((u) => u.status === "suspended").length,
no2fa: users.filter((u) => u.status === "active" && !u.twoFactor).length,
};
}, [users, now]);
/* ------------------------------ helpers ------------------------------ */
const toast = React.useCallback((text: string, undo?: () => void, tone: Toast["tone"] = "ok") => {
const id = ++seq;
setToasts([{ id, text, undo, tone }]);
window.setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 5000);
}, []);
const dismiss = (id: number) => setToasts((ts) => ts.filter((t) => t.id !== id));
const log = (action: AuditAction, target: string, detail?: string) => {
const ev: AuditEvent = { id: uid("ev"), action, actor: myName, target, detail, ip: "10.0.4.12", at: stamp() };
setAudit((a) => [ev, ...a]);
onAuditEvent?.(ev);
};
const toAnchor = (el: HTMLElement): Anchor => {
const r = el.getBoundingClientRect();
const root = rootRef.current!.getBoundingClientRect();
return { top: r.top - root.top, bottom: r.bottom - root.top, left: r.left - root.left, right: r.right - root.left };
};
const openMenu = (kind: MenuKind, el: HTMLElement, extra?: { userId?: string; align?: "start" | "end" }) => setMenu({ kind, anchor: toAnchor(el), trigger: el, ...extra });
const closeMenu = React.useCallback(
(restore?: boolean) => {
setMenu((m) => {
if (restore && m?.trigger) requestAnimationFrame(() => m.trigger?.focus());
return null;
});
},
[],
);
/** Owners and yourself are protected from bulk/destructive changes. */
const protectedIds = (ids: string[]) => ids.filter((id) => id === currentUserId || byId.get(id)?.role === "owner");
const updateUsers = (ids: string[], fn: (u: AdminUser) => AdminUser) => {
const set = new Set(ids);
const next = users.map((u) => (set.has(u.id) ? fn(u) : u));
setUsers(next);
next.filter((u) => set.has(u.id)).forEach((u) => onUserUpdate?.(u));
return next;
};
const withUndo = (text: string, change: () => void) => {
const snapshot = users;
const auditSnap = audit;
change();
toast(text, () => {
setUsers(snapshot);
setAudit(auditSnap);
});
};
/* ------------------------------ actions ------------------------------ */
const changeRole = (ids: string[], role: Role) => {
const skip = new Set(protectedIds(ids));
const target = ids.filter((id) => !skip.has(id) && byId.get(id)?.role !== role);
if (!target.length) {
if (skip.size) toast("Owners and your own account can’t be changed here", undefined, "warn");
return;
}
withUndo(
target.length === 1 ? `${byId.get(target[0])?.name} is now ${ROLE_LABEL[role]}` : `Changed ${plural(target.length, "user")} to ${ROLE_LABEL[role]}${skip.size ? ` · skipped ${skip.size}` : ""}`,
() => {
updateUsers(target, (u) => ({ ...u, role, permissions: undefined }));
target.forEach((id) => {
const u = byId.get(id)!;
log("user.role_changed", u.name, `${u.role} → ${role}`);
});
},
);
setSelected((s) => (ids.length > 1 ? new Set() : s));
};
const setStatus = (ids: string[], status: "active" | "suspended") => {
const skip = new Set(protectedIds(ids));
const target = ids.filter((id) => !skip.has(id) && byId.get(id)?.status !== status && byId.get(id)?.status !== "invited");
if (!target.length) {
toast(skip.size ? "Owners and your own account can’t be suspended" : "Nothing to change", undefined, "warn");
return;
}
const verb = status === "suspended" ? "Suspended" : "Reactivated";
withUndo(`${verb} ${target.length === 1 ? byId.get(target[0])?.name : plural(target.length, "user")}${skip.size ? ` · skipped ${skip.size}` : ""}`, () => {
updateUsers(target, (u) => ({ ...u, status, sessions: status === "suspended" ? [] : u.sessions }));
target.forEach((id) => log(status === "suspended" ? "user.suspended" : "user.reactivated", byId.get(id)!.name));
});
setSelected(new Set());
};
const askDelete = (ids: string[]) => {
const skip = new Set(protectedIds(ids));
const target = ids.filter((id) => !skip.has(id));
if (!target.length) return toast("Owners and your own account can’t be removed", undefined, "warn");
if (skip.size) toast(`Skipping ${plural(skip.size, "protected account")}`, undefined, "warn");
setConfirmIds(target);
};
const doDelete = () => {
const ids = confirmIds ?? [];
const gone = users.filter((u) => ids.includes(u.id));
setConfirmIds(null);
if (activeId && ids.includes(activeId)) setActiveId(null);
withUndo(`Removed ${gone.length === 1 ? gone[0].name : plural(gone.length, "user")}`, () => {
setUsers((us) => us.filter((u) => !ids.includes(u.id)));
gone.forEach((u) => log("user.deleted", u.name));
});
setSelected(new Set());
onUsersDelete?.(gone);
};
const saveProfile = (id: string, patch: ProfilePatch) => {
const before = byId.get(id);
if (!before) return;
const changed = (Object.keys(patch) as (keyof ProfilePatch)[]).filter((k) => patch[k] !== before[k]);
withUndo(`Saved ${before.name.split(" ")[0]}’s profile`, () => {
updateUsers([id], (u) => ({ ...u, ...patch, permissions: patch.role !== u.role ? undefined : u.permissions }));
if (patch.role !== before.role) log("user.role_changed", patch.name, `${before.role} → ${patch.role}`);
const other = changed.filter((k) => k !== "role");
if (other.length) log("user.updated", patch.name, other.join(", "));
});
};
const savePermissions = (id: string, overrides: AdminUser["permissions"]) => {
const u = byId.get(id);
if (!u) return;
withUndo(`Updated permissions for ${u.name.split(" ")[0]}`, () => {
updateUsers([id], (x) => ({ ...x, permissions: overrides }));
log("permissions.updated", u.name, overrides ? Object.keys(overrides).join(", ") : "reset to role");
});
};
const revoke = (id: string, sessionIds: string[]) => {
const u = byId.get(id);
if (!u) return;
const list = u.sessions.filter((s) => sessionIds.includes(s.id));
updateUsers([id], (x) => ({ ...x, sessions: x.sessions.filter((s) => !sessionIds.includes(s.id)) }));
list.forEach((s) => {
log("session.revoked", u.name, `${s.browser} · ${s.os}`);
onSessionRevoke?.(id, s.id);
});
toast(list.length === 1 ? `Signed out ${list[0].browser} on ${list[0].os}` : `Revoked ${plural(list.length, "session")}`);
};
const invite = (invites: Invite[]) => {
const created: AdminUser[] = invites.map((inv) => {
const local = inv.email.split("@")[0];
const name = local
.split(/[._-]+/)
.filter(Boolean)
.map((p) => p[0].toUpperCase() + p.slice(1))
.join(" ");
return {
id: uid("u"),
name: name || inv.email,
email: inv.email,
title: "",
team: inv.team,
role: inv.role,
status: "invited",
plan: "Pro",
lastActive: null,
joined: stamp(),
twoFactor: false,
location: "—",
sessions: [],
activity: [{ id: uid("a"), kind: "invite", text: `Invited by ${myName}`, at: stamp() }],
};
});
setUsers((us) => [...created, ...us]);
created.forEach((u) => log("user.invited", u.email, `as ${u.role}`));
setInviteOpen(false);
setSection("users");
setSort({ key: "joined", dir: "desc" });
setPage(0);
toast(`Sent ${plural(created.length, "invite")}`);
onInvite?.(invites);
};
const resend = (u: AdminUser) => {
log("user.invited", u.email, "resent");
toast(`Invite resent to ${u.email}`);
};
/* ------------------------------ selection ------------------------------ */
const toggle = (id: string, shift: boolean) => {
const n = new Set(sel);
if (shift && lastToggled) {
const ids = rows.map((r) => r.id);
const a = ids.indexOf(lastToggled);
const b = ids.indexOf(id);
if (a >= 0 && b >= 0) {
const on = !sel.has(id);
ids.slice(Math.min(a, b), Math.max(a, b) + 1).forEach((x) => (on ? n.add(x) : n.delete(x)));
setSelected(n);
setLastToggled(id);
return;
}
}
if (n.has(id)) n.delete(id);
else n.add(id);
setSelected(n);
setLastToggled(id);
};
const togglePage = (all: boolean) => {
const n = new Set(sel);
rows.forEach((r) => (all ? n.add(r.id) : n.delete(r.id)));
setSelected(n);
};
const allFilteredSelected = filtered.length > 0 && filtered.every((u) => sel.has(u.id));
const pageAllSelected = rows.length > 0 && rows.every((u) => sel.has(u.id));
/* ------------------------------ keyboard ------------------------------ */
const keyRef = React.useRef<(e: KeyboardEvent) => void>(() => {});
React.useLayoutEffect(() => {
keyRef.current = (e) => {
const t = e.target as HTMLElement;
if (e.key === "Escape") {
if (menu || inviteOpen || confirmIds) return;
if (active) {
setActiveId(null);
requestAnimationFrame(() => drawerReturn.current?.focus());
return;
}
if (sel.size) setSelected(new Set());
return;
}
if (e.metaKey || e.ctrlKey || e.altKey || inviteOpen || confirmIds || active || t.closest("input,textarea,select,[contenteditable]")) return;
if (e.key === "/") {
e.preventDefault();
setSection("users");
requestAnimationFrame(() => searchRef.current?.focus());
} else if (e.key === "i") {
e.preventDefault();
setInviteOpen(true);
}
};
});
React.useEffect(() => {
const on = (e: KeyboardEvent) => keyRef.current(e);
window.addEventListener("keydown", on);
return () => window.removeEventListener("keydown", on);
}, []);
/* ------------------------------ menus ------------------------------ */
const counts = React.useMemo(() => {
const r: Record<string, number> = {};
for (const u of users) {
r[u.role] = (r[u.role] ?? 0) + 1;
r[u.status] = (r[u.status] ?? 0) + 1;
}
return r;
}, [users]);
const menuContent = () => {
if (!menu) return null;
const u = menu.userId ? byId.get(menu.userId) : undefined;
switch (menu.kind) {
case "row":
if (!u) return null;
return (
<>
<MenuItem icon={Eye} onSelect={() => (closeMenu(), openUser(u.id, menu.trigger))}>
View details
</MenuItem>
<MenuItem icon={UserCog} disabled={u.role === "owner" || u.id === currentUserId} onSelect={() => setMenu({ ...menu, kind: "role" })}>
Change role…
</MenuItem>
{u.status === "invited" && (
<MenuItem icon={MailCheck} onSelect={() => (closeMenu(true), resend(u))}>
Resend invite
</MenuItem>
)}
<MenuItem icon={Mail} onSelect={() => (closeMenu(true), navigator.clipboard?.writeText(u.email).catch(() => {}), toast("Email copied"))}>
Copy email
</MenuItem>
{u.status !== "invited" && (
<MenuItem
icon={u.status === "suspended" ? UserCheck : UserMinus}
disabled={u.role === "owner" || u.id === currentUserId}
onSelect={() => (closeMenu(true), setStatus([u.id], u.status === "suspended" ? "active" : "suspended"))}
>
{u.status === "suspended" ? "Reactivate" : "Suspend"}
</MenuItem>
)}
<MenuSep />
<MenuItem icon={Trash2} danger disabled={u.role === "owner" || u.id === currentUserId} onSelect={() => (closeMenu(), askDelete([u.id]))}>
Remove user
</MenuItem>
</>
);
case "role":
case "bulkRole": {
const ids = menu.kind === "role" && u ? [u.id] : [...sel];
const current = menu.kind === "role" ? u?.role : undefined;
return (
<>
<MenuLabel>{menu.kind === "role" ? `Role for ${u?.name.split(" ")[0]}` : `Set role for ${plural(ids.length, "user")}`}</MenuLabel>
{ROLES.filter((r) => r.id !== "owner").map((r) => (
<MenuItem key={r.id} kind="menuitemradio" checked={current === r.id} onSelect={() => (closeMenu(true), changeRole(ids, r.id))}>
<span className="block font-medium">{r.label}</span>
<span className="block text-[11.5px] leading-snug text-muted-foreground">{r.hint}</span>
</MenuItem>
))}
</>
);
}
case "columns":
return (
<>
<MenuLabel>Visible columns</MenuLabel>
{COLUMNS.map((c) => (
<MenuItem
key={c.key}
kind="menuitemcheckbox"
checked={columns.has(c.key)}
onSelect={() =>
setColumns((s) => {
const n = new Set(s);
if (n.has(c.key)) n.delete(c.key);
else n.add(c.key);
return n;
})
}
>
{c.label}
</MenuItem>
))}
<MenuSep />
<MenuItem onSelect={() => setColumns(new Set(DEFAULT_COLUMNS))}>Reset to default</MenuItem>
</>
);
case "filterRole":
return (
<>
<MenuLabel>Filter by role</MenuLabel>
{ROLES.map((r) => (
<MenuItem
key={r.id}
kind="menuitemcheckbox"
checked={roleFilter.has(r.id)}
hint={counts[r.id] ?? 0}
onSelect={() => {
setPage(0);
setRoleFilter((s) => {
const n = new Set(s);
if (n.has(r.id)) n.delete(r.id);
else n.add(r.id);
return n;
});
}}
>
{r.label}
</MenuItem>
))}
{roleFilter.size > 0 && (
<>
<MenuSep />
<MenuItem onSelect={() => (setRoleFilter(new Set()), closeMenu(true))}>Clear role filter</MenuItem>
</>
)}
</>
);
case "filterStatus":
return (
<>
<MenuLabel>Filter by status</MenuLabel>
{STATUSES.map((s) => (
<MenuItem
key={s}
kind="menuitemcheckbox"
checked={statusFilter.has(s)}
hint={counts[s] ?? 0}
onSelect={() => {
setPage(0);
setStatusFilter((f) => {
const n = new Set(f);
if (n.has(s)) n.delete(s);
else n.add(s);
return n;
});
}}
>
<span className="inline-flex items-center gap-2">
<span className={cn("size-2 rounded-full", s === "active" ? "bg-emerald-500" : s === "invited" ? "bg-amber-500" : "bg-rose-500")} aria-hidden />
{STATUS_LABEL[s]}
</span>
</MenuItem>
))}
{statusFilter.size > 0 && (
<>
<MenuSep />
<MenuItem onSelect={() => (setStatusFilter(new Set()), closeMenu(true))}>Clear status filter</MenuItem>
</>
)}
</>
);
case "pageSize":
return (
<>
<MenuLabel>Rows per page</MenuLabel>
{[10, 20, 50].map((n) => (
<MenuItem key={n} kind="menuitemradio" checked={pageSize === n} onSelect={() => (setPageSize(n), setPage(0), closeMenu(true))}>
{n} rows
</MenuItem>
))}
</>
);
}
};
const openUser = (id: string, from?: HTMLElement | null) => {
drawerReturn.current = from ?? (document.activeElement as HTMLElement | null);
setActiveId(id);
};
const clearFilters = () => {
setQuery("");
setRoleFilter(new Set());
setStatusFilter(new Set());
setPage(0);
};
/* -------------------------------- render ------------------------------- */
const statCards: { label: string; value: number; sub: string; icon: typeof Users; tint: string; onClick?: () => void; activeWhen?: boolean }[] = [
{ label: "Members", value: stats.total, sub: `${seatLimit - stats.total} of ${seatLimit} seats free`, icon: Users, tint: "from-indigo-500 to-sky-500" },
{ label: "Active this week", value: stats.active7, sub: `${Math.round((stats.active7 / Math.max(1, stats.total)) * 100)}% of workspace`, icon: UserCheck, tint: "from-emerald-500 to-teal-500", onClick: () => (setStatusFilter(new Set(["active"])), setPage(0)), activeWhen: statusFilter.size === 1 && statusFilter.has("active") },
{ label: "Pending invites", value: stats.invited, sub: "Awaiting sign-up", icon: UserPlus, tint: "from-amber-500 to-orange-500", onClick: () => (setStatusFilter(new Set(["invited"])), setPage(0)), activeWhen: statusFilter.size === 1 && statusFilter.has("invited") },
{ label: "Suspended", value: stats.suspended, sub: `${stats.no2fa} active without 2FA`, icon: UserX, tint: "from-rose-500 to-pink-500", onClick: () => (setStatusFilter(new Set(["suspended"])), setPage(0)), activeWhen: statusFilter.size === 1 && statusFilter.has("suspended") },
];
const nav = [
{ id: "users" as const, label: "Users", icon: Users, count: users.length },
{ id: "audit" as const, label: "Audit log", icon: ScrollText, count: undefined },
];
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
<div inert={inviteOpen || confirmIds || active ? true : undefined} className="flex min-w-0 flex-1">
{/* Rail */}
<aside aria-label="Admin" className="hidden w-60 shrink-0 flex-col border-r bg-muted/25 px-3 pb-4 lg:flex dark:bg-muted/15">
<div className="flex h-14 items-center gap-2.5 px-2">
<span aria-hidden className="grid size-8 place-items-center rounded-xl bg-gradient-to-br from-zinc-800 to-zinc-950 text-[13px] font-bold text-white shadow-md dark:from-zinc-100 dark:to-zinc-300 dark:text-zinc-900">
{workspaceName[0]}
</span>
<div className="min-w-0">
<p className="truncate text-[13.5px] font-semibold leading-tight">{workspaceName}</p>
<p className="text-[11px] text-muted-foreground">Enterprise · Admin console</p>
</div>
</div>
<p className="mb-1.5 mt-4 px-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">People</p>
<LayoutGroup id="admin-nav">
<nav aria-label="Admin sections">
<ul className="space-y-0.5">
{nav.map((n) => (
<li key={n.id}>
<button
type="button"
onClick={() => setSection(n.id)}
aria-current={section === n.id ? "page" : undefined}
className={cn(
"relative flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-[13px] transition-colors",
section === n.id ? "font-medium text-foreground" : "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
focusRing,
)}
>
{section === n.id && <motion.span layoutId="admin-nav-pill" className="absolute inset-0 rounded-lg bg-background shadow-sm ring-1 ring-border dark:bg-muted" transition={{ type: "spring", stiffness: 500, damping: 40 }} />}
<n.icon className="relative size-4" aria-hidden />
<span className="relative flex-1 text-left">{n.label}</span>
{n.count !== undefined && <span className="relative text-[11px] tabular-nums text-muted-foreground">{n.count}</span>}
</button>
</li>
))}
</ul>
</nav>
</LayoutGroup>
<div className="mt-auto rounded-xl border bg-background/70 p-3 dark:bg-muted/30">
<div className="flex items-center gap-2 text-[12.5px] font-medium">
<ShieldAlert className="size-4 text-amber-500" aria-hidden />
Security check
</div>
<p className="mt-1 text-[11.5px] leading-relaxed text-muted-foreground">
{stats.no2fa} active {stats.no2fa === 1 ? "member hasn’t" : "members haven’t"} turned on two-factor authentication.
</p>
<div className="mt-2.5 h-1.5 overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400"
initial={{ width: 0 }}
animate={{ width: `${Math.round((1 - stats.no2fa / Math.max(1, stats.total - stats.invited)) * 100)}%` }}
transition={{ type: "spring", stiffness: 90, damping: 20, delay: 0.2 }}
/>
</div>
{me && (
<div className="mt-3 flex items-center gap-2 border-t pt-3">
<Avatar name={me.name} size={26} status="active" />
<div className="min-w-0">
<p className="truncate text-[12px] font-medium">{me.name}</p>
<p className="truncate text-[11px] text-muted-foreground">{me.email}</p>
</div>
</div>
)}
</div>
</aside>
{/* Main */}
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 px-3 pb-3 pt-4 sm:px-5">
<div className="min-w-0 flex-1">
<h1 className="text-[19px] font-semibold tracking-tight">{section === "users" ? "Users" : "Audit log"}</h1>
<p className="truncate text-[12.5px] text-muted-foreground">
{section === "users" ? "Manage who has access to the workspace and what they can do." : "Every change to members, roles and sessions."}
</p>
</div>
<div role="tablist" aria-label="Admin sections" className="flex rounded-lg bg-muted p-0.5 text-[12px] font-medium lg:hidden">
{nav.map((n) => (
<button
key={n.id}
type="button"
role="tab"
aria-selected={section === n.id}
onClick={() => setSection(n.id)}
className={cn("h-7 rounded-md px-2.5 transition-colors", section === n.id ? "bg-background text-foreground shadow-sm dark:bg-accent" : "text-muted-foreground", focusRing)}
>
{n.label}
</button>
))}
</div>
<Button variant="primary" onClick={() => setInviteOpen(true)} title="Invite (I)">
<UserPlus className="size-4" aria-hidden />
<span className="hidden sm:inline">Invite users</span>
</Button>
</header>
{section === "audit" ? (
<AuditLog events={audit} now={now} />
) : (
<>
{/* Stats */}
<div className="grid shrink-0 grid-cols-2 gap-2 px-3 pb-3 sm:gap-3 sm:px-5 xl:grid-cols-4">
{statCards.map((s, i) => {
const Comp = s.onClick ? "button" : "div";
return (
<motion.div key={s.label} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05, duration: 0.3 }}>
<Comp
{...(s.onClick ? { type: "button" as const, onClick: s.onClick, "aria-pressed": s.activeWhen } : {})}
className={cn(
"group relative flex w-full items-center gap-3 overflow-hidden rounded-xl border bg-card p-3 text-left transition",
s.onClick && "hover:border-foreground/15 hover:shadow-sm",
s.activeWhen && "border-primary/50 ring-1 ring-primary/40",
focusRing,
)}
>
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg bg-gradient-to-br text-white shadow-sm", s.tint)}>
<s.icon className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-[11.5px] text-muted-foreground">{s.label}</span>
<span className="block text-lg font-semibold leading-tight tabular-nums">{s.value}</span>
<span className="hidden truncate text-[11px] text-muted-foreground sm:block">{s.sub}</span>
</span>
</Comp>
</motion.div>
);
})}
</div>
{/* Toolbar */}
<div className="flex shrink-0 flex-wrap items-center gap-2 px-3 pb-3 sm:px-5">
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<label htmlFor="admin-search" className="sr-only">
Search users
</label>
<input
id="admin-search"
ref={searchRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(0);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setQuery("");
e.currentTarget.blur();
}
}}
placeholder="Search name, email, team…"
className="h-9 w-full rounded-lg border bg-background pl-8 pr-8 text-[13px] outline-none transition placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 dark:bg-muted/30"
/>
{query ? (
<button type="button" aria-label="Clear search" onClick={() => setQuery("")} className="absolute right-2 top-1/2 grid size-5 -translate-y-1/2 place-items-center rounded text-muted-foreground hover:text-foreground">
<X className="size-3.5" />
</button>
) : (
<kbd className="pointer-events-none absolute right-2 top-1/2 hidden h-5 -translate-y-1/2 items-center rounded border border-b-2 bg-muted px-1.5 text-[10px] text-muted-foreground sm:inline-flex">/</kbd>
)}
</div>
<FilterButton label="Role" values={[...roleFilter].map((r) => ROLE_LABEL[r])} onClick={(e) => openMenu("filterRole", e.currentTarget)} />
<FilterButton label="Status" values={[...statusFilter].map((s) => STATUS_LABEL[s])} onClick={(e) => openMenu("filterStatus", e.currentTarget)} />
<AnimatePresence>
{filtersOn && (
<motion.button
type="button"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
onClick={clearFilters}
className={cn("h-9 rounded-lg px-2.5 text-[12.5px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground", focusRing)}
>
Reset
</motion.button>
)}
</AnimatePresence>
<IconButton label="Columns" aria-haspopup="menu" onClick={(e) => openMenu("columns", e.currentTarget, { align: "end" })} className="ml-auto size-9 border">
<Columns3 className="size-4" />
</IconButton>
</div>
<div className="mx-3 mb-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card sm:mx-5">
<UsersTable
rows={rows}
total={filtered.length}
filtered={filtersOn}
selected={sel}
columns={columns}
sort={sort}
now={now}
currentUserId={currentUserId}
activeId={activeId}
page={safePage}
pageCount={pageCount}
pageSize={pageSize}
rangeLabel={rangeLabel}
onSort={(key: SortKey) => setSort((s) => ({ key, dir: s.key === key ? (s.dir === "asc" ? "desc" : "asc") : key === "name" || key === "team" || key === "role" ? "asc" : "desc" }))}
onToggle={toggle}
onTogglePage={togglePage}
onOpen={(u) => openUser(u.id)}
onRowMenu={(a, u, t) => setMenu({ kind: "row", anchor: a, userId: u.id, trigger: t, align: "end" })}
onRoleMenu={(a, u, t) => setMenu({ kind: "role", anchor: a, userId: u.id, trigger: t })}
onPage={setPage}
onPageSizeMenu={(a, t) => setMenu({ kind: "pageSize", anchor: a, trigger: t })}
onClearFilters={clearFilters}
toAnchor={toAnchor}
/>
</div>
</>
)}
</main>
</div>
{/* Bulk bar */}
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-30 flex justify-center px-3">
<AnimatePresence>
{section === "users" && sel.size > 0 && !active && (
<motion.div
role="toolbar"
aria-label="Bulk actions"
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ type: "spring", stiffness: 480, damping: 34 }}
className="pointer-events-auto flex max-w-full flex-col items-stretch gap-1 rounded-2xl border bg-popover p-1.5 text-popover-foreground shadow-2xl shadow-black/15 dark:shadow-black/60"
>
{pageAllSelected && !allFilteredSelected && filtered.length > rows.length && (
<button
type="button"
onClick={() => setSelected(new Set(filtered.map((u) => u.id)))}
className={cn("rounded-xl bg-primary/10 px-3 py-1.5 text-[12px] font-medium text-primary hover:bg-primary/15", focusRing)}
>
Select all {filtered.length} matching users
</button>
)}
<div className="flex items-center gap-1">
<span className="grid h-8 min-w-8 place-items-center rounded-lg bg-primary px-2 text-[12.5px] font-semibold tabular-nums text-primary-foreground">{sel.size}</span>
<span className="mr-1 hidden text-[12.5px] font-medium sm:inline">selected</span>
<span aria-hidden className="mx-1 hidden h-5 w-px bg-border sm:block" />
<Button variant="ghost" className="h-8 px-2.5 text-[12.5px] text-foreground" aria-haspopup="menu" onClick={(e) => openMenu("bulkRole", e.currentTarget)}>
<UserCog className="size-4" aria-hidden />
<span className="hidden sm:inline">Change role</span>
<ChevronDown className="size-3.5 text-muted-foreground" aria-hidden />
</Button>
{selUsers.every((u) => u.status === "suspended") ? (
<Button variant="ghost" className="h-8 px-2.5 text-[12.5px] text-foreground" onClick={() => setStatus([...sel], "active")}>
<UserCheck className="size-4" aria-hidden />
<span className="hidden sm:inline">Reactivate</span>
</Button>
) : (
<Button variant="ghost" className="h-8 px-2.5 text-[12.5px] text-foreground" onClick={() => setStatus([...sel], "suspended")}>
<UserMinus className="size-4" aria-hidden />
<span className="hidden sm:inline">Suspend</span>
</Button>
)}
<Button variant="ghost" className="h-8 px-2.5 text-[12.5px] text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => askDelete([...sel])}>
<Trash2 className="size-4" aria-hidden />
<span className="hidden sm:inline">Delete</span>
</Button>
<IconButton label="Clear selection" onClick={() => setSelected(new Set())} className="size-8">
<X className="size-4" />
</IconButton>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Drawer */}
<AnimatePresence>
{active && (
<>
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setActiveId(null)}
className="absolute inset-0 z-40 bg-black/20 dark:bg-black/50"
/>
<motion.aside
key="drawer"
role="dialog"
aria-modal
aria-labelledby="user-drawer-title"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 380, damping: 40 }}
className="absolute inset-y-0 right-0 z-50 w-full border-l bg-background shadow-2xl shadow-black/20 sm:w-[500px]"
onKeyDown={(e) => {
if (e.key === "Escape" && !menu) {
e.stopPropagation();
setActiveId(null);
requestAnimationFrame(() => drawerReturn.current?.focus());
}
}}
ref={(el) => {
if (el && !el.contains(document.activeElement)) requestAnimationFrame(() => el.querySelector<HTMLElement>("[role=tab][aria-selected=true]")?.focus());
}}
>
<UserDrawer
key={active.id}
user={active}
now={now}
isSelf={active.id === currentUserId}
emailTaken={(email, id) => users.some((u) => u.id !== id && u.email.toLowerCase() === email.trim().toLowerCase())}
onClose={() => {
setActiveId(null);
requestAnimationFrame(() => drawerReturn.current?.focus());
}}
onSave={(p) => saveProfile(active.id, p)}
onPermissions={(o) => savePermissions(active.id, o)}
onRevoke={(sid) => revoke(active.id, [sid])}
onRevokeOthers={() => revoke(active.id, active.sessions.filter((s) => !s.current).map((s) => s.id))}
onSuspendToggle={() => setStatus([active.id], active.status === "suspended" ? "active" : "suspended")}
onDelete={() => askDelete([active.id])}
onResendInvite={() => resend(active)}
/>
</motion.aside>
</>
)}
</AnimatePresence>
<AnimatePresence>
{menu && (
<FloatingMenu
key={`${menu.kind}-${menu.userId ?? ""}`}
anchor={menu.anchor}
bounds={bounds}
label={menu.kind === "row" ? "User actions" : menu.kind === "columns" ? "Columns" : menu.kind.startsWith("filter") ? "Filter" : menu.kind === "pageSize" ? "Rows per page" : "Change role"}
align={menu.align}
width={menu.kind === "role" || menu.kind === "bulkRole" ? 272 : 224}
onClose={closeMenu}
>
{menuContent()}
</FloatingMenu>
)}
</AnimatePresence>
<InviteDialog open={inviteOpen} existing={new Set(users.map((u) => u.email.toLowerCase()))} seatsLeft={Math.max(0, seatLimit - users.length)} onClose={() => setInviteOpen(false)} onInvite={invite} />
<ConfirmDelete users={confirmIds ? users.filter((u) => confirmIds.includes(u.id)) : null} onClose={() => setConfirmIds(null)} onConfirm={doDelete} />
{/* Toasts */}
<div aria-live="polite" className="pointer-events-none absolute inset-x-0 bottom-4 z-[90] flex flex-col items-center gap-2 px-4">
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 24, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.95, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 500, damping: 32 }}
role="status"
className="pointer-events-auto relative flex min-w-64 max-w-full items-center gap-2.5 overflow-hidden rounded-xl bg-foreground py-2.5 pl-3 pr-2 text-[13px] text-background shadow-xl shadow-black/20"
>
{t.tone === "warn" ? (
<ShieldAlert className="size-4 shrink-0 text-amber-400 dark:text-amber-600" aria-hidden />
) : (
<CheckCircle2 className="size-4 shrink-0 text-emerald-400 dark:text-emerald-600" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate font-medium">{t.text}</span>
{t.undo && (
<button
type="button"
onClick={() => {
t.undo?.();
dismiss(t.id);
}}
className="h-7 rounded-md px-2 text-xs font-semibold text-background/90 outline-none hover:bg-background/10 focus-visible:ring-2 focus-visible:ring-background/60"
>
Undo
</button>
)}
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(t.id)}
className="grid size-6 place-items-center rounded-md text-background/60 outline-none hover:bg-background/10 hover:text-background focus-visible:ring-2 focus-visible:ring-background/60"
>
<X className="size-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</MotionConfig>
);
}
function FilterButton({ label, values, onClick }: { label: string; values: string[]; onClick: (e: React.MouseEvent<HTMLButtonElement>) => void }) {
const on = values.length > 0;
return (
<button
type="button"
aria-haspopup="menu"
onClick={onClick}
className={cn(
"inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-3 text-[12.5px] font-medium transition",
on ? "border-primary/40 bg-primary/[0.06] text-foreground dark:bg-primary/10" : "border-dashed text-muted-foreground hover:bg-accent hover:text-foreground",
focusRing,
)}
>
<Filter className="size-3.5" aria-hidden />
{label}
{on && (
<>
<span aria-hidden className="h-4 w-px bg-border" />
<span className="max-w-28 truncate text-primary">{values.length > 2 ? `${values.length} selected` : values.join(", ")}</span>
</>
)}
</button>
);
}
export default AdminUsersApp;