@stigmer/react 0.0.83 → 0.0.85

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 (109) hide show
  1. package/demo/fixtures.d.ts +4 -0
  2. package/demo/fixtures.d.ts.map +1 -1
  3. package/demo/fixtures.js +4 -0
  4. package/demo/fixtures.js.map +1 -1
  5. package/index.d.ts +5 -3
  6. package/index.d.ts.map +1 -1
  7. package/index.js +3 -1
  8. package/index.js.map +1 -1
  9. package/library/ResourceListView.d.ts +57 -7
  10. package/library/ResourceListView.d.ts.map +1 -1
  11. package/library/ResourceListView.js +147 -37
  12. package/library/ResourceListView.js.map +1 -1
  13. package/library/index.d.ts +1 -1
  14. package/library/index.d.ts.map +1 -1
  15. package/library/index.js.map +1 -1
  16. package/mcp-server/McpServerConfigPanel.d.ts +45 -0
  17. package/mcp-server/McpServerConfigPanel.d.ts.map +1 -1
  18. package/mcp-server/McpServerConfigPanel.js +90 -14
  19. package/mcp-server/McpServerConfigPanel.js.map +1 -1
  20. package/mcp-server/McpServerConnectDialog.d.ts +51 -0
  21. package/mcp-server/McpServerConnectDialog.d.ts.map +1 -0
  22. package/mcp-server/McpServerConnectDialog.js +164 -0
  23. package/mcp-server/McpServerConnectDialog.js.map +1 -0
  24. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  25. package/mcp-server/McpServerDetailView.js +168 -23
  26. package/mcp-server/McpServerDetailView.js.map +1 -1
  27. package/mcp-server/McpServerPicker.d.ts.map +1 -1
  28. package/mcp-server/McpServerPicker.js +9 -3
  29. package/mcp-server/McpServerPicker.js.map +1 -1
  30. package/mcp-server/OAuthAppForm.d.ts +58 -0
  31. package/mcp-server/OAuthAppForm.d.ts.map +1 -0
  32. package/mcp-server/OAuthAppForm.js +67 -0
  33. package/mcp-server/OAuthAppForm.js.map +1 -0
  34. package/mcp-server/index.d.ts +8 -0
  35. package/mcp-server/index.d.ts.map +1 -1
  36. package/mcp-server/index.js +4 -0
  37. package/mcp-server/index.js.map +1 -1
  38. package/mcp-server/useDisconnectOAuth.d.ts +40 -0
  39. package/mcp-server/useDisconnectOAuth.d.ts.map +1 -0
  40. package/mcp-server/useDisconnectOAuth.js +46 -0
  41. package/mcp-server/useDisconnectOAuth.js.map +1 -0
  42. package/mcp-server/useMcpServerCredentials.d.ts +48 -0
  43. package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
  44. package/mcp-server/useMcpServerCredentials.js +18 -2
  45. package/mcp-server/useMcpServerCredentials.js.map +1 -1
  46. package/mcp-server/useOAuthGrantStatus.d.ts +9 -0
  47. package/mcp-server/useOAuthGrantStatus.d.ts.map +1 -1
  48. package/mcp-server/useOAuthGrantStatus.js +6 -1
  49. package/mcp-server/useOAuthGrantStatus.js.map +1 -1
  50. package/mcp-server/useOrgOAuthApp.d.ts +82 -0
  51. package/mcp-server/useOrgOAuthApp.d.ts.map +1 -0
  52. package/mcp-server/useOrgOAuthApp.js +160 -0
  53. package/mcp-server/useOrgOAuthApp.js.map +1 -0
  54. package/oauth-app/CreateOAuthAppForm.d.ts +41 -0
  55. package/oauth-app/CreateOAuthAppForm.d.ts.map +1 -0
  56. package/oauth-app/CreateOAuthAppForm.js +140 -0
  57. package/oauth-app/CreateOAuthAppForm.js.map +1 -0
  58. package/oauth-app/OAuthAppDetailPanel.d.ts +43 -0
  59. package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -0
  60. package/oauth-app/OAuthAppDetailPanel.js +202 -0
  61. package/oauth-app/OAuthAppDetailPanel.js.map +1 -0
  62. package/oauth-app/OAuthAppListPanel.d.ts +43 -0
  63. package/oauth-app/OAuthAppListPanel.d.ts.map +1 -0
  64. package/oauth-app/OAuthAppListPanel.js +79 -0
  65. package/oauth-app/OAuthAppListPanel.js.map +1 -0
  66. package/oauth-app/index.d.ts +15 -0
  67. package/oauth-app/index.d.ts.map +1 -0
  68. package/oauth-app/index.js +8 -0
  69. package/oauth-app/index.js.map +1 -0
  70. package/oauth-app/useCreateOAuthApp.d.ts +39 -0
  71. package/oauth-app/useCreateOAuthApp.d.ts.map +1 -0
  72. package/oauth-app/useCreateOAuthApp.js +50 -0
  73. package/oauth-app/useCreateOAuthApp.js.map +1 -0
  74. package/oauth-app/useDeleteOAuthApp.d.ts +31 -0
  75. package/oauth-app/useDeleteOAuthApp.d.ts.map +1 -0
  76. package/oauth-app/useDeleteOAuthApp.js +43 -0
  77. package/oauth-app/useDeleteOAuthApp.js.map +1 -0
  78. package/oauth-app/useOAuthAppList.d.ts +32 -0
  79. package/oauth-app/useOAuthAppList.d.ts.map +1 -0
  80. package/oauth-app/useOAuthAppList.js +61 -0
  81. package/oauth-app/useOAuthAppList.js.map +1 -0
  82. package/oauth-app/useUpdateOAuthApp.d.ts +38 -0
  83. package/oauth-app/useUpdateOAuthApp.d.ts.map +1 -0
  84. package/oauth-app/useUpdateOAuthApp.js +49 -0
  85. package/oauth-app/useUpdateOAuthApp.js.map +1 -0
  86. package/package.json +4 -4
  87. package/src/demo/fixtures.ts +8 -0
  88. package/src/index.ts +25 -0
  89. package/src/library/ResourceListView.tsx +303 -46
  90. package/src/library/index.ts +4 -1
  91. package/src/mcp-server/McpServerConfigPanel.tsx +370 -45
  92. package/src/mcp-server/McpServerConnectDialog.tsx +527 -0
  93. package/src/mcp-server/McpServerDetailView.tsx +448 -47
  94. package/src/mcp-server/McpServerPicker.tsx +10 -3
  95. package/src/mcp-server/OAuthAppForm.tsx +304 -0
  96. package/src/mcp-server/index.ts +12 -0
  97. package/src/mcp-server/useDisconnectOAuth.ts +76 -0
  98. package/src/mcp-server/useMcpServerCredentials.ts +70 -2
  99. package/src/mcp-server/useOAuthGrantStatus.ts +19 -1
  100. package/src/mcp-server/useOrgOAuthApp.ts +250 -0
  101. package/src/oauth-app/CreateOAuthAppForm.tsx +449 -0
  102. package/src/oauth-app/OAuthAppDetailPanel.tsx +671 -0
  103. package/src/oauth-app/OAuthAppListPanel.tsx +237 -0
  104. package/src/oauth-app/index.ts +14 -0
  105. package/src/oauth-app/useCreateOAuthApp.ts +70 -0
  106. package/src/oauth-app/useDeleteOAuthApp.ts +62 -0
  107. package/src/oauth-app/useOAuthAppList.ts +84 -0
  108. package/src/oauth-app/useUpdateOAuthApp.ts +69 -0
  109. package/styles.css +1 -1
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
+ import { getUserMessage, isRetryableError } from "@stigmer/sdk";
5
6
  import { timestampDate } from "@bufbuild/protobuf/wkt";
