1"use client";
2
3
12import { useRef, useState } from "react";
13import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
14import { Search } from "lucide-react";
15import { orgAdminApi, type OrgMemberRow, type OrgMemberRole } from "@/lib/org-admin";
16import { isApiError } from "@/lib/api";
17import { toast } from "@/stores/ui-store";
18import { Section, inputCls } from "@/components/settings/settings-shell";
19import { ConfirmDialog } from "@/components/ui/confirm-dialog";
20
21const ROLES: OrgMemberRole[] = ["ADMIN", "MEMBER", "GUEST"];
22
23export function AdminMembersTab({ orgId }: { orgId: string }) {
24 const qc = useQueryClient();
25 const [q, setQ] = useState("");
26 const [search, setSearch] = useState("");
27 const debounceRef = useRef<number | undefined>(undefined);
28 const [confirm, setConfirm] = useState<{ userId: string; email: string } | null>(null);
29
30 const members = useQuery({
31 queryKey: ["orgs", orgId, "admin", "members", search],
32 queryFn: () => orgAdminApi.members(orgId, search || undefined),
33 retry: false,
34 });
35
36 const invalidate = () => qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "members"] });
37
38 const roleMut = useMutation({
39 mutationFn: (v: { userId: string; role: OrgMemberRole }) => orgAdminApi.memberUpdate(orgId, v.userId, { role: v.role }),
40 onSuccess: () => {
41 toast("success", "Role updated");
42 invalidate();
43 },
44 onError: (e) => toast("error", "Could not update role", isApiError(e) ? e.message : undefined),
45 });
46
47 const suspendMut = useMutation({
48 mutationFn: (v: { userId: string; suspend: boolean }) => orgAdminApi.memberUpdate(orgId, v.userId, { suspend: v.suspend }),
49 onSuccess: (_, v) => {
50 toast("success", v.suspend ? "Member suspended" : "Member unsuspended");
51 setConfirm(null);
52 invalidate();
53 },
54 onError: (e) => {
55 setConfirm(null);
56 toast("error", "Could not update member", isApiError(e) ? e.message : undefined);
57 },
58 });
59
60 const data = members.data?.data ?? [];
61
62 return (
63 <Section
64 title="Members"
65 description="Role changes apply immediately (DB-driven authorization). Suspend blocks login + in-flight sessions."
66 >
67 <div className="mb-3 flex items-center gap-2">
68 <div className="relative flex-1">
69 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
70 <input
71 value={q}
72 onChange={(e) => {
73 setQ(e.target.value);
74
75 window.clearTimeout(debounceRef.current);
76 debounceRef.current = window.setTimeout(() => setSearch(e.target.value), 150);
77 }}
78 placeholder="Search by name or email…"
79 className={`${inputCls} pl-8`}
80 aria-label="Search members"
81 />
82 </div>
83 </div>
84
85 {members.isLoading ? (
86 <p className="text-sm text-muted-foreground" role="status">Loading members…</p>
87 ) : members.isError ? (
88 <p className="text-sm text-destructive" role="alert">Could not load members.</p>
89 ) : data.length === 0 ? (
90 <p className="text-sm text-muted-foreground">No members found.</p>
91 ) : (
92 <table className="w-full text-sm">
93 <caption className="sr-only">Organization members</caption>
94 <thead>
95 <tr className="border-b border-border text-left text-xs text-muted-foreground">
96 <th className="py-2 pr-3 font-medium">Member</th>
97 <th className="py-2 pr-3 font-medium">Role</th>
98 <th className="py-2 pr-3 font-medium">Status</th>
99 <th className="py-2 font-medium">Last active</th>
100 </tr>
101 </thead>
102 <tbody>
103 {data.map((m) => (
104 <MemberRow
105 key={m.userId}
106 m={m}
107 busy={roleMut.isPending || suspendMut.isPending}
108 onRole={(role) => roleMut.mutate({ userId: m.userId, role })}
109 onSuspend={() => setConfirm({ userId: m.userId, email: m.email })}
110 onUnsuspend={() => suspendMut.mutate({ userId: m.userId, suspend: false })}
111 />
112 ))}
113 </tbody>
114 </table>
115 )}
116
117 <ConfirmDialog
118 open={confirm !== null}
119 title="Suspend this member?"
120 description={
121 confirm ? `${confirm.email} will be blocked from logging in and their in-flight sessions end. Data is kept.` : undefined
122 }
123 confirmLabel="Suspend"
124 destructive
125 busy={suspendMut.isPending}
126 onConfirm={() => confirm && suspendMut.mutate({ userId: confirm.userId, suspend: true })}
127 onClose={() => setConfirm(null)}
128 />
129 </Section>
130 );
131}
132
133function MemberRow({
134 m,
135 busy,
136 onRole,
137 onSuspend,
138 onUnsuspend,
139}: {
140 m: OrgMemberRow;
141 busy: boolean;
142 onRole: (r: OrgMemberRole) => void;
143 onSuspend: () => void;
144 onUnsuspend: () => void;
145}) {
146 const isOwner = m.role === "OWNER";
147 const suspended = m.status === "SUSPENDED";
148 return (
149 <tr data-testid={`org-admin-member-${m.userId}`} className="border-b border-border/60 last:border-0">
150 <td className="py-2 pr-3">
151 <div className="font-medium">{m.name}</div>
152 <div className="text-xs text-muted-foreground">{m.email}</div>
153 </td>
154 <td className="py-2 pr-3">
155 {isOwner ? (
156 <span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs font-medium">OWNER</span>
157 ) : (
158 <select
159 value={m.role}
160 disabled={busy}
161 onChange={(e) => onRole(e.target.value as OrgMemberRole)}
162 className="rounded-md border border-input bg-background px-2 py-1 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
163 aria-label={`Role for ${m.name}`}
164 >
165 {ROLES.map((r) => (
166 <option key={r} value={r}>
167 {r}
168 </option>
169 ))}
170 </select>
171 )}
172 </td>
173 <td className="py-2 pr-3">
174 <span
175 className={
176 suspended ? "rounded bg-destructive/10 px-1.5 py-0.5 text-xs font-medium text-destructive" : "text-xs text-muted-foreground"
177 }
178 >
179 {m.status}
180 </span>
181 </td>
182 <td className="py-2">
183 {isOwner ? (
184 <span className="text-xs text-muted-foreground">—</span>
185 ) : suspended ? (
186 <button
187 type="button"
188 data-testid="member-unsuspend"
189 onClick={onUnsuspend}
190 disabled={busy}
191 className="text-xs font-medium hover:underline disabled:opacity-50"
192 >
193 Unsuspend
194 </button>
195 ) : (
196 <button
197 type="button"
198 data-testid="member-suspend"
199 onClick={onSuspend}
200 disabled={busy}
201 className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
202 >
203 Suspend
204 </button>
205 )}
206 </td>
207 </tr>
208 );
209}
210