@stigmer/react 0.0.82 → 0.0.84

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 (58) hide show
  1. package/index.d.ts +3 -3
  2. package/index.d.ts.map +1 -1
  3. package/index.js +1 -1
  4. package/index.js.map +1 -1
  5. package/library/ResourceListView.d.ts +57 -7
  6. package/library/ResourceListView.d.ts.map +1 -1
  7. package/library/ResourceListView.js +147 -37
  8. package/library/ResourceListView.js.map +1 -1
  9. package/library/index.d.ts +1 -1
  10. package/library/index.d.ts.map +1 -1
  11. package/library/index.js.map +1 -1
  12. package/mcp-server/McpServerConfigPanel.d.ts +45 -0
  13. package/mcp-server/McpServerConfigPanel.d.ts.map +1 -1
  14. package/mcp-server/McpServerConfigPanel.js +90 -14
  15. package/mcp-server/McpServerConfigPanel.js.map +1 -1
  16. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  17. package/mcp-server/McpServerDetailView.js +168 -23
  18. package/mcp-server/McpServerDetailView.js.map +1 -1
  19. package/mcp-server/McpServerPicker.js +3 -3
  20. package/mcp-server/McpServerPicker.js.map +1 -1
  21. package/mcp-server/OAuthAppForm.d.ts +58 -0
  22. package/mcp-server/OAuthAppForm.d.ts.map +1 -0
  23. package/mcp-server/OAuthAppForm.js +67 -0
  24. package/mcp-server/OAuthAppForm.js.map +1 -0
  25. package/mcp-server/index.d.ts +6 -0
  26. package/mcp-server/index.d.ts.map +1 -1
  27. package/mcp-server/index.js +3 -0
  28. package/mcp-server/index.js.map +1 -1
  29. package/mcp-server/useDisconnectOAuth.d.ts +40 -0
  30. package/mcp-server/useDisconnectOAuth.d.ts.map +1 -0
  31. package/mcp-server/useDisconnectOAuth.js +46 -0
  32. package/mcp-server/useDisconnectOAuth.js.map +1 -0
  33. package/mcp-server/useMcpServerCredentials.d.ts +48 -0
  34. package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
  35. package/mcp-server/useMcpServerCredentials.js +18 -2
  36. package/mcp-server/useMcpServerCredentials.js.map +1 -1
  37. package/mcp-server/useOAuthGrantStatus.d.ts +9 -0
  38. package/mcp-server/useOAuthGrantStatus.d.ts.map +1 -1
  39. package/mcp-server/useOAuthGrantStatus.js +6 -1
  40. package/mcp-server/useOAuthGrantStatus.js.map +1 -1
  41. package/mcp-server/useOrgOAuthApp.d.ts +82 -0
  42. package/mcp-server/useOrgOAuthApp.d.ts.map +1 -0
  43. package/mcp-server/useOrgOAuthApp.js +160 -0
  44. package/mcp-server/useOrgOAuthApp.js.map +1 -0
  45. package/package.json +4 -4
  46. package/src/index.ts +3 -0
  47. package/src/library/ResourceListView.tsx +303 -46
  48. package/src/library/index.ts +4 -1
  49. package/src/mcp-server/McpServerConfigPanel.tsx +370 -45
  50. package/src/mcp-server/McpServerDetailView.tsx +447 -47
  51. package/src/mcp-server/McpServerPicker.tsx +3 -3
  52. package/src/mcp-server/OAuthAppForm.tsx +304 -0
  53. package/src/mcp-server/index.ts +9 -0
  54. package/src/mcp-server/useDisconnectOAuth.ts +76 -0
  55. package/src/mcp-server/useMcpServerCredentials.ts +70 -2
  56. package/src/mcp-server/useOAuthGrantStatus.ts +19 -1
  57. package/src/mcp-server/useOrgOAuthApp.ts +250 -0
  58. package/styles.css +1 -1
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { useCallback } from "react";
3
+ import { useCallback, useState } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
+ import { getUserMessage, isRetryableError } from "@stigmer/sdk";
5
6
  import type { EnvVarInput } from "@stigmer/sdk";
7
+ import { OAuthConnectionHealth } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
6
8
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
7
9
  import type { DiscoveredTool } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/status_pb";
8
10
  import type { ToolApprovalPolicy } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
