@stigmer/react 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/composer/SessionComposer.d.ts.map +1 -1
  2. package/composer/SessionComposer.js +22 -32
  3. package/composer/SessionComposer.js.map +1 -1
  4. package/github/index.d.ts +1 -1
  5. package/github/index.d.ts.map +1 -1
  6. package/github/index.js.map +1 -1
  7. package/github/useGitHubConnection.d.ts +70 -1
  8. package/github/useGitHubConnection.d.ts.map +1 -1
  9. package/github/useGitHubConnection.js +99 -20
  10. package/github/useGitHubConnection.js.map +1 -1
  11. package/identity-provider/IdentityProviderWizard.d.ts.map +1 -1
  12. package/identity-provider/IdentityProviderWizard.js +19 -3
  13. package/identity-provider/IdentityProviderWizard.js.map +1 -1
  14. package/index.d.ts +1 -1
  15. package/index.d.ts.map +1 -1
  16. package/index.js.map +1 -1
  17. package/organization/OrgProfilePanel.d.ts.map +1 -1
  18. package/organization/OrgProfilePanel.js +23 -2
  19. package/organization/OrgProfilePanel.js.map +1 -1
  20. package/package.json +4 -4
  21. package/runner/RunnerFileBrowser.d.ts +11 -1
  22. package/runner/RunnerFileBrowser.d.ts.map +1 -1
  23. package/runner/RunnerFileBrowser.js +70 -7
  24. package/runner/RunnerFileBrowser.js.map +1 -1
  25. package/runner/WorkspaceRunnerSelector.d.ts +36 -0
  26. package/runner/WorkspaceRunnerSelector.d.ts.map +1 -0
  27. package/runner/WorkspaceRunnerSelector.js +63 -0
  28. package/runner/WorkspaceRunnerSelector.js.map +1 -0
  29. package/runner/index.d.ts +2 -0
  30. package/runner/index.d.ts.map +1 -1
  31. package/runner/index.js +1 -0
  32. package/runner/index.js.map +1 -1
  33. package/runner/useRunnerFileBrowser.d.ts.map +1 -1
  34. package/runner/useRunnerFileBrowser.js +26 -2
  35. package/runner/useRunnerFileBrowser.js.map +1 -1
  36. package/settings/MembersSection.d.ts.map +1 -1
  37. package/settings/MembersSection.js +7 -2
  38. package/settings/MembersSection.js.map +1 -1
  39. package/src/composer/SessionComposer.tsx +46 -43
  40. package/src/github/index.ts +1 -0
  41. package/src/github/useGitHubConnection.ts +162 -22
  42. package/src/identity-provider/IdentityProviderWizard.tsx +112 -3
  43. package/src/index.ts +1 -0
  44. package/src/organization/OrgProfilePanel.tsx +98 -0
  45. package/src/runner/RunnerFileBrowser.tsx +227 -8
  46. package/src/runner/WorkspaceRunnerSelector.tsx +180 -0
  47. package/src/runner/index.ts +3 -0
  48. package/src/runner/useRunnerFileBrowser.ts +39 -3
  49. package/src/settings/MembersSection.tsx +23 -1
  50. package/src/workspace/WorkspaceEditor.tsx +176 -126
  51. package/src/workspace/index.ts +5 -0
  52. package/src/workspace/useRecentWorkspaces.ts +162 -0
  53. package/src/workspace/useWorkspaceEntries.ts +13 -0
  54. package/styles.css +1 -1
  55. package/workspace/WorkspaceEditor.d.ts +25 -22
  56. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  57. package/workspace/WorkspaceEditor.js +64 -43
  58. package/workspace/WorkspaceEditor.js.map +1 -1
  59. package/workspace/index.d.ts +2 -0
  60. package/workspace/index.d.ts.map +1 -1
  61. package/workspace/index.js +1 -0
  62. package/workspace/index.js.map +1 -1
  63. package/workspace/useRecentWorkspaces.d.ts +31 -0
  64. package/workspace/useRecentWorkspaces.d.ts.map +1 -0
  65. package/workspace/useRecentWorkspaces.js +117 -0
  66. package/workspace/useRecentWorkspaces.js.map +1 -0
  67. package/workspace/useWorkspaceEntries.d.ts +8 -0
  68. package/workspace/useWorkspaceEntries.d.ts.map +1 -1
  69. package/workspace/useWorkspaceEntries.js +4 -0
  70. package/workspace/useWorkspaceEntries.js.map +1 -1
