"use client"; /** * TASK-0133 — Members tab: list/search, role change, suspend/unsuspend. * * Suspend = `User.status SUSPENDED` (the SCIM deactivation semantics: login + * in-flight sessions are blocked by the AuthGuard re-check). The OWNER is * protected: their role cannot be changed (ownership lives on * `Organization.ownerId`) — the API 403s an owner demotion, so the UI disables * the owner's controls. */ import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { orgAdminApi, type OrgMemberRow, type OrgMemberRole } from "@/lib/org-admin"; import { isApiError } from "@/lib/api"; import { toast } from "@/stores/ui-store"; import { Section, inputCls } from "@/components/settings/settings-shell"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; const ROLES: OrgMemberRole[] = ["ADMIN", "MEMBER", "GUEST"]; export function AdminMembersTab({ orgId }: { orgId: string }) { const qc = useQueryClient(); const [q, setQ] = useState(""); const [search, setSearch] = useState(""); const debounceRef = useRef(undefined); const [confirm, setConfirm] = useState<{ userId: string; email: string } | null>(null); const members = useQuery({ queryKey: ["orgs", orgId, "admin", "members", search], queryFn: () => orgAdminApi.members(orgId, search || undefined), retry: false, }); const invalidate = () => qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "members"] }); const roleMut = useMutation({ mutationFn: (v: { userId: string; role: OrgMemberRole }) => orgAdminApi.memberUpdate(orgId, v.userId, { role: v.role }), onSuccess: () => { toast("success", "Role updated"); invalidate(); }, onError: (e) => toast("error", "Could not update role", isApiError(e) ? e.message : undefined), }); const suspendMut = useMutation({ mutationFn: (v: { userId: string; suspend: boolean }) => orgAdminApi.memberUpdate(orgId, v.userId, { suspend: v.suspend }), onSuccess: (_, v) => { toast("success", v.suspend ? "Member suspended" : "Member unsuspended"); setConfirm(null); invalidate(); }, onError: (e) => { setConfirm(null); toast("error", "Could not update member", isApiError(e) ? e.message : undefined); }, }); const data = members.data?.data ?? []; return (
{ setQ(e.target.value); // Debounce the search into the query key (150 ms). window.clearTimeout(debounceRef.current); debounceRef.current = window.setTimeout(() => setSearch(e.target.value), 150); }} placeholder="Search by name or email…" className={`${inputCls} pl-8`} aria-label="Search members" />
{members.isLoading ? (

Loading members…

) : members.isError ? (

Could not load members.

) : data.length === 0 ? (

No members found.

) : ( {data.map((m) => ( roleMut.mutate({ userId: m.userId, role })} onSuspend={() => setConfirm({ userId: m.userId, email: m.email })} onUnsuspend={() => suspendMut.mutate({ userId: m.userId, suspend: false })} /> ))}
Organization members
Member Role Status Last active
)} confirm && suspendMut.mutate({ userId: confirm.userId, suspend: true })} onClose={() => setConfirm(null)} />
); } function MemberRow({ m, busy, onRole, onSuspend, onUnsuspend, }: { m: OrgMemberRow; busy: boolean; onRole: (r: OrgMemberRole) => void; onSuspend: () => void; onUnsuspend: () => void; }) { const isOwner = m.role === "OWNER"; const suspended = m.status === "SUSPENDED"; return (
{m.name}
{m.email}
{isOwner ? ( OWNER ) : ( )} {m.status} {isOwner ? ( — ) : suspended ? ( ) : ( )} ); }