Fazekit

Code

"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;

More in Dashboards

View all →