@@ -42,6 +42,52 @@ export interface GitHubConnectOptions {
42
42
  readonly popup?: boolean;
43
43
  }
44
44
 
45
+ /**
46
+ * Optional configuration for {@link useGitHubConnection}.
47
+ *
48
+ * Enables desktop and non-browser environments to participate in the
49
+ * GitHub OAuth flow without relying on `window.open()` popups.
50
+ */
51
+ export interface UseGitHubConnectionConfig {
52
+ /**
53
+ * Custom function to open the authorization URL in a browser.
54
+ *
55
+ * When provided, the hook calls this instead of `window.open()` during
56
+ * popup-mode `connect()` flows. This enables desktop environments
57
+ * (e.g. Tauri, Electron) where webview popups are blocked but the
58
+ * system browser is available.
59
+ *
60
+ * When used with {@link callbackUrl}, the callback page processes the
61
+ * token exchange and the consumer calls {@link UseGitHubConnectionReturn.reconcile}
62
+ * to pick up the token from the personal environment.
63
+ *
64
+ * When used without {@link callbackUrl} (e.g. localhost callback server),
65
+ * the consumer calls {@link UseGitHubConnectionReturn.handleCallback}
66
+ * with the code, state, and redirect URI to complete the exchange.
67
+ */
68
+ readonly openUrl?: (url: string) => void | Promise<void>;
69
+
70
+ /**
71
+ * Override the OAuth callback URL.
72
+ *
73
+ * When provided, this replaces the `redirectUri` parameter passed to
74
+ * `connect()` by UI components like `WorkspaceEditor`. Use this to
75
+ * route the GitHub callback to a specific URL — for example, the
76
+ * Stigmer web console's callback page for desktop flows, or a
77
+ * localhost server for local development.
78
+ *
79
+ * When the callback page handles the token exchange itself (cloud
80
+ * desktop flows), the consumer triggers re-reconciliation via
81
+ * {@link UseGitHubConnectionReturn.reconcile} after the callback
82
+ * completes externally.
83
+ *
84
+ * When the consumer handles the exchange (localhost flows), the same
85
+ * URL must be passed to `handleCallback()` — GitHub requires an
86
+ * exact match between the authorize and exchange redirect URIs.
87
+ */
88
+ readonly callbackUrl?: string;
89
+ }
90
+
45
91
  /** Return value of {@link useGitHubConnection}. */
46
92
  export interface UseGitHubConnectionReturn {
47
93
  /** Whether a valid GitHub token exists. */
@@ -72,6 +118,16 @@ export interface UseGitHubConnectionReturn {
72
118
  state: string,
73
119
  redirectUri: string,
74
120
  ) => Promise<void>;
121
+ /**
122
+ * Trigger re-reconciliation from the personal environment.
123
+ *
124
+ * Call this when the token exchange was handled externally (e.g. by
125
+ * the Stigmer web callback page during a desktop OAuth flow) and the
126
+ * token is already stored server-side. The hook will refetch the
127
+ * personal environment, reveal the token, and update
128
+ * {@link isConnected}.
129
+ */
130
+ readonly reconcile: () => void;
75
131
  /** Clear the stored token and user info. */
76
132
  readonly disconnect: () => void;
77
133
  }
