@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
@@ -0,0 +1,527 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import type { EnvVarInput } from "@stigmer/sdk";
6
+ import { useMcpServer } from "./useMcpServer";
7
+ import { useMcpServerCredentials } from "./useMcpServerCredentials";
8
+ import { useMcpServerOAuthConnect } from "./useMcpServerOAuthConnect";
9
+ import type { OAuthConnectPhase } from "./useMcpServerOAuthConnect";
10
+ import { useMcpServerConnect } from "./useMcpServerConnect";
11
+ import { useDisconnectOAuth } from "./useDisconnectOAuth";
12
+ import { EnvVarForm } from "../environment/EnvVarForm";
13
+ import { ErrorMessage } from "../error/ErrorMessage";
14
+
15
+ /** Props for {@link McpServerConnectDialog}. */
16
+ export interface McpServerConnectDialogProps {
17
+ /** Organization slug that owns the MCP server. */
18
+ readonly org: string;
19
+ /** MCP server slug. */
20
+ readonly slug: string;
21
+ /**
22
+ * The authenticated user's active organization slug.
23
+ * Used for credential storage — tokens are stored in the user's
24
+ * personal environment within this org.
25
+ * Falls back to `org` when omitted.
26
+ */
27
+ readonly activeOrg?: string;
28
+ /** Whether the dialog is open. */
29
+ readonly open: boolean;
30
+ /** Called when the dialog should close (backdrop click, cancel, or success). */
31
+ readonly onClose: () => void;
32
+ /** Called after a successful connect with the server name. */
33
+ readonly onConnected?: (serverName: string) => void;
34
+ /** Additional CSS classes for the dialog element. */
35
+ readonly className?: string;
36
+ }
37
+
38
+ type DialogPhase = "credentials" | "connecting" | "success" | "error";
39
+
40
+ /**
41
+ * Modal dialog for connecting to an MCP server.
42
+ *
43
+ * Fetches the server, determines whether credentials or OAuth are
44
+ * needed, and walks the user through the connect flow — all without
45
+ * navigating away from the current page.
46
+ *
47
+ * Uses the native `<dialog>` element for accessibility (focus trap,
48
+ * Escape to close, backdrop click).
49
+ *
50
+ * Built on existing hooks: {@link useMcpServer},
51
+ * {@link useMcpServerCredentials}, {@link useMcpServerOAuthConnect},
52
+ * and {@link useMcpServerConnect}.
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * const [connectTarget, setConnectTarget] = useState<{ org: string; slug: string } | null>(null);
57
+ *
58
+ * <McpServerConnectDialog
59
+ * org={connectTarget?.org ?? ""}
60
+ * slug={connectTarget?.slug ?? ""}
61
+ * open={connectTarget !== null}
62
+ * onClose={() => setConnectTarget(null)}
63
+ * onConnected={(name) => toast(`Connected to ${name}`)}
64
+ * />
65
+ * ```
66
+ */
67
+ export function McpServerConnectDialog({
68
+ org,
69
+ slug,
70
+ activeOrg,
71
+ open,
72
+ onClose,
73
+ onConnected,
74
+ className,
75
+ }: McpServerConnectDialogProps) {
76
+ const dialogRef = useRef<HTMLDialogElement>(null);
77
+ const resolvedOrg = activeOrg || org;
78
+
79
+ useEffect(() => {
80
+ const dialog = dialogRef.current;
81
+ if (!dialog) return;
82
+
83
+ if (open && !dialog.open) {
84
+ dialog.showModal();
85
+ } else if (!open && dialog.open) {
86
+ dialog.close();
87
+ }
88
+ }, [open]);
89
+
90
+ const handleDialogClose = useCallback(() => {
91
+ onClose();
92
+ }, [onClose]);
93
+
94
+ const handleBackdropClick = useCallback(
95
+ (e: React.MouseEvent<HTMLDialogElement>) => {
96
+ if (e.target === dialogRef.current) {
97
+ onClose();
98
+ }
99
+ },
100
+ [onClose],
101
+ );
102
+
103
+ if (!open) return null;
104
+
105
+ return (
106
+ <dialog
107
+ ref={dialogRef}
108
+ onClose={handleDialogClose}
109
+ onClick={handleBackdropClick}
110
+ className={cn(
111
+ "m-auto max-h-[85vh] w-full max-w-md overflow-visible rounded-lg border border-border bg-card p-0 text-foreground shadow-lg",
112
+ "backdrop:bg-black/50",
113
+ className,
114
+ )}
115
+ >
116
+ <div
117
+ className="flex max-h-[85vh] flex-col overflow-y-auto p-6"
118
+ onClick={(e) => e.stopPropagation()}
119
+ >
120
+ <ConnectDialogContent
121
+ org={org}
122
+ slug={slug}
123
+ activeOrg={resolvedOrg}
124
+ onClose={onClose}
125
+ onConnected={onConnected}
126
+ />
127
+ </div>
128
+ </dialog>
129
+ );
130
+ }
131
+
132
+ function ConnectDialogContent({
133
+ org,
134
+ slug,
135
+ activeOrg,
136
+ onClose,
137
+ onConnected,
138
+ }: {
139
+ readonly org: string;
140
+ readonly slug: string;
141
+ readonly activeOrg: string;
142
+ readonly onClose: () => void;
143
+ readonly onConnected?: (serverName: string) => void;
144
+ }) {
145
+ const { mcpServer, isLoading: isServerLoading, error: serverError } = useMcpServer(org, slug);
146
+ const creds = useMcpServerCredentials(activeOrg, mcpServer);
147
+ const { connect, isConnecting, error: connectError, clearError: clearConnectError } = useMcpServerConnect();
148
+ const oauth = useMcpServerOAuthConnect();
149
+ const disconnect = useDisconnectOAuth();
150
+
151
+ const [phase, setPhase] = useState<DialogPhase>("credentials");
152
+
153
+ const serverName = mcpServer?.metadata?.name ?? slug;
154
+ const serverId = mcpServer?.metadata?.id ?? "";
155
+ const declaredEnvKeys = Object.keys(mcpServer?.spec?.env ?? {});
156
+
157
+ const handleConnect = useCallback(async () => {
158
+ if (!serverId) return;
159
+
160
+ setPhase("connecting");
161
+ try {
162
+ await connect(serverId, activeOrg, undefined, declaredEnvKeys);
163
+ setPhase("success");
164
+ onConnected?.(serverName);
165
+ } catch {
166
+ setPhase("error");
167
+ }
168
+ }, [serverId, activeOrg, declaredEnvKeys, connect, onConnected, serverName]);
169
+
170
+ const handleCredentialSubmit = useCallback(
171
+ async (values: Record<string, EnvVarInput>) => {
172
+ await creds.saveCredentials(values);
173
+ creds.refetch();
174
+ },
175
+ [creds],
176
+ );
177
+
178
+ const handleOAuthSignIn = useCallback(() => {
179
+ if (!serverId) return;
180
+ oauth.startOAuth(serverId, activeOrg, declaredEnvKeys).then(
181
+ () => {
182
+ creds.refetch();
183
+ setPhase("success");
184
+ onConnected?.(serverName);
185
+ },
186
+ () => {
187
+ setPhase("error");
188
+ },
189
+ );
190
+ }, [serverId, activeOrg, declaredEnvKeys, oauth, creds, onConnected, serverName]);
191
+
192
+ // Auto-trigger connect when credentials become ready (manual-only servers)
193
+ useEffect(() => {
194
+ if (
195
+ phase === "credentials" &&
196
+ creds.isReady &&
197
+ !creds.isLoading &&
198
+ mcpServer &&
199
+ creds.authMode === "manual"
200
+ ) {
201
+ // Don't auto-connect; let user click the button
202
+ }
203
+ }, [phase, creds.isReady, creds.isLoading, mcpServer, creds.authMode]);
204
+
205
+ if (isServerLoading || creds.isLoading) {
206
+ return (
207
+ <>
208
+ <DialogHeader title="Connect MCP Server" onClose={onClose} />
209
+ <div className="flex flex-col items-center gap-3 py-8">
210
+ <LoadingSpinner />
211
+ <p className="text-sm text-muted-foreground">Loading server details...</p>
212
+ </div>
213
+ </>
214
+ );
215
+ }
216
+
217
+ if (serverError) {
218
+ return (
219
+ <>
220
+ <DialogHeader title="Connect MCP Server" onClose={onClose} />
221
+ <div className="py-4">
222
+ <ErrorMessage error={serverError} />
223
+ </div>
224
+ </>
225
+ );
226
+ }
227
+
228
+ if (!mcpServer) {
229
+ return (
230
+ <>
231
+ <DialogHeader title="Connect MCP Server" onClose={onClose} />
232
+ <p className="py-4 text-sm text-muted-foreground">MCP server not found.</p>
233
+ </>
234
+ );
235
+ }
236
+
237
+ if (phase === "success") {
238
+ return (
239
+ <>
240
+ <DialogHeader title="Connected" onClose={onClose} />
241
+ <div className="flex flex-col items-center gap-3 py-6">
242
+ <SuccessIcon />
243
+ <p className="text-sm font-medium text-foreground">
244
+ Successfully connected to {serverName}
245
+ </p>
246
+ <p className="text-xs text-muted-foreground">
247
+ Tools and capabilities have been discovered.
248
+ </p>
249
+ <button
250
+ type="button"
251
+ onClick={onClose}
252
+ className={cn(
253
+ "mt-2 inline-flex items-center rounded-md px-4 py-2 text-sm font-medium",
254
+ "bg-primary text-primary-foreground",
255
+ "hover:bg-primary/90",
256
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
257
+ )}
258
+ >
259
+ Done
260
+ </button>
261
+ </div>
262
+ </>
263
+ );
264
+ }
265
+
266
+ const isConnectingPhase = phase === "connecting" || isConnecting || oauth.isInProgress;
267
+ const activeError = connectError ?? oauth.error;
268
+
269
+ return (
270
+ <>
271
+ <DialogHeader title={serverName} onClose={onClose} />
272
+
273
+ {mcpServer.spec?.description && (
274
+ <p className="mb-4 text-xs leading-relaxed text-muted-foreground">
275
+ {mcpServer.spec.description}
276
+ </p>
277
+ )}
278
+
279
+ {activeError && (
280
+ <div className="mb-4">
281
+ <ErrorMessage
282
+ error={activeError}
283
+ retry={() => {
284
+ clearConnectError();
285
+ oauth.clearError();
286
+ setPhase("credentials");
287
+ }}
288
+ />
289
+ </div>
290
+ )}
291
+
292
+ {creds.authMode === "oauth" && !creds.manualOverride && (
293
+ <OAuthSection
294
+ isConnected={creds.isOAuthConnected}
295
+ phase={oauth.phase}
296
+ onSignIn={handleOAuthSignIn}
297
+ isVendorApprovalBlocked={creds.isVendorApprovalBlocked}
298
+ onSwitchToManual={() => creds.setManualOverride(true)}
299
+ disabled={isConnectingPhase}
300
+ />
301
+ )}
302
+
303
+ {creds.missingVariables.length > 0 && (
304
+ <div className="mt-2">
305
+ <EnvVarForm
306
+ variables={creds.missingVariables}
307
+ onSubmit={(values) => handleCredentialSubmit(values)}
308
+ isSubmitting={creds.isSaving}
309
+ disabled={isConnectingPhase}
310
+ hideSaveToggle
311
+ />
312
+ </div>
313
+ )}
314
+
315
+ {creds.authMode === "oauth" && creds.manualOverride && (
316
+ <button
317
+ type="button"
318
+ onClick={() => creds.setManualOverride(false)}
319
+ disabled={isConnectingPhase}
320
+ className="mt-2 text-xs text-muted-foreground underline hover:text-foreground"
321
+ >
322
+ Sign in with OAuth instead
323
+ </button>
324
+ )}
325
+
326
+ {creds.isReady && creds.authMode === "manual" && (
327
+ <button
328
+ type="button"
329
+ onClick={handleConnect}
330
+ disabled={isConnectingPhase}
331
+ className={cn(
332
+ "mt-4 inline-flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium",
333
+ "bg-primary text-primary-foreground",
334
+ "hover:bg-primary/90",
335
+ "disabled:pointer-events-none disabled:opacity-50",
336
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
337
+ )}
338
+ >
339
+ {isConnectingPhase && <LoadingSpinner size="sm" />}
340
+ {isConnectingPhase ? "Connecting..." : "Connect"}
341
+ </button>
342
+ )}
343
+
344
+ {creds.isReady && creds.authMode === "oauth" && creds.isOAuthConnected && (
345
+ <button
346
+ type="button"
347
+ onClick={handleConnect}
348
+ disabled={isConnectingPhase}
349
+ className={cn(
350
+ "mt-4 inline-flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium",
351
+ "bg-primary text-primary-foreground",
352
+ "hover:bg-primary/90",
353
+ "disabled:pointer-events-none disabled:opacity-50",
354
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
355
+ )}
356
+ >
357
+ {isConnectingPhase && <LoadingSpinner size="sm" />}
358
+ {isConnectingPhase ? "Discovering tools..." : "Discover Tools"}
359
+ </button>
360
+ )}
361
+
362
+ {!creds.isReady &&
363
+ creds.missingVariables.length === 0 &&
364
+ creds.authMode === "manual" && (
365
+ <p className="mt-4 text-center text-xs text-muted-foreground">
366
+ No credentials required — this server is ready to connect.
367
+ </p>
368
+ )}
369
+ </>
370
+ );
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Sub-components
375
+ // ---------------------------------------------------------------------------
376
+
377
+ function DialogHeader({
378
+ title,
379
+ onClose,
380
+ }: {
381
+ readonly title: string;
382
+ readonly onClose: () => void;
383
+ }) {
384
+ return (
385
+ <div className="mb-4 flex items-start justify-between gap-3">
386
+ <h2 className="text-base font-semibold text-foreground">{title}</h2>
387
+ <button
388
+ type="button"
389
+ onClick={onClose}
390
+ aria-label="Close"
391
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
392
+ >
393
+ <CloseIcon />
394
+ </button>
395
+ </div>
396
+ );
397
+ }
398
+
399
+ function OAuthSection({
400
+ isConnected,
401
+ phase,
402
+ onSignIn,
403
+ isVendorApprovalBlocked,
404
+ onSwitchToManual,
405
+ disabled,
406
+ }: {
407
+ readonly isConnected: boolean;
408
+ readonly phase: OAuthConnectPhase;
409
+ readonly onSignIn: () => void;
410
+ readonly isVendorApprovalBlocked: boolean;
411
+ readonly onSwitchToManual: () => void;
412
+ readonly disabled?: boolean;
413
+ }) {
414
+ if (isConnected) {
415
+ return (
416
+ <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
417
+ <span className="size-2 shrink-0 rounded-full bg-green-500" />
418
+ <span className="text-sm text-foreground">OAuth connected</span>
419
+ </div>
420
+ );
421
+ }
422
+
423
+ const isInProgress = phase !== "idle" && phase !== "done";
424
+ const phaseLabel: Record<string, string> = {
425
+ initiating: "Starting OAuth...",
426
+ "awaiting-callback": "Waiting for authorization...",
427
+ completing: "Completing OAuth...",
428
+ connecting: "Discovering tools...",
429
+ };
430
+
431
+ return (
432
+ <div className="flex flex-col gap-2">
433
+ <button
434
+ type="button"
435
+ onClick={onSignIn}
436
+ disabled={disabled || isInProgress || isVendorApprovalBlocked}
437
+ className={cn(
438
+ "inline-flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium",
439
+ "bg-primary text-primary-foreground",
440
+ "hover:bg-primary/90",
441
+ "disabled:pointer-events-none disabled:opacity-50",
442
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
443
+ )}
444
+ >
445
+ {isInProgress && <LoadingSpinner size="sm" />}
446
+ {isInProgress ? (phaseLabel[phase] ?? "Connecting...") : "Sign in with OAuth"}
447
+ </button>
448
+ {isVendorApprovalBlocked && (
449
+ <p className="text-xs text-muted-foreground">
450
+ OAuth sign-in is pending vendor approval. You can enter your token manually instead.
451
+ </p>
452
+ )}
453
+ <button
454
+ type="button"
455
+ onClick={onSwitchToManual}
456
+ disabled={disabled || isInProgress}
457
+ className="text-xs text-muted-foreground underline hover:text-foreground"
458
+ >
459
+ Enter token manually
460
+ </button>
461
+ </div>
462
+ );
463
+ }
464
+
465
+ function LoadingSpinner({ size = "md" }: { readonly size?: "sm" | "md" }) {
466
+ const cls = size === "sm" ? "size-3.5" : "size-5";
467
+ return (
468
+ <svg
469
+ className={cn(cls, "animate-spin text-current")}
470
+ viewBox="0 0 24 24"
471
+ fill="none"
472
+ aria-hidden="true"
473
+ >
474
+ <circle
475
+ cx="12"
476
+ cy="12"
477
+ r="10"
478
+ stroke="currentColor"
479
+ strokeWidth="3"
480
+ strokeLinecap="round"
481
+ className="opacity-25"
482
+ />
483
+ <path
484
+ d="M12 2a10 10 0 0 1 10 10"
485
+ stroke="currentColor"
486
+ strokeWidth="3"
487
+ strokeLinecap="round"
488
+ className="opacity-75"
489
+ />
490
+ </svg>
491
+ );
492
+ }
493
+
494
+ function SuccessIcon() {
495
+ return (
496
+ <svg
497
+ className="size-10 text-green-500"
498
+ viewBox="0 0 24 24"
499
+ fill="none"
500
+ stroke="currentColor"
501
+ strokeWidth="2"
502
+ strokeLinecap="round"
503
+ strokeLinejoin="round"
504
+ aria-hidden="true"
505
+ >
506
+ <circle cx="12" cy="12" r="10" />
507
+ <path d="m9 12 2 2 4-4" />
508
+ </svg>
509
+ );
510
+ }
511
+
512
+ function CloseIcon() {
513
+ return (
514
+ <svg
515
+ className="size-4"
516
+ viewBox="0 0 16 16"
517
+ fill="none"
518
+ stroke="currentColor"
519
+ strokeWidth="1.5"
520
+ strokeLinecap="round"
521
+ strokeLinejoin="round"
522
+ aria-hidden="true"
523
+ >
524
+ <path d="m4 4 8 8M12 4l-8 8" />
525
+ </svg>
526
+ );
527
+ }