@@ -72,6 +74,23 @@ export interface McpServerOAuthSignInProps {
72
74
  readonly phase: OAuthConnectPhase;
73
75
  /** `true` when the OAuth token already exists in the personal environment. */
74
76
  readonly isConnected: boolean;
77
+ /**
78
+ * Health of the OAuth connection. Drives the status dot color and
79
+ * label beyond the binary `isConnected` boolean.
80
+ */
81
+ readonly connectionHealth?: OAuthConnectionHealth;
82
+ /**
83
+ * Called to disconnect the OAuth grant. When provided and the user
84
+ * is connected, a "Disconnect" link is shown. The parent is responsible
85
+ * for refreshing credentials after the promise resolves.
86
+ */
87
+ readonly onDisconnect?: () => Promise<void>;
88
+ /** `true` while a disconnect operation is in flight. */
89
+ readonly isDisconnecting?: boolean;
90
+ /** Error from the most recent failed disconnect, or `null`. */
91
+ readonly disconnectError?: Error | null;
92
+ /** Clear the disconnect error state. */
93
+ readonly onClearDisconnectError?: () => void;
75
94
  /** Error from the most recent failed OAuth attempt, or `null`. */
76
95
  readonly error: Error | null;
77
96
  /** Clear the OAuth error state. */
@@ -81,11 +100,38 @@ export interface McpServerOAuthSignInProps {
81
100
  * Disables the sign-in button and shows an informational message.
82
101
  */
83
102
  readonly isVendorApprovalPending?: boolean;
103
+ /**
104
+ * `true` when the platform OAuth app's vendor approval is PENDING
105
+ * or REJECTED — the platform sign-in flow is blocked. Covers both
106
+ * statuses. When omitted, falls back to `isVendorApprovalPending`.
107
+ */
108
+ readonly isVendorApprovalBlocked?: boolean;
84
109
  /**
85
110
  * Documentation URL for bringing your own OAuth token.
86
111
  * Shown as a help link when `isVendorApprovalPending` is `true`.
87
112
  */
88
113
  readonly vendorApprovalDocsUrl?: string | null;
114
+ /**
115
+ * `true` when the BYOA (Bring Your Own App) option is relevant:
116
+ * the server uses vendor OAuth and no org override exists.
117
+ */
118
+ readonly canBringOwnApp?: boolean;
119
+ /**
120
+ * `true` when an org-level BYOA override is active.
121
+ */
122
+ readonly isOrgOAuthApp?: boolean;
123
+ /**
124
+ * Open the BYOA form. The parent is responsible for rendering the
125
+ * form/dialog and handling the mutation.
126
+ */
127
+ readonly onBringOwnApp?: () => void;
128
+ /**
129
+ * Remove the org's BYOA override. The parent is responsible for
130
+ * refreshing state after the promise resolves.
131
+ */
132
+ readonly onRemoveOrgApp?: () => Promise<void>;
133
+ /** `true` while a remove-org-app operation is in flight. */
134
+ readonly isRemovingOrgApp?: boolean;
89
135
  }
90
136
 
91
137
  // ---------------------------------------------------------------------------
@@ -277,12 +323,23 @@ export function McpServerConfigPanel({
277
323
  <InlineOAuthSignIn
278
324
  serverName={serverName}
279
325
  isConnected={oauthSignIn.isConnected}
326
+ connectionHealth={oauthSignIn.connectionHealth}
280
327
  phase={oauthSignIn.phase}
281
328
  onSignIn={oauthSignIn.onSignIn}
329
+ onDisconnect={oauthSignIn.onDisconnect}
330
+ isDisconnecting={oauthSignIn.isDisconnecting}
331
+ disconnectError={oauthSignIn.disconnectError}
332
+ onClearDisconnectError={oauthSignIn.onClearDisconnectError}
282
333
  error={oauthSignIn.error}
283
334
  onClearError={oauthSignIn.onClearError}
284
335
  isVendorApprovalPending={oauthSignIn.isVendorApprovalPending}
336
+ isVendorApprovalBlocked={oauthSignIn.isVendorApprovalBlocked}
285
337
  vendorApprovalDocsUrl={oauthSignIn.vendorApprovalDocsUrl}
338
+ canBringOwnApp={oauthSignIn.canBringOwnApp}
339
+ isOrgOAuthApp={oauthSignIn.isOrgOAuthApp}
340
+ onBringOwnApp={oauthSignIn.onBringOwnApp}
341
+ onRemoveOrgApp={oauthSignIn.onRemoveOrgApp}
342
+ isRemovingOrgApp={oauthSignIn.isRemovingOrgApp}
286
343
  onSwitchToManual={onSwitchToManual}
287
344
  />
288
345
  )}
@@ -319,7 +376,7 @@ export function McpServerConfigPanel({
319
376
  role="alert"
320
377
  className="rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-xs text-destructive"
321
378
  >
322
- {error.message}
379
+ {getUserMessage(error)}
323
380
  </div>
324
381
  )}
325
382
 
@@ -339,68 +396,279 @@ export function McpServerConfigPanel({
339
396
  // Inline OAuth sign-in (compact, for config panel context)
340
397
  // ---------------------------------------------------------------------------
341
398
 
399
+ /** Maps OAuthConnectionHealth to compact status dot + label for InlineOAuthSignIn. */
400
+ function inlineHealthProps(
401
+ health: OAuthConnectionHealth | undefined,
402
+ isConnected: boolean,
403
+ isVendorApprovalPending: boolean,
404
+ ): { textClass: string; dotClass: string; label: string } {
405
+ if (isVendorApprovalPending && !isConnected) {
406
+ return {
407
+ textClass: "text-amber-600 dark:text-amber-400",
408
+ dotClass: "bg-amber-500",
409
+ label: "Pending approval",
410
+ };
411
+ }
412
+ switch (health) {
413
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_HEALTHY:
414
+ return {
415
+ textClass: "text-success",
416
+ dotClass: "bg-success",
417
+ label: "Signed in",
418
+ };
419
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_TOKEN_EXPIRED_REFRESHABLE:
420
+ return {
421
+ textClass: "text-amber-600 dark:text-amber-400",
422
+ dotClass: "bg-amber-500",
423
+ label: "Token expired",
424
+ };
425
+ case OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_TOKEN_EXPIRED:
426
+ return {
427
+ textClass: "text-destructive",
428
+ dotClass: "bg-destructive",
429
+ label: "Re-auth needed",
430
+ };
431
+ default:
432
+ return {
433
+ textClass: "text-muted-foreground",
434
+ dotClass: "bg-muted-foreground",
435
+ label: "Sign-in required",
436
+ };
437
+ }
438
+ }
439
+
440
+ type InlineDisconnectPhase = "idle" | "confirming" | "disconnecting";
441
+ type InlineRemoveOrgAppPhase = "idle" | "confirming" | "removing";
442
+
342
443
  function InlineOAuthSignIn({
343
444
  serverName,
344
445
  isConnected,
446
+ connectionHealth,
345
447
  phase,
346
448
  onSignIn,
449
+ onDisconnect,
450
+ isDisconnecting,
451
+ disconnectError,
452
+ onClearDisconnectError,
347
453
  error,
348
454
  onClearError,
349
455
  isVendorApprovalPending,
456
+ isVendorApprovalBlocked,
350
457
  vendorApprovalDocsUrl,
458
+ canBringOwnApp,
459
+ isOrgOAuthApp,
460
+ onBringOwnApp,
461
+ onRemoveOrgApp,
462
+ isRemovingOrgApp,
351
463
  onSwitchToManual,
352
464
  }: {
353
465
  readonly serverName: string;
354
466
  readonly isConnected: boolean;
467
+ readonly connectionHealth?: OAuthConnectionHealth;
355
468
  readonly phase: OAuthConnectPhase;
356
469
  readonly onSignIn: () => void;
470
+ readonly onDisconnect?: () => Promise<void>;
471
+ readonly isDisconnecting?: boolean;
472
+ readonly disconnectError?: Error | null;
473
+ readonly onClearDisconnectError?: () => void;
357
474
  readonly error: Error | null;
358
475
  readonly onClearError: () => void;
359
476
  readonly isVendorApprovalPending?: boolean;
477
+ readonly isVendorApprovalBlocked?: boolean;
360
478
  readonly vendorApprovalDocsUrl?: string | null;
479
+ readonly canBringOwnApp?: boolean;
480
+ readonly isOrgOAuthApp?: boolean;
481
+ readonly onBringOwnApp?: () => void;
482
+ readonly onRemoveOrgApp?: () => Promise<void>;
483
+ readonly isRemovingOrgApp?: boolean;
361
484
  readonly onSwitchToManual?: () => void;
362
485
  }) {
486
+ const [disconnectPhase, setDisconnectPhase] = useState<InlineDisconnectPhase>("idle");
487
+ const [removeOrgAppPhase, setRemoveOrgAppPhase] = useState<InlineRemoveOrgAppPhase>("idle");
488
+
489
+ const blocked = isVendorApprovalBlocked ?? isVendorApprovalPending;
490
+
363
491
  const isBusy =
364
492
  phase === "initiating" ||
365
493
  phase === "awaiting-callback" ||
366
494
  phase === "completing" ||
367
495
  phase === "connecting";
368
496
 
369
- const signInDisabled = isBusy || !!isVendorApprovalPending;
497
+ const signInDisabled = isBusy || (!!blocked && !isOrgOAuthApp);
498
+ const anyBusy = isBusy || !!isDisconnecting || !!isRemovingOrgApp;
499
+
500
+ const needsReAuth =
501
+ connectionHealth === OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_TOKEN_EXPIRED;
502
+
503
+ const status = inlineHealthProps(
504
+ connectionHealth,
505
+ isConnected,
506
+ !!isVendorApprovalPending && !isOrgOAuthApp,
507
+ );
508
+
509
+ const showDisconnectLink =
510
+ isConnected && onDisconnect && !anyBusy && disconnectPhase === "idle";
511
+
512
+ const showRemoveOrgAppLink =
513
+ isOrgOAuthApp && onRemoveOrgApp && !anyBusy && removeOrgAppPhase === "idle";
514
+
515
+ // Inline disconnect confirmation
516
+ if (disconnectPhase === "confirming" || disconnectPhase === "disconnecting") {
517
+ return (
518
+ <div className="space-y-1.5">
519
+ <p className="text-[0.65rem] text-foreground">
520
+ Remove credentials? You can reconnect at any time.
521
+ </p>
522
+ <div className="flex items-center gap-1.5">
523
+ <button
524
+ type="button"
525
+ disabled={!!isDisconnecting}
526
+ onClick={async () => {
527
+ if (!onDisconnect) return;
528
+ setDisconnectPhase("disconnecting");
529
+ try {
530
+ await onDisconnect();
531
+ setDisconnectPhase("idle");
532
+ } catch {
533
+ setDisconnectPhase("confirming");
534
+ }
535
+ }}
536
+ className={cn(
537
+ "inline-flex items-center gap-1 rounded px-2 py-0.5 text-[0.65rem] font-medium",
538
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
539
+ "disabled:pointer-events-none disabled:opacity-50",
540
+ )}
541
+ >
542
+ {isDisconnecting && <InlineSpinner />}
543
+ Disconnect
544
+ </button>
545
+ <button
546
+ type="button"
547
+ disabled={!!isDisconnecting}
548
+ onClick={() => {
549
+ setDisconnectPhase("idle");
550
+ onClearDisconnectError?.();
551
+ }}
552
+ className={cn(
553
+ "inline-flex items-center rounded px-2 py-0.5 text-[0.65rem] font-medium",
554
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
555
+ "disabled:pointer-events-none disabled:opacity-50",
556
+ )}
557
+ >
558
+ Cancel
559
+ </button>
560
+ </div>
561
+ {disconnectError && (
562
+ <div className="flex items-start gap-1.5 text-[0.65rem] text-destructive" role="alert">
563
+ <span className="flex-1">{getUserMessage(disconnectError)}</span>
564
+ <button
565
+ type="button"
566
+ onClick={() => onClearDisconnectError?.()}
567
+ className="shrink-0 underline underline-offset-2 hover:no-underline"
568
+ >
569
+ Dismiss
570
+ </button>
571
+ </div>
572
+ )}
573
+ </div>
574
+ );
575
+ }
576
+
577
+ // Inline "remove custom app" confirmation
578
+ if (removeOrgAppPhase === "confirming" || removeOrgAppPhase === "removing") {
579
+ return (
580
+ <div className="space-y-1.5">
581
+ <p className="text-[0.65rem] text-foreground">
582
+ Remove your custom OAuth app? The server will revert to the
583
+ platform&apos;s app.
584
+ </p>
585
+ <div className="flex items-center gap-1.5">
586
+ <button
587
+ type="button"
588
+ disabled={!!isRemovingOrgApp}
589
+ onClick={async () => {
590
+ if (!onRemoveOrgApp) return;
591
+ setRemoveOrgAppPhase("removing");
592
+ try {
593
+ await onRemoveOrgApp();
594
+ setRemoveOrgAppPhase("idle");
595
+ } catch {
596
+ setRemoveOrgAppPhase("confirming");
597
+ }
598
+ }}
599
+ className={cn(
600
+ "inline-flex items-center gap-1 rounded px-2 py-0.5 text-[0.65rem] font-medium",
601
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
602
+ "disabled:pointer-events-none disabled:opacity-50",
603
+ )}
604
+ >
605
+ {isRemovingOrgApp && <InlineSpinner />}
606
+ Remove
607
+ </button>
608
+ <button
609
+ type="button"
610
+ disabled={!!isRemovingOrgApp}
611
+ onClick={() => setRemoveOrgAppPhase("idle")}
612
+ className={cn(
613
+ "inline-flex items-center rounded px-2 py-0.5 text-[0.65rem] font-medium",
614
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
615
+ "disabled:pointer-events-none disabled:opacity-50",
616
+ )}
617
+ >
618
+ Cancel
619
+ </button>
620
+ </div>
621
+ </div>
622
+ );
623
+ }
370
624
 
371
625
  return (
372
626
  <div className="space-y-1.5">
373
627
  <div className="flex items-center justify-between">
374
- <span
375
- className={cn(
376
- "inline-flex items-center gap-1 text-[0.65rem] font-medium",
377
- isConnected
378
- ? "text-success"
379
- : isVendorApprovalPending
380
- ? "text-amber-600 dark:text-amber-400"
381
- : "text-muted-foreground",
382
- )}
383
- >
628
+ <div className="flex items-center gap-1.5">
384
629
  <span
385
630
  className={cn(
386
- "size-1.5 rounded-full",
387
- isConnected
388
- ? "bg-success"
389
- : isVendorApprovalPending
390
- ? "bg-amber-500"
391
- : "bg-muted-foreground",
631
+ "inline-flex items-center gap-1 text-[0.65rem] font-medium",
632
+ status.textClass,
392
633
  )}
393
- aria-hidden="true"
394
- />
395
- {isConnected ? "Signed in" : isVendorApprovalPending ? "Pending approval" : "Sign-in required"}
396
- </span>
634
+ >
635
+ <span
636
+ className={cn("size-1.5 rounded-full", status.dotClass)}
637
+ aria-hidden="true"
638
+ />
639
+ {status.label}
640
+ </span>
641
+ {isOrgOAuthApp && isConnected && (
642
+ <span className="text-[0.6rem] text-muted-foreground">
643
+ Your app
644
+ </span>
645
+ )}
646
+ {showDisconnectLink && (
647
+ <button
648
+ type="button"
649
+ onClick={() => setDisconnectPhase("confirming")}
650
+ className="text-[0.65rem] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
651
+ >
652
+ Disconnect
653
+ </button>
654
+ )}
655
+ {showRemoveOrgAppLink && (
656
+ <button
657
+ type="button"
658
+ onClick={() => setRemoveOrgAppPhase("confirming")}
659
+ className="text-[0.65rem] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
660
+ >
661
+ Remove custom app
662
+ </button>
663
+ )}
664
+ </div>
397
665
  <button
398
666
  type="button"
399
667
  onClick={onSignIn}
400
668
  disabled={signInDisabled}
401
669
  className={cn(
402
670
  "inline-flex items-center gap-1 rounded px-2 py-0.5 text-[0.65rem] font-medium",
403
- isConnected
671
+ isConnected && !needsReAuth
404
672
  ? "text-muted-foreground hover:text-foreground hover:bg-accent/50"
405
673
  : "bg-primary text-primary-foreground hover:bg-primary-hover",
406
674
  "disabled:pointer-events-none disabled:opacity-50",
@@ -408,17 +676,37 @@ function InlineOAuthSignIn({
408
676
  >
409
677
  {isBusy ? (
410
678
  <InlineSpinner />
411
- ) : isConnected ? (
679
+ ) : isOrgOAuthApp && !isConnected ? (
680
+ "Sign in with your app"
681
+ ) : isConnected && !needsReAuth ? (
412
682
  "Re-authenticate"
683
+ ) : needsReAuth ? (
684
+ "Sign in to reconnect"
413
685
  ) : (
414
686
  `Sign in with ${serverName}`
415
687
  )}
416
688
  </button>
417
689
  </div>
418
- {isVendorApprovalPending && !isConnected && (
690
+
691
+ {/* Vendor approval blocked message with BYOA CTA */}
692
+ {blocked && !isConnected && !isOrgOAuthApp && (
419
693
  <div className="text-[0.65rem] text-amber-700 dark:text-amber-300">
420
- <p>OAuth sign-in is pending vendor approval.</p>
421
- {vendorApprovalDocsUrl && (
694
+ <p>
695
+ OAuth sign-in is pending vendor approval.
696
+ {canBringOwnApp && onBringOwnApp
697
+ ? " You can use your own OAuth app or enter a token manually."
698
+ : ""}
699
+ </p>
700
+ {canBringOwnApp && onBringOwnApp && (
701
+ <button
702
+ type="button"
703
+ onClick={onBringOwnApp}
704
+ className="mt-1 inline-flex items-center gap-1 rounded bg-amber-600 px-2 py-0.5 text-[0.6rem] font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
705
+ >
706
+ Use your own OAuth app
707
+ </button>
708
+ )}
709
+ {vendorApprovalDocsUrl && !canBringOwnApp && (
422
710
  <a
423
711
  href={vendorApprovalDocsUrl}
424
712
  target="_blank"
@@ -430,26 +718,63 @@ function InlineOAuthSignIn({
430
718
  )}
431
719
  </div>
432
720
  )}
721
+
722
+ {/* Org override indicator when not connected */}
723
+ {isOrgOAuthApp && !isConnected && (
724
+ <span className="text-[0.6rem] text-muted-foreground">
725
+ Using your OAuth app
726
+ </span>
727
+ )}
728
+
433
729
  {error && (
434
- <div className="flex items-start gap-1.5 text-[0.65rem] text-destructive">
435
- <span className="flex-1">{error.message}</span>
436
- <button
437
- type="button"
438
- onClick={onClearError}
439
- className="shrink-0 underline underline-offset-2 hover:no-underline"
440
- >
441
- Dismiss
442
- </button>
730
+ <div className="flex items-start gap-1.5 text-[0.65rem] text-destructive" role="alert">
731
+ <span className="flex-1">{getUserMessage(error)}</span>
732
+ <div className="flex shrink-0 items-center gap-1.5">
733
+ {isRetryableError(error) && (
734
+ <button
735
+ type="button"
736
+ onClick={() => {
737
+ onClearError();
738
+ onSignIn();
739
+ }}
740
+ className="font-medium underline underline-offset-2 hover:no-underline"
741
+ >
742
+ Try again
743
+ </button>
744
+ )}
745
+ <button
746
+ type="button"
747
+ onClick={onClearError}
748
+ className="underline underline-offset-2 hover:no-underline"
749
+ >
750
+ Dismiss
751
+ </button>
752
+ </div>
443
753
  </div>
444
754
  )}
445
- {onSwitchToManual && !isConnected && !isBusy && (
446
- <button
447
- type="button"
448
- onClick={onSwitchToManual}
449
- className="text-[0.65rem] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
450
- >
451
- Enter token manually
452
- </button>
755
+
756
+ {/* Secondary actions: manual entry, BYOA */}
757
+ {!isConnected && !isBusy && (
758
+ <div className="flex items-center gap-2">
759
+ {onSwitchToManual && (
760
+ <button
761
+ type="button"
762
+ onClick={onSwitchToManual}
763
+ className="text-[0.65rem] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
764
+ >
765
+ Enter token manually
766
+ </button>
767
+ )}
768
+ {canBringOwnApp && onBringOwnApp && !blocked && (
769
+ <button
770
+ type="button"
771
+ onClick={onBringOwnApp}
772
+ className="text-[0.65rem] text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
773
+ >
774
+ Use your own OAuth app
775
+ </button>
776
+ )}
777
+ </div>
453
778
  )}
454
779
  </div>
455
780
  );