@@ -93,6 +149,39 @@ async function fetchGitHubUser(token: string): Promise<GitHubUser | null> {
93
149
  }
94
150
  }
95
151
 
152
+ /**
153
+ * Reads the OAuth state from the opener window's sessionStorage when
154
+ * running inside a popup (same-origin). Falls back to the current
155
+ * window's sessionStorage for redirect-based flows or when the
156
+ * opener is unavailable.
157
+ */
158
+ function getSavedOAuthState(): string | null {
159
+ try {
160
+ if (window.opener && !window.opener.closed) {
161
+ const openerState = window.opener.sessionStorage.getItem(
162
+ STORAGE_KEY_STATE,
163
+ );
164
+ if (openerState) return openerState;
165
+ }
166
+ } catch {
167
+ // Cross-origin or closed opener — fall through to local storage.
168
+ }
169
+ return sessionStorage.getItem(STORAGE_KEY_STATE);
170
+ }
171
+
172
+ /**
173
+ * Removes the OAuth state key from both the current window's and the
174
+ * opener's sessionStorage (best-effort).
175
+ */
176
+ function clearOAuthState(): void {
177
+ sessionStorage.removeItem(STORAGE_KEY_STATE);
178
+ try {
179
+ window.opener?.sessionStorage?.removeItem(STORAGE_KEY_STATE);
180
+ } catch {
181
+ // Cross-origin or closed opener — ignore.
182
+ }
183
+ }
184
+
96
185
  /**
97
186
  * Checks whether the personal environment's redacted data contains a given key.
98
187
  * The key is present even when the value is redacted (`***REDACTED***`).
@@ -127,6 +216,8 @@ function personalEnvHasKey(
127
216
  *
128
217
  * @param org - The active organization slug. Required for server-side
129
218
  * token storage. Pass `null` to skip all server operations.
219
+ * @param config - Optional configuration for desktop / non-browser
220
+ * environments. See {@link UseGitHubConnectionConfig}.
130
221
  *
131
222
  * @example
132
223
  * ```tsx
@@ -155,9 +246,23 @@ function personalEnvHasKey(
155
246
  * );
156
247
  * }
157
248
  * ```
249
+ *
250
+ * @example Desktop (Tauri) — open in system browser, callback via deep link
251
+ * ```tsx
252
+ * function DesktopGitHubConnect({ org }: { org: string }) {
253
+ * const gh = useGitHubConnection(org, {
254
+ * openUrl: (url) => invoke("open_auth_in_browser", { authUrl: url }),
255
+ * callbackUrl: "https://app.stigmer.ai/auth/github/callback?source=desktop",
256
+ * });
257
+ * // The web callback page processes the exchange, then redirects to
258
+ * // stigmer://github/callback-done. The desktop deep link handler
259
+ * // calls gh.reconcile() to pick up the token.
260
+ * }
261
+ * ```
158
262
  */
