1"use client";
2
3
29import { useMemo, useState } from "react";
30import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
31import { Check, Copy, Loader2, RefreshCw, ShieldAlert, Users, Webhook } from "lucide-react";
32import { scimApi, type ScimConfigView, type ScimTestResult } from "@/lib/api";
33import { toast } from "@/stores/ui-store";
34import { cn } from "@/lib/utils";
35import { ConfirmDialog } from "@/components/ui/confirm-dialog";
36import { Field, Section, inputCls } from "@/components/settings/settings-shell";
37
38interface ScimCardProps {
39 orgId: string;
40}
41
42const btnPrimary =
43 "inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50";
44const btnSecondary =
45 "inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent disabled:opacity-50";
46const btnDanger =
47 "inline-flex items-center gap-1.5 rounded-md border border-destructive/50 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50";
48
49export function ScimCard({ orgId }: ScimCardProps) {
50 const qc = useQueryClient();
51 const query = useQuery({
52 queryKey: ["orgs", orgId, "scim"],
53 queryFn: () => scimApi.get(orgId),
54 retry: false
55 });
56 const cfg: ScimConfigView | null = query.data ?? null;
57
58
59 const [userEmailDomain, setUserEmailDomain] = useState("");
60 const [defaultRole, setDefaultRole] = useState<"GUEST" | "MEMBER">("MEMBER");
61 const [autoJoin, setAutoJoin] = useState(true);
62
63
64
65 const [revealedToken, setRevealedToken] = useState<string | null>(null);
66 const [copied, setCopied] = useState(false);
67
68
69 const [testToken, setTestToken] = useState("");
70 const [testResult, setTestResult] = useState<ScimTestResult | null>(null);
71
72
73 const [editing, setEditing] = useState(false);
74 const [editDomain, setEditDomain] = useState("");
75 const [editRole, setEditRole] = useState<"GUEST" | "MEMBER">("MEMBER");
76 const [editAutoJoin, setEditAutoJoin] = useState(true);
77
78 const [confirmDisable, setConfirmDisable] = useState(false);
79
80 const invalidate = () => qc.invalidateQueries({ queryKey: ["orgs", orgId, "scim"] });
81
82 const enableMutation = useMutation({
83 mutationFn: () =>
84 scimApi.enable(orgId, {
85 ...(userEmailDomain.trim() ? { userEmailDomain: userEmailDomain.trim() } : {}),
86 defaultRole,
87 autoJoinOrgWorkspaces: autoJoin
88 }),
89 onSuccess: (res) => {
90 setRevealedToken(res.token);
91 setTestToken(res.token);
92 setCopied(false);
93 toast("success", "SCIM enabled");
94 invalidate();
95 },
96 onError: (e: Error) => toast("error", "Could not enable SCIM", e.message)
97 });
98
99 const rotateMutation = useMutation({
100 mutationFn: () => scimApi.update(orgId, { rotate: true }),
101 onSuccess: (res) => {
102 if (res.token) {
103 setRevealedToken(res.token);
104 setTestToken(res.token);
105 setCopied(false);
106 }
107 toast("success", "SCIM token rotated");
108 setTestResult(null);
109 invalidate();
110 },
111 onError: (e: Error) => toast("error", "Could not rotate SCIM token", e.message)
112 });
113
114 const saveEditMutation = useMutation({
115 mutationFn: () =>
116 scimApi.update(orgId, {
117 userEmailDomain: editDomain.trim() ? editDomain.trim() : null,
118 defaultRole: editRole,
119 autoJoinOrgWorkspaces: editAutoJoin
120 }),
121 onSuccess: () => {
122 setEditing(false);
123 toast("success", "SCIM settings saved");
124 invalidate();
125 },
126 onError: (e: Error) => toast("error", "Could not save SCIM settings", e.message)
127 });
128
129 const disableMutation = useMutation({
130 mutationFn: () => scimApi.update(orgId, { disable: true }),
131 onSuccess: () => {
132 setConfirmDisable(false);
133 setRevealedToken(null);
134 setTestToken("");
135 setTestResult(null);
136 toast("success", "SCIM disabled");
137 invalidate();
138 },
139 onError: (e: Error) => {
140 setConfirmDisable(false);
141 toast("error", "Could not disable SCIM", e.message);
142 }
143 });
144
145
146
147 const effectiveTestToken = testToken || revealedToken || "";
148 const testMutation = useMutation({
149 mutationFn: () => scimApi.test(orgId, effectiveTestToken.trim()),
150 onSuccess: (res) => setTestResult(res),
151 onError: (e: Error) => setTestResult({ ok: false, detail: e.message })
152 });
153
154 const canCopy = useMemo(() => Boolean(revealedToken && navigator?.clipboard?.writeText), [revealedToken]);
155
156 async function copyToken() {
157 if (!revealedToken || !canCopy) return;
158 try {
159 await navigator.clipboard.writeText(revealedToken);
160 setCopied(true);
161 setTimeout(() => setCopied(false), 2000);
162 } catch {
163 toast("error", "Copy failed — select the token and copy manually");
164 }
165 }
166
167 function startEdit() {
168 setEditDomain(cfg?.userEmailDomain ?? "");
169 setEditRole(cfg?.defaultRole ?? "MEMBER");
170 setEditAutoJoin(cfg?.autoJoinOrgWorkspaces ?? true);
171 setEditing(true);
172 }
173
174
175 if (query.isLoading) {
176 return (
177 <div className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status">
178 <Loader2 className="size-4 animate-spin" /> Loading SCIM settings…
179 </div>
180 );
181 }
182 if (query.isError) {
183 return (
184 <div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive" role="alert">
185 Could not load SCIM settings: {(query.error as Error).message}
186 </div>
187 );
188 }
189
190
191 if (!cfg?.enabled) {
192 return (
193 <Section
194 title="SCIM 2.0 (user provisioning)"
195 description="Provision users and groups from your IdP (Okta, Microsoft Entra, …) over SCIM. Deactivating a user in the IdP deactivates them here — accounts are never hard-deleted."
196 >
197 <div className="rounded-md border border-dashed p-4">
198 <p className="mb-3 text-sm text-muted-foreground">SCIM is not enabled for this organization.</p>
199 <form
200 className="space-y-3"
201 noValidate
202 onSubmit={(e) => {
203 e.preventDefault();
204 enableMutation.mutate();
205 }}
206 >
207 <Field
208 label="User email domain"
209 hint="For @-less SCIM usernames: jane.doe + domain → jane.doe@<domain>. Leave blank if your IdP sends full emails."
210 >
211 <input
212 className={inputCls}
213 data-testid="scim-user-email-domain"
214 value={userEmailDomain}
215 onChange={(e) => setUserEmailDomain(e.target.value)}
216 placeholder="acme.com"
217 />
218 </Field>
219 <div className="grid gap-3 sm:grid-cols-2">
220 <Field label="Default role">
221 <select
222 className={inputCls}
223 data-testid="scim-default-role"
224 value={defaultRole}
225 onChange={(e) => setDefaultRole(e.target.value as "GUEST" | "MEMBER")}
226 >
227 <option value="MEMBER">Member</option>
228 <option value="GUEST">Guest</option>
229 </select>
230 </Field>
231 <div className="flex items-end pb-1">
232 <label className="inline-flex items-center gap-2 text-sm">
233 <input
234 type="checkbox"
235 data-testid="scim-auto-join"
236 checked={autoJoin}
237 onChange={(e) => setAutoJoin(e.target.checked)}
238 className="size-4 rounded border-border"
239 />
240 Auto-join org workspaces
241 </label>
242 </div>
243 </div>
244 <div className="flex justify-end">
245 <button type="submit" className={btnPrimary} data-testid="scim-enable" disabled={enableMutation.isPending}>
246 {enableMutation.isPending ? <Loader2 className="size-4 animate-spin" /> : <Users className="size-4" />}
247 Enable SCIM
248 </button>
249 </div>
250 </form>
251 </div>
252 </Section>
253 );
254 }
255
256
257 if (editing) {
258 return (
259 <form
260 className="space-y-4"
261 noValidate
262 onSubmit={(e) => {
263 e.preventDefault();
264 saveEditMutation.mutate();
265 }}
266 >
267 <div className="flex items-center justify-between">
268 <h2 className="text-sm font-semibold">Edit SCIM settings</h2>
269 <button type="button" className={btnSecondary} onClick={() => setEditing(false)}>
270 Cancel
271 </button>
272 </div>
273 <Field
274 label="User email domain"
275 hint="Clear to require full emails in the SCIM payload."
276 >
277 <input
278 className={inputCls}
279 data-testid="scim-edit-domain"
280 value={editDomain}
281 onChange={(e) => setEditDomain(e.target.value)}
282 placeholder="acme.com"
283 />
284 </Field>
285 <div className="grid gap-3 sm:grid-cols-2">
286 <Field label="Default role">
287 <select
288 className={inputCls}
289 data-testid="scim-edit-role"
290 value={editRole}
291 onChange={(e) => setEditRole(e.target.value as "GUEST" | "MEMBER")}
292 >
293 <option value="MEMBER">Member</option>
294 <option value="GUEST">Guest</option>
295 </select>
296 </Field>
297 <div className="flex items-end pb-1">
298 <label className="inline-flex items-center gap-2 text-sm">
299 <input
300 type="checkbox"
301 data-testid="scim-edit-auto-join"
302 checked={editAutoJoin}
303 onChange={(e) => setEditAutoJoin(e.target.checked)}
304 className="size-4 rounded border-border"
305 />
306 Auto-join org workspaces
307 </label>
308 </div>
309 </div>
310 <div className="flex justify-end">
311 <button type="submit" className={btnPrimary} disabled={saveEditMutation.isPending}>
312 {saveEditMutation.isPending && <Loader2 className="size-4 animate-spin" />}
313 Save changes
314 </button>
315 </div>
316 </form>
317 );
318 }
319
320
321 return (
322 <div className="space-y-4">
323 {revealedToken && (
324 <div
325 className="rounded-md border border-amber-400/50 bg-amber-500/10 p-3"
326 role="alert"
327 data-testid="scim-token-panel"
328 >
329 <div className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-amber-700 dark:text-amber-300">
330 <ShieldAlert className="size-4" /> SCIM bearer token — shown only once
331 </div>
332 <div className="flex items-center gap-2">
333 <code className="min-w-0 flex-1 break-all rounded bg-background/60 px-2 py-1.5 font-mono text-xs" data-testid="scim-token">
334 {revealedToken}
335 </code>
336 <button
337 type="button"
338 className={btnSecondary}
339 data-testid="scim-copy-token"
340 aria-label="Copy SCIM bearer token"
341 onClick={() => void copyToken()}
342 disabled={!canCopy}
343 >
344 {copied ? <Check className="size-4 text-emerald-500" /> : <Copy className="size-4" />}
345 {copied ? "Copied" : "Copy"}
346 </button>
347 </div>
348 <p className="mt-1.5 text-xs text-amber-700/80 dark:text-amber-300/80">
349 Configure this token as the bearer credential in your IdP now — it is stored only as a
350 SHA-256 hash and can never be retrieved again. Rotate it at any time (the old token stops
351 working immediately).
352 </p>
353 </div>
354 )}
355
356 <Section
357 title="SCIM 2.0 (user provisioning)"
358 description={
359 <span className="inline-flex items-center gap-2">
360 <span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/40 bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-700 dark:text-emerald-300">
361 <Users className="size-3" /> Enabled
362 </span>
363 {cfg.tokenHint && (
364 <span className="text-xs text-muted-foreground">
365 token …{cfg.tokenHint}
366 </span>
367 )}
368 </span>
369 }
370 >
371 <dl className="grid gap-2 text-sm">
372 <div className="min-w-0">
373 <dt className="text-xs text-muted-foreground">Endpoint</dt>
374 <dd className="truncate font-mono text-xs" data-testid="scim-endpoint">
375 {cfg.endpointUrl}
376 </dd>
377 </div>
378 <div className="min-w-0">
379 <dt className="text-xs text-muted-foreground">Email domain</dt>
380 <dd className="truncate text-sm">{cfg.userEmailDomain || "—"}</dd>
381 </div>
382 <div className="min-w-0">
383 <dt className="text-xs text-muted-foreground">Default role</dt>
384 <dd className="text-sm">{cfg.defaultRole === "GUEST" ? "Guest" : "Member"}</dd>
385 </div>
386 <div className="min-w-0">
387 <dt className="text-xs text-muted-foreground">Auto-join workspaces</dt>
388 <dd className="text-sm">{cfg.autoJoinOrgWorkspaces ? "Yes" : "No"}</dd>
389 </div>
390 </dl>
391 <div className="mt-2 rounded-md bg-muted/40 p-2">
392 <p className="mb-1 text-xs text-muted-foreground">Quick test (IdP side):</p>
393 <code className="block break-all font-mono text-xs" data-testid="scim-curl">
394 {cfg.curlExample}
395 </code>
396 </div>
397 <div className="mt-3 flex flex-wrap gap-2">
398 <button type="button" className={btnSecondary} data-testid="scim-edit" onClick={startEdit}>
399 <RefreshCw className="size-4" /> Edit
400 </button>
401 <button
402 type="button"
403 className={btnSecondary}
404 data-testid="scim-rotate"
405 onClick={() => rotateMutation.mutate()}
406 disabled={rotateMutation.isPending}
407 >
408 {rotateMutation.isPending ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
409 Rotate token
410 </button>
411 <button
412 type="button"
413 className={btnSecondary}
414 data-testid="scim-test"
415 onClick={() => testMutation.mutate()}
416 disabled={testMutation.isPending || !effectiveTestToken.trim()}
417 >
418 {testMutation.isPending ? <Loader2 className="size-4 animate-spin" /> : <Webhook className="size-4" />}
419 Test connection
420 </button>
421 </div>
422 <div className="mt-3">
423 <label className="mb-1 block text-xs font-medium text-muted-foreground" htmlFor="scim-test-token">
424 Bearer token (paste to verify — never stored)
425 </label>
426 <input
427 id="scim-test-token"
428 className={inputCls}
429 data-testid="scim-test-token"
430 value={testToken}
431 onChange={(e) => {
432 setTestToken(e.target.value);
433 setTestResult(null);
434 }}
435 placeholder="scim_…"
436 />
437 {testResult && (
438 <div
439 className={cn(
440 "mt-2 flex items-start gap-2 rounded-md border p-2.5 text-xs",
441 testResult.ok
442 ? "border-emerald-400/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
443 : "border-destructive/40 bg-destructive/5 text-destructive"
444 )}
445 role="status"
446 data-testid="scim-test-result"
447 >
448 <span className="font-medium">{testResult.ok ? "Connected" : "Failed"}</span>
449 <span> — {testResult.detail}</span>
450 </div>
451 )}
452 </div>
453 </Section>
454
455 <Section
456 danger
457 title="Danger zone"
458 description="Disabling SCIM immediately invalidates the bearer token — the IdP can no longer provision or deactivate users. Provisioned users keep their accounts."
459 >
460 <button type="button" className={btnDanger} data-testid="scim-disable" onClick={() => setConfirmDisable(true)}>
461 <Users className="size-4" /> Disable SCIM
462 </button>
463 </Section>
464
465 <ConfirmDialog
466 open={confirmDisable}
467 title="Disable SCIM?"
468 description="The bearer token stops working immediately. Your IdP will no longer be able to create, update, or deactivate users. Provisioned accounts are kept."
469 confirmLabel="Disable"
470 destructive
471 busy={disableMutation.isPending}
472 onConfirm={() => disableMutation.mutate()}
473 onClose={() => setConfirmDisable(false)}
474 />
475 </div>
476 );
477}
478