6
7
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
8
+ import { OAuthConnectionHealth } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
7
9
  import type {
8
10
  DiscoveredTool,
9
11
  DiscoveredResourceTemplate,
@@ -17,6 +19,9 @@ import { useMcpServerConnect } from "./useMcpServerConnect";
17
19
  import { useMcpServerCredentials } from "./useMcpServerCredentials";
18
20
  import { useMcpServerOAuthConnect } from "./useMcpServerOAuthConnect";
19
21
  import type { OAuthConnectPhase } from "./useMcpServerOAuthConnect";
22
+ import { useDisconnectOAuth } from "./useDisconnectOAuth";
23
+ import { useOrgOAuthApp } from "./useOrgOAuthApp";
24
+ import { OAuthAppForm } from "./OAuthAppForm";
20
25
  import { ErrorMessage } from "../error/ErrorMessage";
21
26
  import { EnvVarForm } from "../environment/EnvVarForm";
22
27
  import type { EnvVarFormVariable } from "../environment/EnvVarForm";
@@ -124,9 +129,16 @@ export function McpServerDetailView({
124
129
  const credentials = useMcpServerCredentials(activeOrg ?? org, mcpServer ?? null);
125
130
  const connection = useMcpServerConnect();
126
131
  const oauth = useMcpServerOAuthConnect();
132
+ const disconnectOAuth = useDisconnectOAuth();
133
+ const orgOAuthApp = useOrgOAuthApp(
134
+ mcpServer?.metadata?.id ?? null,
135
+ activeOrg ?? org,
136
+ );
127
137
 
128
138
  const [showCredentialForm, setShowCredentialForm] = useState(defaultShowCredentialForm);
139
+ const [showByoaForm, setShowByoaForm] = useState(false);
129
140
  const [capabilityTab, setCapabilityTab] = useState<CapabilityTab>(defaultCapabilityTab);
141
+ const byoaDialogRef = useRef<HTMLDialogElement>(null);
130
142
 
131
143
  const onResourceLoadRef = useRef(onResourceLoad);
132
144
  onResourceLoadRef.current = onResourceLoad;
@@ -206,6 +218,55 @@ export function McpServerDetailView({
206
218
  [credentials, mcpServer, connection, refetch],
207
219
  );
208
220
 
221
+ const handleDisconnect = useCallback(async () => {
222
+ if (!mcpServer?.metadata?.id) return;
223
+ try {
224
+ await disconnectOAuth.disconnect(mcpServer.metadata.id, activeOrg ?? org);
225
+ credentials.refetch();
226
+ refetch();
227
+ } catch {
228
+ // error state is managed by the disconnect hook
229
+ }
230
+ }, [mcpServer, disconnectOAuth, credentials, refetch, activeOrg, org]);
231
+
232
+ // BYOA dialog lifecycle — native <dialog> toggle
233
+ useEffect(() => {
234
+ const dialog = byoaDialogRef.current;
235
+ if (!dialog) return;
236
+ if (showByoaForm && !dialog.open) {
237
+ dialog.showModal();
238
+ } else if (!showByoaForm && dialog.open) {
239
+ dialog.close();
240
+ }
241
+ }, [showByoaForm]);
242
+
243
+ const handleByoaDialogCancel = useCallback(
244
+ (e: React.SyntheticEvent) => {
245
+ e.preventDefault();
246
+ setShowByoaForm(false);
247
+ orgOAuthApp.clearErrors();
248
+ },
249
+ [orgOAuthApp],
250
+ );
251
+
252
+ const handleByoaSubmit = useCallback(
253
+ async (clientId: string, clientSecret: string) => {
254
+ await orgOAuthApp.setOrgOAuthApp(clientId, clientSecret);
255
+ setShowByoaForm(false);
256
+ orgOAuthApp.refetch();
257
+ credentials.refetch();
258
+ refetch();
259
+ },
260
+ [orgOAuthApp, credentials, refetch],
261
+ );
262
+
263
+ const handleRemoveOrgApp = useCallback(async () => {
264
+ await orgOAuthApp.deleteOrgOAuthApp();
265
+ orgOAuthApp.refetch();
266
+ credentials.refetch();
267
+ refetch();
268
+ }, [orgOAuthApp, credentials, refetch]);
269
+
209
270
  const spec = mcpServer?.spec;
210
271
  const status = mcpServer?.status;
211
272
  const hasSource = spec && (spec.repositoryUrl || spec.githubStars > 0);
@@ -294,10 +355,25 @@ export function McpServerDetailView({
294
355
  oauthPhase={oauth.phase}
295
356
  authMode={credentials.authMode}
296
357
  isOAuthConnected={credentials.isOAuthConnected}
358
+ connectionHealth={credentials.connectionHealth}
359
+ canDisconnect={credentials.canDisconnect}
360
+ onDisconnect={handleDisconnect}
361
+ isDisconnecting={disconnectOAuth.isDisconnecting}
362
+ disconnectError={disconnectOAuth.error}
363
+ onClearDisconnectError={disconnectOAuth.clearError}
364
+ serverName={mcpServer?.metadata?.name ?? slug}
297
365
  accessTokenExpiresAt={credentials.accessTokenExpiresAt}
298
366
  tokenLifetimeHint={credentials.tokenLifetimeHint}
299
367
  isVendorApprovalPending={credentials.isVendorApprovalPending}
368
+ isVendorApprovalBlocked={credentials.isVendorApprovalBlocked}
300
369
  vendorApprovalDocsUrl={credentials.vendorApprovalDocsUrl}
370
+ canBringOwnApp={credentials.canBringOwnApp}
371
+ isOrgOAuthApp={credentials.isOrgOAuthApp}
372
+ onBringOwnApp={() => setShowByoaForm(true)}
373
+ onRemoveOrgApp={handleRemoveOrgApp}
374
+ isRemovingOrgApp={orgOAuthApp.isDeleting}
375
+ removeOrgAppError={orgOAuthApp.deleteError}
376
+ onClearRemoveOrgAppError={orgOAuthApp.clearErrors}
301
377
  manualOverride={credentials.manualOverride}
302
378
  onManualOverride={() => {
303
379
  credentials.setManualOverride(true);
@@ -328,6 +404,31 @@ export function McpServerDetailView({
328
404
  )}
329
405
  </Section>
330
406
 
407
+ {/* BYOA dialog — native <dialog> for zero-dependency modal behavior */}
408
+ <dialog
409
+ ref={byoaDialogRef}
410
+ onCancel={handleByoaDialogCancel}
411
+ className={cn(
412
+ "m-auto w-full max-w-md rounded-lg border border-border bg-background p-6 shadow-lg",
413
+ "backdrop:bg-black/50",
414
+ )}
415
+ >
416
+ <h3 className="mb-4 text-base font-semibold text-foreground">
417
+ Use your own OAuth app
418
+ </h3>
419
+ <OAuthAppForm
420
+ providerName={mcpServer?.metadata?.name ?? slug}
421
+ vendorDocsUrl={credentials.vendorApprovalDocsUrl}
422
+ onSubmit={handleByoaSubmit}
423
+ onCancel={() => {
424
+ setShowByoaForm(false);
425
+ orgOAuthApp.clearErrors();
426
+ }}
427
+ isSubmitting={orgOAuthApp.isSetting}
428
+ error={orgOAuthApp.setError}
429
+ />
430
+ </dialog>
431
+
331
432
  <Section title="Capabilities">
332
433
  <Tabs
333
434
  tabs={capabilityTabs}
@@ -362,6 +463,75 @@ export function McpServerDetailView({
362
463
  // ConnectBar — single entry point for capability discovery
363
464
  // ---------------------------------------------------------------------------
364
465
 
466
+ /** Maps an OAuthConnectionHealth enum to pill display properties. */
467
+ function healthPillProps(
468
+ health: OAuthConnectionHealth,
469
+ isVendorApprovalPending: boolean,
470
+ ): { pillClass: string; dotClass: string; label: string } {
471
+ if (isVendorApprovalPending) {
472
+ return {
473
+ pillClass: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
474
+ dotClass: "bg-amber-500",
475
+ label: "Pending approval",
476
+ };
477
+ }
478
+ switch (health) {
479
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_HEALTHY:
480
+ return {
481
+ pillClass: "bg-success/10 text-success",
482
+ dotClass: "bg-success",
483
+ label: "Connected",
484
+ };
485
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_TOKEN_EXPIRED_REFRESHABLE:
486
+ return {
487
+ pillClass: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
488
+ dotClass: "bg-amber-500",
489
+ label: "Token expired",
490
+ };
491
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_TOKEN_EXPIRED:
492
+ return {
493
+ pillClass: "bg-destructive/10 text-destructive",
494
+ dotClass: "bg-destructive",
495
+ label: "Re-auth needed",
496
+ };
497
+ default:
498
+ return {
499
+ pillClass: "bg-muted text-muted-foreground",
500
+ dotClass: "bg-muted-foreground",
501
+ label: "Not connected",
502
+ };
503
+ }
504
+ }
505
+
506
+ /** Health-aware status detail text shown alongside the pill. */
507
+ function healthStatusText(
508
+ health: OAuthConnectionHealth,
509
+ accessTokenExpiresAt: bigint,
510
+ tokenLifetimeHint: string | null,
511
+ ): string {
512
+ switch (health) {
513
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_HEALTHY: {
514
+ const expiryLabel = formatTokenExpiry(accessTokenExpiresAt);
515
+ if (expiryLabel) return `Tokens refresh automatically \u00B7 ${expiryLabel}`;
516
+ const hint =
517
+ tokenLifetimeHint && tokenLifetimeHint !== "never"
518
+ ? ` \u00B7 Session lasts ~${tokenLifetimeHint}`
519
+ : "";
520
+ return `Tokens refresh automatically${hint}`;
521
+ }
522
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_TOKEN_EXPIRED_REFRESHABLE:
523
+ return "Will refresh automatically on next use";
524
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_TOKEN_EXPIRED:
525
+ return "Token expired \u2014 sign in again to reconnect";
526
+ default:
527
+ return "Not connected yet";
528
+ }
529
+ }
530
+
531
+ type DisconnectPhase = "idle" | "confirming" | "disconnecting";
532
+
533
+ type RemoveOrgAppPhase = "idle" | "confirming" | "removing";
534
+
365
535
  function ConnectBar({
366
536
  isConnecting,
367
537
  connectionError,
@@ -374,10 +544,25 @@ function ConnectBar({
374
544
  oauthPhase,
375
545
  authMode,
376
546
  isOAuthConnected,
547
+ connectionHealth,
548
+ canDisconnect,
549
+ onDisconnect,
550
+ isDisconnecting,
551
+ disconnectError,
552
+ onClearDisconnectError,
553
+ serverName,
377
554
  accessTokenExpiresAt,
378
555
  tokenLifetimeHint,
379
556
  isVendorApprovalPending,
557
+ isVendorApprovalBlocked,
380
558
  vendorApprovalDocsUrl,
559
+ canBringOwnApp,
560
+ isOrgOAuthApp,
561
+ onBringOwnApp,
562
+ onRemoveOrgApp,
563
+ isRemovingOrgApp,
564
+ removeOrgAppError,
565
+ onClearRemoveOrgAppError,
381
566
  manualOverride,
382
567
  onManualOverride,
383
568
  onBackToOAuth,
@@ -393,14 +578,32 @@ function ConnectBar({
393
578
  readonly oauthPhase: OAuthConnectPhase;
394
579
  readonly authMode: "manual" | "oauth";
395
580
  readonly isOAuthConnected: boolean;
581
+ readonly connectionHealth: OAuthConnectionHealth;
582
+ readonly canDisconnect: boolean;
583
+ readonly onDisconnect: () => Promise<void>;
584
+ readonly isDisconnecting: boolean;
585
+ readonly disconnectError: Error | null;
586
+ readonly onClearDisconnectError: () => void;
587
+ readonly serverName: string;
396
588
  readonly accessTokenExpiresAt: bigint;
397
589
  readonly tokenLifetimeHint: string | null;
398
590
  readonly isVendorApprovalPending: boolean;
591
+ readonly isVendorApprovalBlocked: boolean;
399
592
  readonly vendorApprovalDocsUrl: string | null;
593
+ readonly canBringOwnApp: boolean;
594
+ readonly isOrgOAuthApp: boolean;
595
+ readonly onBringOwnApp: () => void;
596
+ readonly onRemoveOrgApp: () => Promise<void>;
597
+ readonly isRemovingOrgApp: boolean;
598
+ readonly removeOrgAppError: Error | null;
599
+ readonly onClearRemoveOrgAppError: () => void;
400
600
  readonly manualOverride: boolean;
401
601
  readonly onManualOverride: () => void;
402
602
  readonly onBackToOAuth: () => void;
403
603
  }) {
604
+ const [disconnectPhase, setDisconnectPhase] = useState<DisconnectPhase>("idle");
605
+ const [removeOrgAppPhase, setRemoveOrgAppPhase] = useState<RemoveOrgAppPhase>("idle");
606
+
404
607
  const isOAuthBusy =
405
608
  oauthPhase === "initiating" ||
406
609
  oauthPhase === "awaiting-callback" ||
@@ -410,37 +613,188 @@ function ConnectBar({
410
613
  const showOAuthPrimary =
411
614
  authMode === "oauth" && !isOAuthConnected && !manualOverride;
412
615
 
413
- const oauthSignInDisabled = isVendorApprovalPending && showOAuthPrimary;
616
+ const needsReAuth =
617
+ connectionHealth === OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_TOKEN_EXPIRED;
618
+
619
+ const oauthSignInDisabled =
620
+ isVendorApprovalBlocked && showOAuthPrimary && !isOrgOAuthApp;
621
+
622
+ const anyBusy = isConnecting || isOAuthBusy || isDisconnecting || isRemovingOrgApp;
414
623
 
415
624
  const buttonLabel = (() => {
416
625
  if (isOAuthBusy) return oauthPhaseLabel(oauthPhase);
417
626
  if (isConnecting) return "Connecting...";
418
- if (showOAuthPrimary) return "Sign in to connect";
627
+ if (isOrgOAuthApp && showOAuthPrimary) return "Sign in with your app";
628
+ if (showOAuthPrimary || needsReAuth) return "Sign in to connect";
419
629
  if (hasDiscoveredTools) return "Reconnect";
420
630
  return "Connect";
421
631
  })();
422
632
 
423
633
  const buttonIcon = (() => {
424
634
  if (isOAuthBusy || isConnecting) return <Spinner />;
425
- if (showOAuthPrimary) return <OAuthIcon className="size-3.5" />;
635
+ if (showOAuthPrimary || needsReAuth) return <OAuthIcon className="size-3.5" />;
426
636
  if (hasDiscoveredTools) return <RefreshIcon className="size-3.5" />;
427
637
  return <ConnectIcon className="size-3.5" />;
428
638
  })();
429
639
 
430
640
  const statusText = (() => {
431
641
  if (authMode === "oauth" && isOAuthConnected) {
432
- const expiryLabel = formatTokenExpiry(accessTokenExpiresAt);
433
- if (expiryLabel) return `Tokens refresh automatically \u00B7 ${expiryLabel}`;
434
- const hint = tokenLifetimeHint && tokenLifetimeHint !== "never"
435
- ? ` \u00B7 Session lasts ~${tokenLifetimeHint}`
436
- : "";
437
- return `Tokens refresh automatically${hint}`;
642
+ const base = healthStatusText(connectionHealth, accessTokenExpiresAt, tokenLifetimeHint);
643
+ return isOrgOAuthApp ? `${base} \u00B7 Using your OAuth app` : base;
438
644
  }
645
+ if (isOrgOAuthApp && showOAuthPrimary) return "Using your OAuth app";
439
646
  if (manualOverride) return "Entering token manually";
440
647
  if (hasDiscoveredTools) return formatConnectionSummary(toolCount, policyCount);
441
648
  return "Not connected yet";
442
649
  })();
443
650
 
651
+ const pill = healthPillProps(connectionHealth, isVendorApprovalPending && !isOAuthConnected);
652
+
653
+ const showDisconnectLink =
654
+ canDisconnect && !anyBusy && !manualOverride && disconnectPhase === "idle";
655
+
656
+ const showRemoveOrgAppLink =
657
+ isOrgOAuthApp && !anyBusy && removeOrgAppPhase === "idle";
658
+
659
+ // Inline disconnect confirmation replaces the main bar content
660
+ if (disconnectPhase === "confirming" || disconnectPhase === "disconnecting") {
661
+ return (
662
+ <div className="flex flex-col">
663
+ <div className="flex items-center gap-2 px-3 py-2">
664
+ <WarningIcon className="size-3.5 shrink-0 text-destructive" />
665
+ <p className="flex-1 text-xs text-foreground">
666
+ Remove OAuth credentials for <span className="font-medium">{serverName}</span>?
667
+ You can reconnect at any time.
668
+ </p>
669
+ <div className="flex shrink-0 items-center gap-1.5">
670
+ <button
671
+ type="button"
672
+ disabled={isDisconnecting}
673
+ onClick={async () => {
674
+ setDisconnectPhase("disconnecting");
675
+ try {
676
+ await onDisconnect();
677
+ setDisconnectPhase("idle");
678
+ } catch {
679
+ setDisconnectPhase("confirming");
680
+ }
681
+ }}
682
+ className={cn(
683
+ "inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium",
684
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
685
+ "disabled:pointer-events-none disabled:opacity-50",
686
+ )}
687
+ >
688
+ {isDisconnecting && <Spinner />}
689
+ Disconnect
690
+ </button>
691
+ <button
692
+ type="button"
693
+ disabled={isDisconnecting}
694
+ onClick={() => {
695
+ setDisconnectPhase("idle");
696
+ onClearDisconnectError();
697
+ }}
698
+ className={cn(
699
+ "inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium",
700
+ "border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
701
+ "disabled:pointer-events-none disabled:opacity-50",
702
+ )}
703
+ >
704
+ Cancel
705
+ </button>
706
+ </div>
707
+ </div>
708
+ {disconnectError && (
709
+ <div className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-3 py-2">
710
+ <WarningIcon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
711
+ <p className="flex-1 text-xs text-destructive">
712
+ {getUserMessage(disconnectError)}
713
+ </p>
714
+ <button
715
+ type="button"
716
+ onClick={onClearDisconnectError}
717
+ className="shrink-0 text-xs text-destructive/70 hover:text-destructive"
718
+ aria-label="Dismiss error"
719
+ >
720
+ Dismiss
721
+ </button>
722
+ </div>
723
+ )}
724
+ </div>
725
+ );
726
+ }
727
+
728
+ // Inline "remove custom app" confirmation
729
+ if (removeOrgAppPhase === "confirming" || removeOrgAppPhase === "removing") {
730
+ return (
731
+ <div className="flex flex-col">
732
+ <div className="flex items-center gap-2 px-3 py-2">
733
+ <WarningIcon className="size-3.5 shrink-0 text-destructive" />
734
+ <p className="flex-1 text-xs text-foreground">
735
+ Remove your custom OAuth app for{" "}
736
+ <span className="font-medium">{serverName}</span>? The server
737
+ will revert to the platform&apos;s OAuth app.
738
+ </p>
739
+ <div className="flex shrink-0 items-center gap-1.5">
740
+ <button
741
+ type="button"
742
+ disabled={isRemovingOrgApp}
743
+ onClick={async () => {
744
+ setRemoveOrgAppPhase("removing");
745
+ try {
746
+ await onRemoveOrgApp();
747
+ setRemoveOrgAppPhase("idle");
748
+ } catch {
749
+ setRemoveOrgAppPhase("confirming");
750
+ }
751
+ }}
752
+ className={cn(
753
+ "inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium",
754
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
755
+ "disabled:pointer-events-none disabled:opacity-50",
756
+ )}
757
+ >
758
+ {isRemovingOrgApp && <Spinner />}
759
+ Remove
760
+ </button>
761
+ <button
762
+ type="button"
763
+ disabled={isRemovingOrgApp}
764
+ onClick={() => {
765
+ setRemoveOrgAppPhase("idle");
766
+ onClearRemoveOrgAppError();
767
+ }}
768
+ className={cn(
769
+ "inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium",
770
+ "border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
771
+ "disabled:pointer-events-none disabled:opacity-50",
772
+ )}
773
+ >
774
+ Cancel
775
+ </button>
776
+ </div>
777
+ </div>
778
+ {removeOrgAppError && (
779
+ <div className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-3 py-2">
780
+ <WarningIcon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
781
+ <p className="flex-1 text-xs text-destructive">
782
+ {getUserMessage(removeOrgAppError)}
783
+ </p>
784
+ <button
785
+ type="button"
786
+ onClick={onClearRemoveOrgAppError}
787
+ className="shrink-0 text-xs text-destructive/70 hover:text-destructive"
788
+ aria-label="Dismiss error"
789
+ >
790
+ Dismiss
791
+ </button>
792
+ </div>
793
+ )}
794
+ </div>
795
+ );
796
+ }
797
+
444
798
  return (
445
799
  <div className="flex flex-col">
446
800
  <div className="flex items-center justify-between px-3 py-2">
@@ -449,39 +803,46 @@ function ConnectBar({
449
803
  <span
450
804
  className={cn(
451
805
  "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-medium",
452
- isOAuthConnected
453
- ? "bg-success/10 text-success"
454
- : oauthSignInDisabled
455
- ? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
456
- : "bg-muted text-muted-foreground",
806
+ pill.pillClass,
457
807
  )}
458
808
  >
459
809
  <span
460
- className={cn(
461
- "size-1.5 rounded-full",
462
- isOAuthConnected
463
- ? "bg-success"
464
- : oauthSignInDisabled
465
- ? "bg-amber-500"
466
- : "bg-muted-foreground",
467
- )}
810
+ className={cn("size-1.5 rounded-full", pill.dotClass)}
468
811
  aria-hidden="true"
469
812
  />
470
- {isOAuthConnected ? "Connected" : oauthSignInDisabled ? "Pending approval" : "Not connected"}
813
+ {pill.label}
471
814
  </span>
472
815
  )}
473
816
  <span className="text-xs text-muted-foreground">
474
817
  {oauthSignInDisabled ? "OAuth sign-in is pending vendor approval" : statusText}
475
818
  </span>
819
+ {showDisconnectLink && (
820
+ <button
821
+ type="button"
822
+ onClick={() => setDisconnectPhase("confirming")}
823
+ className="text-[11px] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
824
+ >
825
+ Disconnect
826
+ </button>
827
+ )}
828
+ {showRemoveOrgAppLink && (
829
+ <button
830
+ type="button"
831
+ onClick={() => setRemoveOrgAppPhase("confirming")}
832
+ className="text-[11px] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
833
+ >
834
+ Remove custom app
835
+ </button>
836
+ )}
476
837
  </div>
477
838
  <button
478
839
  type="button"
479
840
  onClick={onConnect}
480
- disabled={isConnecting || isOAuthBusy || credentialsLoading || oauthSignInDisabled}
841
+ disabled={anyBusy || credentialsLoading || oauthSignInDisabled}
481
842
  data-cursor-target="connect-button"
482
843
  className={cn(
483
844
  "inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium",
484
- showOAuthPrimary
845
+ showOAuthPrimary || needsReAuth
485
846
  ? "bg-primary text-primary-foreground hover:bg-primary-hover"
486
847
  : "border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
487
848
  "disabled:pointer-events-none disabled:opacity-50",
@@ -492,16 +853,28 @@ function ConnectBar({
492
853
  </button>
493
854
  </div>
494
855
 
495
- {/* Vendor approval pending banner with docs link */}
856
+ {/* Vendor approval blocked banner with BYOA CTA */}
496
857
  {oauthSignInDisabled && (
497
858
  <div className="flex items-start gap-2 border-t border-amber-500/20 bg-amber-500/5 px-3 py-2">
498
859
  <WarningIcon className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
499
860
  <div className="flex-1 text-xs text-amber-700 dark:text-amber-300">
500
861
  <p>
501
862
  The platform&apos;s OAuth app is awaiting vendor approval.
502
- You can still connect by entering your own token manually.
863
+ {canBringOwnApp
864
+ ? " You can use your own OAuth app or enter a token manually."
865
+ : " You can still connect by entering your own token manually."}
503
866
  </p>
504
- {vendorApprovalDocsUrl && (
867
+ {canBringOwnApp && (
868
+ <button
869
+ type="button"
870
+ onClick={onBringOwnApp}
871
+ data-cursor-target="byoa-cta-button"
872
+ className="mt-1.5 inline-flex items-center gap-1 rounded-md bg-amber-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
873
+ >
874
+ Use your own OAuth app
875
+ </button>
876
+ )}
877
+ {vendorApprovalDocsUrl && !canBringOwnApp && (
505
878
  <a
506
879
  href={vendorApprovalDocsUrl}
507
880
  target="_blank"
@@ -516,9 +889,9 @@ function ConnectBar({
516
889
  </div>
517
890
  )}
518
891
 
519
- {/* Secondary action: switch between OAuth and manual token entry */}
892
+ {/* Secondary actions: manual entry, BYOA, back to OAuth */}
520
893
  {authMode === "oauth" && !isOAuthConnected && !isOAuthBusy && !isConnecting && (
521
- <div className="border-t border-border px-3 py-1.5">
894
+ <div className="flex items-center gap-3 border-t border-border px-3 py-1.5">
522
895
  {manualOverride ? (
523
896
  <button
524
897
  type="button"
@@ -528,31 +901,59 @@ function ConnectBar({
528
901
  {isVendorApprovalPending ? "Back to OAuth status" : "Sign in with OAuth instead"}
529
902
  </button>
530
903
  ) : (
531
- <button
532
- type="button"
533
- onClick={onManualOverride}
534
- className="text-[11px] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
535
- >
536
- Enter token manually
537
- </button>
904
+ <>
905
+ <button
906
+ type="button"
907
+ onClick={onManualOverride}
908
+ className="text-[11px] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
909
+ >
910
+ Enter token manually
911
+ </button>
912
+ {canBringOwnApp && !isVendorApprovalBlocked && (
913
+ <button
914
+ type="button"
915
+ onClick={onBringOwnApp}
916
+ className="text-[11px] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
917
+ >
918
+ Use your own OAuth app
919
+ </button>
920
+ )}
921
+ </>
538
922
  )}
539
923
  </div>
540
924
  )}
541
925
 
542
926
  {connectionError && (
543
- <div className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-3 py-2">
927
+ <div
928
+ className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-3 py-2"
929
+ role="alert"
930
+ >
544
931
  <WarningIcon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
545
932
  <p className="flex-1 text-xs text-destructive">
546
- {connectionError.message}
933
+ {getUserMessage(connectionError)}
547
934
  </p>
548
- <button
549
- type="button"
550
- onClick={onClearConnectionError}
551
- className="shrink-0 text-xs text-destructive/70 hover:text-destructive"
552
- aria-label="Dismiss error"
553
- >
554
- Dismiss
555
- </button>
935
+ <div className="flex shrink-0 items-center gap-2">
936
+ {isRetryableError(connectionError) && (
937
+ <button
938
+ type="button"
939
+ onClick={() => {
940
+ onClearConnectionError();
941
+ onConnect();
942
+ }}
943
+ className="text-xs font-medium text-destructive underline underline-offset-2 hover:no-underline"
944
+ >
945
+ Try again
946
+ </button>
947
+ )}
948
+ <button
949
+ type="button"
950
+ onClick={onClearConnectionError}
951
+ className="text-xs text-destructive/70 hover:text-destructive"
952
+ aria-label="Dismiss error"
953
+ >
954
+ Dismiss
955
+ </button>
956
+ </div>
556
957
  </div>
557
958
  )}
558
959
  </div>