159
263
  export function useGitHubConnection(
160
264
  org: string | null,
265
+ config?: UseGitHubConnectionConfig,
161
266
  ): UseGitHubConnectionReturn {
162
267
  const stigmer = useStigmer();
163
268
  const [token, setToken] = useState<string | null>(null);
@@ -287,8 +392,12 @@ export function useGitHubConnection(
287
392
 
288
393
  const connect = useCallback(
289
394
  async (redirectUri: string, options?: GitHubConnectOptions) => {
395
+ const effectiveRedirectUri = config?.callbackUrl ?? redirectUri;
396
+
290
397
  const { authorizeUrl, state } =
291
- await stigmer.github.getOAuthAuthorizeUrl({ redirectUri });
398
+ await stigmer.github.getOAuthAuthorizeUrl({
399
+ redirectUri: effectiveRedirectUri,
400
+ });
292
401
 
293
402
  sessionStorage.setItem(STORAGE_KEY_STATE, state);
294
403
 
@@ -297,7 +406,18 @@ export function useGitHubConnection(
297
406
  return;
298
407
  }
299
408
 
300
- // If a popup is already open, bring it to focus.
409
+ // ── External opener (desktop / non-browser) ─────────────────────
410
+ // When `config.openUrl` is provided, delegate to the consumer
411
+ // instead of using `window.open()`. The consumer is responsible
412
+ // for delivering the callback data via `handleCallback()` or
413
+ // triggering re-reconciliation via `reconcile()`.
414
+ if (config?.openUrl) {
415
+ setIsConnecting(true);
416
+ await config.openUrl(authorizeUrl);
417
+ return;
418
+ }
419
+
420
+ // ── Browser popup ───────────────────────────────────────────────
301
421
  if (popupRef.current && !popupRef.current.closed) {
302
422
  popupRef.current.focus();
303
423
  return;
@@ -327,43 +447,62 @@ export function useGitHubConnection(
327
447
  popupPollRef.current = null;
328
448
  popupRef.current = null;
329
449
  setIsConnecting(false);
450
+
451
+ // The callback page may have exchanged the code and stored
452
+ // the token server-side (e.g. cross-origin popup flow for
453
+ // platform builders). Re-reconcile from the personal
454
+ // environment to pick up the token.
455
+ setIsLoading(true);
456
+ reconciled.current = false;
457
+ personalEnvRef.current.refetch();
330
458
  }
331
459
  }, POPUP_CLOSE_POLL_MS);
332
460
  popupPollRef.current = pollId;
333
461
  },
334
- [stigmer],
462
+ [stigmer, config?.openUrl, config?.callbackUrl],
335
463
  );
336
464
 
337
465
  const handleCallback = useCallback(
338
466
  async (code: string, state: string, redirectUri: string) => {
339
- const savedState = sessionStorage.getItem(STORAGE_KEY_STATE);
467
+ const savedState = getSavedOAuthState();
340
468
  if (savedState && savedState !== state) {
341
469
  throw new Error("OAuth state mismatch — possible CSRF attack");
342
470
  }
343
- sessionStorage.removeItem(STORAGE_KEY_STATE);
344
-
345
- const { accessToken } = await stigmer.github.exchangeOAuthCode({
346
- code,
347
- state,
348
- redirectUri,
349
- });
350
-
351
- const tokenVar = {
352
- [GITHUB_TOKEN_KEY]: { value: accessToken, isSecret: true },
353
- };
354
- const env = await personalEnvRef.current.getOrCreate(tokenVar);
355
- if (!personalEnvHasKey(env, GITHUB_TOKEN_KEY)) {
356
- await personalEnvRef.current.addVariables(tokenVar);
357
- }
471
+ clearOAuthState();
358
472
 
359
- setToken(accessToken);
473
+ try {
474
+ const { accessToken } = await stigmer.github.exchangeOAuthCode({
475
+ code,
476
+ state,
477
+ redirectUri,
478
+ });
479
+
480
+ const tokenVar = {
481
+ [GITHUB_TOKEN_KEY]: { value: accessToken, isSecret: true },
482
+ };
483
+ const env = await personalEnvRef.current.getOrCreate(tokenVar);
484
+ if (!personalEnvHasKey(env, GITHUB_TOKEN_KEY)) {
485
+ await personalEnvRef.current.addVariables(tokenVar);
486
+ }
360
487
 
361
- const u = await fetchGitHubUser(accessToken);
362
- setUser(u);
488
+ setToken(accessToken);
489
+
490
+ const u = await fetchGitHubUser(accessToken);
491
+ setUser(u);
492
+ } finally {
493
+ setIsConnecting(false);
494
+ }
363
495
  },
364
496
  [stigmer],
365
497
  );
366
498
 
499
+ const reconcile = useCallback(() => {
500
+ setIsConnecting(false);
501
+ setIsLoading(true);
502
+ reconciled.current = false;
503
+ personalEnvRef.current.refetch();
504
+ }, []);
505
+
367
506
  const disconnect = useCallback(() => {
368
507
  sessionStorage.removeItem(STORAGE_KEY_STATE);
369
508
  setToken(null);
@@ -396,6 +535,7 @@ export function useGitHubConnection(
396
535
  token,
397
536
  connect,
398
537
  handleCallback,
538
+ reconcile,
399
539
  disconnect,
400
540
  };
401
541
  }
@@ -26,7 +26,7 @@ export interface IdentityProviderWizardProps {
26
26
  readonly className?: string;
27
27
  }
28
28
 
29
- type WizardStep = "pick" | "configure" | "review";
29
+ type WizardStep = "pick" | "configure" | "review" | "success";
30
30
 
31
31
  /**
32
32
  * Multi-step wizard for creating a new identity provider.
@@ -88,6 +88,9 @@ export function IdentityProviderWizard({
88
88
  const [autoGrantRole, setAutoGrantRole] = useState<IamRole>(IamRole.iam_role_unspecified);
89
89
  const [tenantOrgClaim, setTenantOrgClaim] = useState("");
90
90
 
91
+ // Success step
92
+ const [createdIdp, setCreatedIdp] = useState<IdentityProvider | null>(null);
93
+
91
94
  // -- Step transitions ------------------------------------------------
92
95
 
93
96
  const handlePickProvider = useCallback((selected: ProviderPreset) => {
@@ -186,7 +189,8 @@ export function IdentityProviderWizard({
186
189
  }),
187
190
  }),
188
191
  });
189
- onCreated?.(idp);
192
+ setCreatedIdp(idp);
193
+ setStep("success");
190
194
  } catch {
191
195
  // error state is managed by useCreateIdentityProvider
192
196
  }
@@ -194,7 +198,7 @@ export function IdentityProviderWizard({
194
198
  [
195
199
  name, org, jwksUri, issuers, audience, userinfoEndpoint,
196
200
  isSso, oidcClientId, autoProvision, autoGrant, autoGrantRole,
197
- tenantOrgClaim, create, clearError, onCreated,
201
+ tenantOrgClaim, create, clearError,
198
202
  ],
199
203
  );
200
204
 
@@ -267,6 +271,18 @@ export function IdentityProviderWizard({
267
271
  onCancel={onCancel}
268
272
  />
269
273
  )}
274
+
275
+ {step === "success" && createdIdp && (
276
+ <SuccessStep
277
+ identityProvider={createdIdp}
278
+ org={org}
279
+ isSso={isSso}
280
+ autoProvision={autoProvision}
281
+ autoGrant={autoGrant}
282
+ autoGrantRole={autoGrantRole}
283
+ onDone={() => onCreated?.(createdIdp)}
284
+ />
285
+ )}
270
286
  </div>
271
287
  );
272
288
  }
@@ -279,6 +295,7 @@ const STEPS: { key: WizardStep; label: string }[] = [
279
295
  { key: "pick", label: "Provider" },
280
296
  { key: "configure", label: "Configure" },
281
297
  { key: "review", label: "Review" },
298
+ { key: "success", label: "Done" },
282
299
  ];
283
300
 
284
301
  function StepIndicator({ current }: { current: WizardStep }) {
@@ -608,6 +625,98 @@ function ReviewStep({
608
625
  );
609
626
  }
610
627
 
628
+ // ---------------------------------------------------------------------------
629
+ // Success step (step 4)
630
+ // ---------------------------------------------------------------------------
631
+
632
+ function SuccessStep({
633
+ identityProvider,
634
+ org,
635
+ isSso,
636
+ autoProvision,
637
+ autoGrant,
638
+ autoGrantRole,
639
+ onDone,
640
+ }: {
641
+ identityProvider: IdentityProvider;
642
+ org: string;
643
+ isSso: boolean;
644
+ autoProvision: boolean;
645
+ autoGrant: boolean;
646
+ autoGrantRole: IamRole;
647
+ onDone: () => void;
648
+ }) {
649
+ const displayName =
650
+ identityProvider.spec?.displayName ||
651
+ identityProvider.metadata?.name ||
652
+ "Identity provider";
653
+
654
+ const roleName =
655
+ autoGrantRole !== IamRole.iam_role_unspecified
656
+ ? IamRole[autoGrantRole]
657
+ : "viewer";
658
+
659
+ return (
660
+ <div className="space-y-4">
661
+ <div className="rounded-md border border-primary/30 bg-primary-subtle px-3 py-2.5">
662
+ <p className="text-xs font-medium text-foreground">
663
+ {displayName} created successfully
664
+ </p>
665
+ </div>
666
+
667
+ <div className="space-y-2">
668
+ <p className="text-xs font-medium text-foreground">What happens next</p>
669
+
670
+ {isSso ? (
671
+ <p className="text-[0.65rem] text-muted-foreground">
672
+ Users can sign in via SSO at{" "}
673
+ <span className="font-mono text-foreground">
674
+ /login?org={org}
675
+ </span>
676
+ . Accounts are auto-provisioned and granted the{" "}
677
+ <span className="font-medium text-foreground">viewer</span> role on
678
+ this organization.
679
+ </p>
680
+ ) : autoProvision && autoGrant ? (
681
+ <p className="text-[0.65rem] text-muted-foreground">
682
+ Users authenticating with JWTs from this provider will be
683
+ automatically provisioned and granted the{" "}
684
+ <span className="font-medium text-foreground">{roleName}</span> role
685
+ on this organization. No additional setup is required.
686
+ </p>
687
+ ) : autoProvision ? (
688
+ <p className="text-[0.65rem] text-muted-foreground">
689
+ Accounts are auto-provisioned on first authentication, but no
690
+ organization role is granted automatically. Use the Members page to
691
+ grant access.
692
+ </p>
693
+ ) : (
694
+ <p className="text-[0.65rem] text-muted-foreground">
695
+ The trust relationship is configured. Accounts must be created
696
+ manually before users can authenticate.
697
+ </p>
698
+ )}
699
+
700
+ <p className="text-[0.65rem] text-muted-foreground">
701
+ To verify the setup, have a user authenticate with a JWT from this
702
+ provider and confirm they can access the organization&apos;s resources.
703
+ </p>
704
+ </div>
705
+
706
+ <button
707
+ type="button"
708
+ onClick={onDone}
709
+ className={cn(
710
+ "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium",
711
+ "bg-primary text-primary-foreground hover:bg-primary-hover",
712
+ )}
713
+ >
714
+ Done
715
+ </button>
716
+ </div>
717
+ );
718
+ }
719
+
611
720
  // ---------------------------------------------------------------------------
612
721
  // Shared primitives
613
722
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -315,6 +315,7 @@ export {
315
315
  export type {
316
316
  GitHubUser,
317
317
  GitHubConnectOptions,
318
+ UseGitHubConnectionConfig,
318
319
  UseGitHubConnectionReturn,
319
320
  GitHubRepo,
320
321
  GitHubBranch,
@@ -12,6 +12,8 @@ import { getUserMessage } from "@stigmer/sdk";
12
12
  import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
13
13
  import { useOrganization } from "./useOrganization";
14
14
  import { useUpdateOrganization } from "./useUpdateOrganization";
15
+ import { useIdentityProviderList } from "../identity-provider/useIdentityProviderList";
16
+ import { useResourceAvailable, ApiResourceKind } from "../deployment-mode";
15
17
 
16
18
  // ---------------------------------------------------------------------------
17
19
  // Constants
@@ -338,10 +340,106 @@ export function OrgProfilePanel({
338
340
  </button>
339
341
  )}
340
342
  </div>
343
+
344
+ {/* -- Identity Providers summary -- */}
345
+ <IdentityProvidersSummary orgSlug={serverSlug} />
341
346
  </form>
342
347
  );
