1"use client";
2
3
29import { useEffect, useMemo, useState } from "react";
30import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
31import {
32 Loader2,
33 Plus,
34 RefreshCw,
35 ShieldCheck,
36 ShieldOff,
37 Trash2,
38 Webhook,
39 KeyRound,
40 Fingerprint
41} from "lucide-react";
42import {
43 ssoApi,
44 type SsoOidcConfig,
45 type SsoProviderView,
46 type SsoSamlConfig,
47 type SsoTestResult
48} from "@/lib/api";
49import { toast } from "@/stores/ui-store";
50import { cn } from "@/lib/utils";
51import { ConfirmDialog } from "@/components/ui/confirm-dialog";
52import { Field, Section, inputCls } from "@/components/settings/settings-shell";
53
54interface SsoTabsProps {
55 orgId: string;
56}
57
58type SsoType = "SAML" | "OIDC";
59
60interface FormState {
61 type: SsoType;
62 name: string;
63
64 samlEntityId: string;
65 samlSsoUrl: string;
66 samlAcsUrl: string;
67 samlNameIdAttribute: string;
68 samlEmailAttribute: string;
69 samlMetadataUrl: string;
70 samlIdpEntityId: string;
71 samlCertificate: string;
72 samlPreviousCertificate: string;
73
74 oidcIssuer: string;
75 oidcClientId: string;
76 oidcAuthorizationEndpoint: string;
77 oidcTokenEndpoint: string;
78 oidcRedirectUri: string;
79 oidcJwksUri: string;
80 oidcScope: string;
81 oidcClientSecret: string;
82}
83
84const EMPTY_FORM: FormState = {
85 type: "SAML",
86 name: "",
87 samlEntityId: "",
88 samlSsoUrl: "",
89 samlAcsUrl: "",
90 samlNameIdAttribute: "nameId",
91 samlEmailAttribute: "mail",
92 samlMetadataUrl: "",
93 samlIdpEntityId: "",
94 samlCertificate: "",
95 samlPreviousCertificate: "",
96 oidcIssuer: "",
97 oidcClientId: "",
98 oidcAuthorizationEndpoint: "",
99 oidcTokenEndpoint: "",
100 oidcRedirectUri: "",
101 oidcJwksUri: "",
102 oidcScope: "openid email profile",
103 oidcClientSecret: ""
104};
105
106const btnPrimary =
107 "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";
108const btnSecondary =
109 "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";
110const btnDanger =
111 "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";
112
113export function SsoTabs({ orgId }: SsoTabsProps) {
114 const qc = useQueryClient();
115 const providerQuery = useQuery({
116 queryKey: ["orgs", orgId, "sso"],
117 queryFn: () => ssoApi.get(orgId),
118 retry: false
119 });
120 const provider: SsoProviderView | null = providerQuery.data?.provider ?? null;
121
122 const [mode, setMode] = useState<"view" | "create" | "edit">("view");
123 const [form, setForm] = useState<FormState>(EMPTY_FORM);
124 const [confirmEnforce, setConfirmEnforce] = useState<null | boolean>(null);
125 const [confirmDelete, setConfirmDelete] = useState(false);
126 const [testResult, setTestResult] = useState<SsoTestResult | null>(null);
127
128 const invalidate = () => qc.invalidateQueries({ queryKey: ["orgs", orgId, "sso"] });
129
130
131
132 useEffect(() => {
133 if (mode === "edit" && provider) {
134 setForm((f) => ({
135 ...f,
136 type: provider.type,
137 name: provider.name,
138 ...(provider.type === "SAML"
139 ? {
140 samlEntityId: (provider.config as SsoSamlConfig).entityId,
141 samlSsoUrl: (provider.config as SsoSamlConfig).ssoUrl,
142 samlAcsUrl: (provider.config as SsoSamlConfig).acsUrl,
143 samlNameIdAttribute: (provider.config as SsoSamlConfig).nameIdAttribute ?? "nameId",
144 samlEmailAttribute: (provider.config as SsoSamlConfig).emailAttribute ?? "mail",
145 samlMetadataUrl: (provider.config as SsoSamlConfig).metadataUrl ?? "",
146 samlIdpEntityId: (provider.config as SsoSamlConfig).idpEntityId ?? ""
147 }
148 : {
149 oidcIssuer: (provider.config as SsoOidcConfig).issuer,
150 oidcClientId: (provider.config as SsoOidcConfig).clientId,
151 oidcAuthorizationEndpoint: (provider.config as SsoOidcConfig).authorizationEndpoint,
152 oidcTokenEndpoint: (provider.config as SsoOidcConfig).tokenEndpoint,
153 oidcRedirectUri: (provider.config as SsoOidcConfig).redirectUri,
154 oidcJwksUri: (provider.config as SsoOidcConfig).jwksUri ?? "",
155 oidcScope: (provider.config as SsoOidcConfig).scope ?? "openid email profile"
156 })
157 }));
158 }
159 }, [mode, provider]);
160
161 const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
162 setForm((f) => ({ ...f, [key]: value }));
163
164 const createMutation = useMutation({
165 mutationFn: () =>
166 form.type === "SAML"
167 ? ssoApi.create(orgId, {
168 type: "SAML",
169 name: form.name,
170 certificate: form.samlCertificate,
171 ...(form.samlPreviousCertificate
172 ? { previousCertificate: form.samlPreviousCertificate }
173 : {}),
174 config: {
175 entityId: form.samlEntityId,
176 ssoUrl: form.samlSsoUrl,
177 acsUrl: form.samlAcsUrl,
178 nameIdAttribute: form.samlNameIdAttribute || "nameId",
179 emailAttribute: form.samlEmailAttribute || "mail",
180 ...(form.samlMetadataUrl ? { metadataUrl: form.samlMetadataUrl } : {}),
181 ...(form.samlIdpEntityId ? { idpEntityId: form.samlIdpEntityId } : {})
182 }
183 })
184 : ssoApi.create(orgId, {
185 type: "OIDC",
186 name: form.name,
187 clientSecret: form.oidcClientSecret,
188 config: {
189 issuer: form.oidcIssuer,
190 clientId: form.oidcClientId,
191 authorizationEndpoint: form.oidcAuthorizationEndpoint,
192 tokenEndpoint: form.oidcTokenEndpoint,
193 redirectUri: form.oidcRedirectUri,
194 jwksUri: form.oidcJwksUri || undefined,
195 scope: form.oidcScope
196 }
197 }),
198 onSuccess: () => {
199 toast("success", "SSO provider configured");
200 setMode("view");
201 setForm(EMPTY_FORM);
202 invalidate();
203 },
204 onError: (e: Error) => toast("error", "Could not save SSO", e.message)
205 });
206
207 const updateMutation = useMutation({
208 mutationFn: () => {
209 const body: Parameters<typeof ssoApi.update>[1] = { name: form.name };
210 if (provider?.type === "SAML") {
211 body.config = {
212 entityId: form.samlEntityId,
213 ssoUrl: form.samlSsoUrl,
214 acsUrl: form.samlAcsUrl,
215 nameIdAttribute: form.samlNameIdAttribute || "nameId",
216 emailAttribute: form.samlEmailAttribute || "mail"
217 };
218 if (form.samlCertificate) body.certificate = form.samlCertificate;
219 } else {
220 body.config = {
221 issuer: form.oidcIssuer,
222 clientId: form.oidcClientId,
223 authorizationEndpoint: form.oidcAuthorizationEndpoint,
224 tokenEndpoint: form.oidcTokenEndpoint,
225 redirectUri: form.oidcRedirectUri,
226 scope: form.oidcScope
227 };
228 if (form.oidcClientSecret) body.clientSecret = form.oidcClientSecret;
229 }
230 return ssoApi.update(orgId, body);
231 },
232 onSuccess: () => {
233 toast("success", "SSO provider updated");
234 setMode("view");
235 invalidate();
236 },
237 onError: (e: Error) => toast("error", "Could not update SSO", e.message)
238 });
239
240 const enforceMutation = useMutation({
241 mutationFn: (enforce: boolean) => ssoApi.setEnforce(orgId, enforce),
242 onSuccess: (res) => {
243 toast("success", `SSO enforce ${res.enforce ? "ON" : "OFF"}`);
244 setConfirmEnforce(null);
245 invalidate();
246 },
247 onError: (e: Error) => {
248 toast("error", "Could not change enforce", e.message);
249 setConfirmEnforce(null);
250 }
251 });
252
253 const deleteMutation = useMutation({
254 mutationFn: () => ssoApi.remove(orgId),
255 onSuccess: () => {
256 toast("success", "SSO provider removed");
257 setConfirmDelete(false);
258 setTestResult(null);
259 invalidate();
260 },
261 onError: (e: Error) => {
262 toast("error", "Could not remove SSO", e.message);
263 setConfirmDelete(false);
264 }
265 });
266
267 const testMutation = useMutation({
268 mutationFn: () => ssoApi.test(orgId),
269 onSuccess: (res) => setTestResult(res)
270 });
271
272 const createValid = useMemo(() => {
273 if (!form.name.trim()) return false;
274 if (form.type === "SAML") {
275 return Boolean(
276 form.samlEntityId && form.samlSsoUrl && form.samlAcsUrl && form.samlCertificate.trim()
277 );
278 }
279 return Boolean(
280 form.oidcIssuer &&
281 form.oidcClientId &&
282 form.oidcAuthorizationEndpoint &&
283 form.oidcTokenEndpoint &&
284 form.oidcRedirectUri &&
285 form.oidcClientSecret.trim()
286 );
287 }, [form]);
288
289 const updateValid = useMemo(() => {
290 if (!form.name.trim()) return false;
291 if (form.type === "SAML") return Boolean(form.samlEntityId && form.samlSsoUrl && form.samlAcsUrl);
292 return Boolean(
293 form.oidcIssuer && form.oidcClientId && form.oidcAuthorizationEndpoint && form.oidcTokenEndpoint
294 );
295 }, [form]);
296
297 if (providerQuery.isLoading) {
298 return (
299 <div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
300 <Loader2 className="size-4 animate-spin" /> Loading SSO settings…
301 </div>
302 );
303 }
304
305
306 if (!provider && mode !== "create") {
307 return (
308 <Section
309 title="Single sign-on (SSO)"
310 description="Let your organization's IdP (SAML 2.0 or OIDC) sign users in and auto-provision accounts (JIT)."
311 >
312 <div className="flex items-center justify-between rounded-md border border-dashed p-4">
313 <p className="text-sm text-muted-foreground">No SSO provider configured yet.</p>
314 <button type="button" className={btnPrimary} onClick={() => setMode("create")}>
315 <Plus className="size-4" /> Configure SSO
316 </button>
317 </div>
318 </Section>
319 );
320 }
321
322 if (mode === "create" || mode === "edit") {
323 return (
324 <form
325 className="space-y-4"
326 noValidate
327 onSubmit={(e) => {
328 e.preventDefault();
329 if (mode === "create" && createValid) createMutation.mutate();
330 if (mode === "edit" && updateValid) updateMutation.mutate();
331 }}
332 >
333 <div className="flex items-center justify-between">
334 <h2 className="text-sm font-semibold">{mode === "create" ? "Configure SSO" : "Edit SSO"}</h2>
335 <button type="button" className={btnSecondary} onClick={() => setMode("view")}>
336 Cancel
337 </button>
338 </div>
339
340 <div role="group" aria-label="Provider type" data-testid="sso-provider-type">
341 <span className="mb-1 block text-xs font-medium text-muted-foreground">Provider type</span>
342 <div className="flex gap-2">
343 {(["SAML", "OIDC"] as SsoType[]).map((t) => (
344 <button
345 key={t}
346 type="button"
347 disabled={mode === "edit"}
348 onClick={() => set("type", t)}
349 aria-pressed={form.type === t}
350 className={cn(
351 "flex-1 rounded-md border px-3 py-1.5 text-sm font-medium disabled:cursor-not-allowed",
352 form.type === t
353 ? "border-primary bg-primary/10 text-foreground"
354 : "border-border text-muted-foreground"
355 )}
356 >
357 {t === "SAML" ? "SAML 2.0" : "OIDC"}
358 </button>
359 ))}
360 </div>
361 </div>
362
363 <Field label="Display name">
364 <input
365 className={inputCls}
366 value={form.name}
367 onChange={(e) => set("name", e.target.value)}
368 placeholder={form.type === "SAML" ? "e.g. Acme Okta" : "e.g. Acme Okta OIDC"}
369 />
370 </Field>
371
372 {form.type === "SAML" ? (
373 <>
374 <Field label="SP entity ID" hint="Defaults to <origin>/saml/metadata/<org-slug>.">
375 <input
376 className={inputCls}
377 value={form.samlEntityId}
378 onChange={(e) => set("samlEntityId", e.target.value)}
379 placeholder="https://factory.example.com/saml/metadata/acme"
380 />
381 </Field>
382 <div className="grid gap-4 md:grid-cols-2">
383 <Field label="IdP SSO URL">
384 <input
385 className={inputCls}
386 value={form.samlSsoUrl}
387 onChange={(e) => set("samlSsoUrl", e.target.value)}
388 placeholder="https://idp.example.com/saml/sso"
389 />
390 </Field>
391 <Field label="ACS URL" hint="Defaults to <origin>/saml/acs.">
392 <input
393 className={inputCls}
394 value={form.samlAcsUrl}
395 onChange={(e) => set("samlAcsUrl", e.target.value)}
396 placeholder="https://factory.example.com/saml/acs"
397 />
398 </Field>
399 <Field label="NameID attribute">
400 <input
401 className={inputCls}
402 value={form.samlNameIdAttribute}
403 onChange={(e) => set("samlNameIdAttribute", e.target.value)}
404 />
405 </Field>
406 <Field label="Email attribute">
407 <input
408 className={inputCls}
409 value={form.samlEmailAttribute}
410 onChange={(e) => set("samlEmailAttribute", e.target.value)}
411 />
412 </Field>
413 <Field label="IdP metadata URL (optional)">
414 <input
415 className={inputCls}
416 data-testid="sso-metadata-url"
417 value={form.samlMetadataUrl}
418 onChange={(e) => set("samlMetadataUrl", e.target.value)}
419 placeholder="https://idp.example.com/metadata"
420 />
421 </Field>
422 <Field label="IdP entity ID (optional pin)">
423 <input
424 className={inputCls}
425 value={form.samlIdpEntityId}
426 onChange={(e) => set("samlIdpEntityId", e.target.value)}
427 />
428 </Field>
429 </div>
430 {mode === "create" ? (
431 <>
432 <Field
433 label="IdP signing certificate (PEM)"
434 hint="Paste the X.509 cert from your IdP. Stored encrypted; never returned."
435 >
436 <textarea
437 className={cn(inputCls, "min-h-28 font-mono text-xs")}
438 value={form.samlCertificate}
439 onChange={(e) => set("samlCertificate", e.target.value)}
440 placeholder="-----BEGIN CERTIFICATE----- … -----END CERTIFICATE-----"
441 />
442 </Field>
443 <Field
444 label="Previous certificate (PEM, optional)"
445 hint="For rotation grace — responses signed by either cert are accepted."
446 >
447 <textarea
448 className={cn(inputCls, "min-h-20 font-mono text-xs")}
449 value={form.samlPreviousCertificate}
450 onChange={(e) => set("samlPreviousCertificate", e.target.value)}
451 />
452 </Field>
453 </>
454 ) : (
455 <Field
456 label="Rotate signing certificate (PEM)"
457 hint="Leave blank to keep the current cert. Setting a new cert moves the old one to the rotation slot."
458 >
459 <textarea
460 className={cn(inputCls, "min-h-20 font-mono text-xs")}
461 value={form.samlCertificate}
462 onChange={(e) => set("samlCertificate", e.target.value)}
463 placeholder="Paste a new certificate PEM to rotate"
464 />
465 </Field>
466 )}
467 </>
468 ) : (
469 <>
470 <Field label="Issuer">
471 <input
472 className={inputCls}
473 value={form.oidcIssuer}
474 onChange={(e) => set("oidcIssuer", e.target.value)}
475 placeholder="https://idp.example.com"
476 />
477 </Field>
478 <div className="grid gap-4 md:grid-cols-2">
479 <Field label="Client ID">
480 <input
481 className={inputCls}
482 value={form.oidcClientId}
483 onChange={(e) => set("oidcClientId", e.target.value)}
484 />
485 </Field>
486 <Field label="Scope">
487 <input
488 className={inputCls}
489 value={form.oidcScope}
490 onChange={(e) => set("oidcScope", e.target.value)}
491 />
492 </Field>
493 <Field label="Authorization endpoint">
494 <input
495 className={inputCls}
496 value={form.oidcAuthorizationEndpoint}
497 onChange={(e) => set("oidcAuthorizationEndpoint", e.target.value)}
498 />
499 </Field>
500 <Field label="Token endpoint">
501 <input
502 className={inputCls}
503 value={form.oidcTokenEndpoint}
504 onChange={(e) => set("oidcTokenEndpoint", e.target.value)}
505 />
506 </Field>
507 <Field label="Redirect URI">
508 <input
509 className={inputCls}
510 value={form.oidcRedirectUri}
511 onChange={(e) => set("oidcRedirectUri", e.target.value)}
512 />
513 </Field>
514 <Field label="JWKS URI (optional)">
515 <input
516 className={inputCls}
517 value={form.oidcJwksUri}
518 onChange={(e) => set("oidcJwksUri", e.target.value)}
519 placeholder="defaults to <issuer>/.well-known/jwks.json"
520 />
521 </Field>
522 </div>
523 {mode === "create" ? (
524 <Field
525 label="Client secret"
526 hint="Reveal-once: stored encrypted, never returned."
527 >
528 <input
529 className={inputCls}
530 type="password"
531 value={form.oidcClientSecret}
532 onChange={(e) => set("oidcClientSecret", e.target.value)}
533 />
534 </Field>
535 ) : (
536 <Field label="Rotate client secret" hint="Leave blank to keep the current secret.">
537 <input
538 className={inputCls}
539 type="password"
540 value={form.oidcClientSecret}
541 onChange={(e) => set("oidcClientSecret", e.target.value)}
542 placeholder="Paste a new secret to rotate"
543 />
544 </Field>
545 )}
546 </>
547 )}
548
549 <div className="flex justify-end gap-2">
550 <button
551 type="submit"
552 className={btnPrimary}
553 data-testid="sso-save"
554 disabled={mode === "create" ? !createValid : !updateValid}
555 >
556 {(mode === "create" ? createMutation : updateMutation).isPending && (
557 <Loader2 className="size-4 animate-spin" />
558 )}
559 {mode === "create" ? "Save provider" : "Save changes"}
560 </button>
561 </div>
562 </form>
563 );
564 }
565
566
567
568
569
570 if (!provider) return null;
571 const isSaml = provider.type === "SAML";
572 return (
573 <div className="space-y-4">
574 <Section
575 title="Single sign-on (SSO)"
576 description={
577 <span className="inline-flex items-center gap-2">
578 <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs">
579 {isSaml ? <Fingerprint className="size-3" /> : <KeyRound className="size-3" />}
580 {provider.type}
581 </span>
582 {provider.enforce ? (
583 <span className="inline-flex items-center gap-1 rounded-full border border-amber-400/40 bg-amber-500/10 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-300">
584 <ShieldCheck className="size-3" /> Enforce ON
585 </span>
586 ) : (
587 <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
588 <ShieldOff className="size-3" /> Enforce OFF
589 </span>
590 )}
591 </span>
592 }
593 >
594 <dl className="grid gap-2 text-sm sm:grid-cols-2">
595 <SummaryRow label="Name" value={provider.name} />
596 <SummaryRow
597 label={isSaml ? "Certificate" : "Client secret"}
598 value={
599 isSaml
600 ? (provider.certificateConfigured ? "Configured" : "Missing")
601 : (provider.clientSecretConfigured ? "Configured" : "Missing")
602 }
603 />
604 {isSaml ? (
605 <>
606 <SummaryRow label="SP entity ID" value={(provider.config as SsoSamlConfig).entityId} mono />
607 <SummaryRow label="IdP SSO URL" value={(provider.config as SsoSamlConfig).ssoUrl} mono />
608 </>
609 ) : (
610 <>
611 <SummaryRow label="Issuer" value={(provider.config as SsoOidcConfig).issuer} mono />
612 <SummaryRow label="Client ID" value={(provider.config as SsoOidcConfig).clientId} mono />
613 </>
614 )}
615 </dl>
616 <div className="mt-3 flex flex-wrap gap-2">
617 <button type="button" className={btnSecondary} data-testid="sso-edit" onClick={() => setMode("edit")}>
618 <RefreshCw className="size-4" /> Edit
619 </button>
620 <button
621 type="button"
622 className={btnSecondary}
623 onClick={() => testMutation.mutate()}
624 data-testid="sso-test"
625 disabled={testMutation.isPending}
626 >
627 {testMutation.isPending ? <Loader2 className="size-4 animate-spin" /> : <Webhook className="size-4" />}
628 Test connection
629 </button>
630 </div>
631 {testResult && (
632 <div
633 className={cn(
634 "mt-3 flex items-start gap-2 rounded-md border p-2.5 text-xs",
635 testResult.ok
636 ? "border-emerald-400/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
637 : "border-destructive/40 bg-destructive/5 text-destructive"
638 )}
639 role="status"
640 data-testid="sso-test-result"
641 >
642 <span className="font-medium">{testResult.ok ? "Connected" : "Failed"}</span>
643 <span>
644 {testResult.reason
645 ? ` — ${testResult.reason}`
646 : testResult.details && Object.keys(testResult.details).length
647 ? ` — ${JSON.stringify(testResult.details)}`
648 : ""}
649 </span>
650 </div>
651 )}
652 </Section>
653
654 <Section
655 title="Enforce SSO"
656 description="When ON, a password login is refused for every member of this organization — they must sign in through the IdP. The owner is also blocked (recover via the documented service-credential runbook)."
657 >
658 <button
659 type="button"
660 role="switch"
661 aria-checked={provider.enforce}
662 data-testid="sso-enforce-toggle"
663 onClick={() => setConfirmEnforce(!provider.enforce)}
664 disabled={enforceMutation.isPending}
665 className={cn(
666 "relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
667 provider.enforce ? "bg-primary" : "bg-muted-foreground/30"
668 )}
669 >
670 <span
671 className={cn(
672 "inline-block size-4 transform rounded-full bg-white transition-transform",
673 provider.enforce ? "translate-x-6" : "translate-x-1"
674 )}
675 />
676 </button>
677 <span className="ml-2 text-sm text-muted-foreground">
678 {provider.enforce ? "Enforced — password logins are blocked." : "Optional — password and SSO both work."}
679 </span>
680 </Section>
681
682 <Section
683 danger
684 title="Danger zone"
685 description="Removing the SSO provider deletes its configuration and encrypted secrets. Members' existing sessions are not revoked."
686 >
687 <button type="button" className={btnDanger} data-testid="sso-delete" onClick={() => setConfirmDelete(true)}>
688 <Trash2 className="size-4" /> Remove SSO provider
689 </button>
690 </Section>
691
692 <ConfirmDialog
693 open={confirmEnforce !== null}
694 title={confirmEnforce ? "Enforce SSO sign-in?" : "Stop enforcing SSO?"}
695 description={
696 confirmEnforce
697 ? "Members (including the owner) will no longer be able to sign in with a password. Continue?"
698 : "Password logins will be allowed again alongside SSO."
699 }
700 confirmLabel={confirmEnforce ? "Enforce" : "Allow passwords"}
701 destructive={confirmEnforce ?? false}
702 busy={enforceMutation.isPending}
703 onConfirm={() => confirmEnforce !== null && enforceMutation.mutate(confirmEnforce)}
704 onClose={() => setConfirmEnforce(null)}
705 />
706 <ConfirmDialog
707 open={confirmDelete}
708 title="Remove SSO provider?"
709 description="This deletes the provider configuration and its encrypted secrets. This cannot be undone."
710 confirmLabel="Remove"
711 destructive
712 busy={deleteMutation.isPending}
713 onConfirm={() => deleteMutation.mutate()}
714 onClose={() => setConfirmDelete(false)}
715 />
716 </div>
717 );
718}
719
720function SummaryRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
721 return (
722 <div className="min-w-0">
723 <dt className="text-xs text-muted-foreground">{label}</dt>
724 <dd className={cn("truncate text-sm", mono && "font-mono text-xs")}>{value || "—"}</dd>
725 </div>
726 );
727}
728