343
348
  }
344
349
 
350
+ // ---------------------------------------------------------------------------
351
+ // IdentityProvidersSummary — shows linked IDPs on the org profile
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function IdentityProvidersSummary({ orgSlug }: { orgSlug: string }) {
355
+ const idpAvailable = useResourceAvailable(ApiResourceKind.identity_provider);
356
+ const { identityProviders, isLoading } = useIdentityProviderList(
357
+ idpAvailable && orgSlug ? orgSlug : null,
358
+ );
359
+
360
+ if (!idpAvailable || !orgSlug) return null;
361
+
362
+ return (
363
+ <>
364
+ <hr className="border-border" />
365
+ <div className="space-y-2">
366
+ <p className="text-[0.65rem] font-medium text-muted-foreground uppercase tracking-wider">
367
+ Identity Providers
368
+ </p>
369
+
370
+ {isLoading ? (
371
+ <div className="bg-muted-subtle h-8 animate-pulse rounded" />
372
+ ) : identityProviders.length === 0 ? (
373
+ <p className="text-xs text-muted-foreground">
374
+ No identity providers configured.{" "}
375
+ <a
376
+ href="/settings/identity-providers"
377
+ className="text-primary hover:text-primary-hover underline underline-offset-2"
378
+ >
379
+ Set up federated authentication
380
+ </a>
381
+ </p>
382
+ ) : (
383
+ <div className="space-y-1.5">
384
+ {identityProviders.map((idp) => {
385
+ const spec = idp.spec;
386
+ const displayName =
387
+ spec?.displayName || idp.metadata?.name || "Unnamed";
388
+ const isSso = spec?.isSsoProvider;
389
+ const isJit = !isSso && spec?.autoProvisionAccounts;
390
+
391
+ return (
392
+ <div
393
+ key={idp.metadata?.id}
394
+ className="flex items-center gap-2 text-xs"
395
+ >
396
+ <ShieldSmallIcon />
397
+ <span className="text-foreground truncate">{displayName}</span>
398
+ {isSso && (
399
+ <span className="rounded-full border border-primary/30 bg-primary-subtle px-1.5 py-px text-[0.6rem] font-medium text-primary">
400
+ SSO
401
+ </span>
402
+ )}
403
+ {isJit && (
404
+ <span className="rounded-full border border-primary/30 bg-primary-subtle px-1.5 py-px text-[0.6rem] font-medium text-primary">
405
+ JIT
406
+ </span>
407
+ )}
408
+ </div>
409
+ );
410
+ })}
411
+ <a
412
+ href="/settings/identity-providers"
413
+ className="inline-block text-[0.65rem] text-primary hover:text-primary-hover underline underline-offset-2"
414
+ >
415
+ Manage
416
+ </a>
417
+ </div>
418
+ )}
419
+ </div>
420
+ </>
421
+ );
422
+ }
423
+
424
+ function ShieldSmallIcon() {
425
+ return (
426
+ <svg
427
+ width="12"
428
+ height="12"
429
+ viewBox="0 0 16 16"
430
+ fill="none"
431
+ stroke="currentColor"
432
+ strokeWidth="1.5"
433
+ strokeLinecap="round"
434
+ strokeLinejoin="round"
435
+ aria-hidden="true"
436
+ className="shrink-0 text-muted-foreground"
437
+ >
438
+ <path d="M8 1.5L2 4v4c0 3.5 2.5 5.5 6 7 3.5-1.5 6-3.5 6-7V4L8 1.5z" />
439
+ </svg>
440
+ );
441
+ }
442
+
345
443
  // ---------------------------------------------------------------------------
346
444
  // ReadOnlyField — copiable label/value pair
347
445
  // ---------------------------------------------------------------------------