@stigmer/react 0.0.84 → 0.0.86

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 (87) 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/demo/samples.d.ts +1 -0
  6. package/demo/samples.d.ts.map +1 -1
  7. package/demo/samples.js +1 -0
  8. package/demo/samples.js.map +1 -1
  9. package/execution/ArtifactPreviewModal.d.ts +78 -18
  10. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  11. package/execution/ArtifactPreviewModal.js +82 -60
  12. package/execution/ArtifactPreviewModal.js.map +1 -1
  13. package/execution/index.d.ts +2 -2
  14. package/execution/index.d.ts.map +1 -1
  15. package/execution/index.js +1 -1
  16. package/execution/index.js.map +1 -1
  17. package/index.d.ts +6 -4
  18. package/index.d.ts.map +1 -1
  19. package/index.js +4 -2
  20. package/index.js.map +1 -1
  21. package/library/ResourceListView.js +1 -1
  22. package/library/ResourceListView.js.map +1 -1
  23. package/mcp-server/McpServerConnectDialog.d.ts +51 -0
  24. package/mcp-server/McpServerConnectDialog.d.ts.map +1 -0
  25. package/mcp-server/McpServerConnectDialog.js +164 -0
  26. package/mcp-server/McpServerConnectDialog.js.map +1 -0
  27. package/mcp-server/McpServerDetailView.js +2 -2
  28. package/mcp-server/McpServerDetailView.js.map +1 -1
  29. package/mcp-server/McpServerPicker.d.ts.map +1 -1
  30. package/mcp-server/McpServerPicker.js +7 -1
  31. package/mcp-server/McpServerPicker.js.map +1 -1
  32. package/mcp-server/index.d.ts +2 -0
  33. package/mcp-server/index.d.ts.map +1 -1
  34. package/mcp-server/index.js +1 -0
  35. package/mcp-server/index.js.map +1 -1
  36. package/oauth-app/CreateOAuthAppForm.d.ts +41 -0
  37. package/oauth-app/CreateOAuthAppForm.d.ts.map +1 -0
  38. package/oauth-app/CreateOAuthAppForm.js +140 -0
  39. package/oauth-app/CreateOAuthAppForm.js.map +1 -0
  40. package/oauth-app/OAuthAppDetailPanel.d.ts +43 -0
  41. package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -0
  42. package/oauth-app/OAuthAppDetailPanel.js +202 -0
  43. package/oauth-app/OAuthAppDetailPanel.js.map +1 -0
  44. package/oauth-app/OAuthAppListPanel.d.ts +43 -0
  45. package/oauth-app/OAuthAppListPanel.d.ts.map +1 -0
  46. package/oauth-app/OAuthAppListPanel.js +79 -0
  47. package/oauth-app/OAuthAppListPanel.js.map +1 -0
  48. package/oauth-app/index.d.ts +15 -0
  49. package/oauth-app/index.d.ts.map +1 -0
  50. package/oauth-app/index.js +8 -0
  51. package/oauth-app/index.js.map +1 -0
  52. package/oauth-app/useCreateOAuthApp.d.ts +39 -0
  53. package/oauth-app/useCreateOAuthApp.d.ts.map +1 -0
  54. package/oauth-app/useCreateOAuthApp.js +50 -0
  55. package/oauth-app/useCreateOAuthApp.js.map +1 -0
  56. package/oauth-app/useDeleteOAuthApp.d.ts +31 -0
  57. package/oauth-app/useDeleteOAuthApp.d.ts.map +1 -0
  58. package/oauth-app/useDeleteOAuthApp.js +43 -0
  59. package/oauth-app/useDeleteOAuthApp.js.map +1 -0
  60. package/oauth-app/useOAuthAppList.d.ts +32 -0
  61. package/oauth-app/useOAuthAppList.d.ts.map +1 -0
  62. package/oauth-app/useOAuthAppList.js +61 -0
  63. package/oauth-app/useOAuthAppList.js.map +1 -0
  64. package/oauth-app/useUpdateOAuthApp.d.ts +38 -0
  65. package/oauth-app/useUpdateOAuthApp.d.ts.map +1 -0
  66. package/oauth-app/useUpdateOAuthApp.js +49 -0
  67. package/oauth-app/useUpdateOAuthApp.js.map +1 -0
  68. package/package.json +4 -4
  69. package/src/demo/fixtures.ts +8 -0
  70. package/src/demo/samples.ts +2 -0
  71. package/src/execution/ArtifactPreviewModal.tsx +206 -128
  72. package/src/execution/index.ts +2 -2
  73. package/src/index.ts +24 -0
  74. package/src/library/ResourceListView.tsx +8 -8
  75. package/src/mcp-server/McpServerConnectDialog.tsx +527 -0
  76. package/src/mcp-server/McpServerDetailView.tsx +2 -1
  77. package/src/mcp-server/McpServerPicker.tsx +8 -1
  78. package/src/mcp-server/index.ts +3 -0
  79. package/src/oauth-app/CreateOAuthAppForm.tsx +449 -0
  80. package/src/oauth-app/OAuthAppDetailPanel.tsx +671 -0
  81. package/src/oauth-app/OAuthAppListPanel.tsx +237 -0
  82. package/src/oauth-app/index.ts +14 -0
  83. package/src/oauth-app/useCreateOAuthApp.ts +70 -0
  84. package/src/oauth-app/useDeleteOAuthApp.ts +62 -0
  85. package/src/oauth-app/useOAuthAppList.ts +84 -0
  86. package/src/oauth-app/useUpdateOAuthApp.ts +69 -0
  87. package/styles.css +1 -1
@@ -0,0 +1,671 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState, type FormEvent } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { getUserMessage } from "@stigmer/sdk";
6
+ import type { OAuthApp } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/api_pb";
7
+ import { VendorApprovalStatus } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/spec_pb";
8
+ import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt";
9
+ import { useUpdateOAuthApp } from "./useUpdateOAuthApp";
10
+ import { useDeleteOAuthApp } from "./useDeleteOAuthApp";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Public API
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Props for {@link OAuthAppDetailPanel}. */
17
+ export interface OAuthAppDetailPanelProps {
18
+ /** The OAuth app resource to display and edit. */
19
+ readonly oauthApp: OAuthApp;
20
+ /** Fired with the updated resource after a successful save. */
21
+ readonly onUpdated?: (app: OAuthApp) => void;
22
+ /** Fired after the resource is successfully deleted. */
23
+ readonly onDeleted?: () => void;
24
+ /** Fired when the user clicks the back button. */
25
+ readonly onBack?: () => void;
26
+ /** Additional CSS class names for the root container. */
27
+ readonly className?: string;
28
+ }
29
+
30
+ /**
31
+ * View and edit panel for an existing OAuth app.
32
+ *
33
+ * In **view mode**, displays all OAuth configuration fields in a
34
+ * structured label/value layout with "Edit" and "Delete" buttons.
35
+ *
36
+ * In **edit mode**, fields become editable inputs. The client secret
37
+ * field shows a placeholder — leave it empty to keep the existing
38
+ * secret, or enter a new value to replace it. "Save" submits the
39
+ * update via {@link useUpdateOAuthApp}; "Cancel" discards changes
40
+ * and returns to view mode.
41
+ *
42
+ * Delete uses an inline confirmation pattern (no modal) to avoid
43
+ * portal/z-index issues for SDK embedders.
44
+ *
45
+ * All visual properties flow through `--stgm-*` design tokens.
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * <OAuthAppDetailPanel
50
+ * oauthApp={app}
51
+ * onUpdated={(updated) => refetch()}
52
+ * onDeleted={() => { refetch(); setFlow({ phase: "idle" }); }}
53
+ * onBack={() => setFlow({ phase: "idle" })}
54
+ * />
55
+ * ```
56
+ */
57
+ export function OAuthAppDetailPanel({
58
+ oauthApp,
59
+ onUpdated,
60
+ onDeleted,
61
+ onBack,
62
+ className,
63
+ }: OAuthAppDetailPanelProps) {
64
+ const spec = oauthApp.spec;
65
+ const meta = oauthApp.metadata;
66
+
67
+ const { update, isUpdating, error: updateError, clearError: clearUpdateError } =
68
+ useUpdateOAuthApp();
69
+ const { deleteApp, isDeleting, error: deleteError, clearError: clearDeleteError } =
70
+ useDeleteOAuthApp();
71
+
72
+ const [mode, setMode] = useState<"view" | "edit">("view");
73
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
74
+
75
+ // Edit form state — initialized from current resource
76
+ const [provider, setProvider] = useState(spec?.provider ?? "");
77
+ const [clientId, setClientId] = useState(spec?.clientId ?? "");
78
+ const [clientSecret, setClientSecret] = useState("");
79
+ const [authorizationUrl, setAuthorizationUrl] = useState(
80
+ spec?.authorizationUrl ?? "",
81
+ );
82
+ const [tokenUrl, setTokenUrl] = useState(spec?.tokenUrl ?? "");
83
+ const [scopes, setScopes] = useState(spec?.scopes.join(", ") ?? "");
84
+ const [userinfoUrl, setUserinfoUrl] = useState(spec?.userinfoUrl ?? "");
85
+ const [scopeParameterName, setScopeParameterName] = useState(
86
+ spec?.scopeParameterName ?? "",
87
+ );
88
+ const [vendorApprovalStatus, setVendorApprovalStatus] = useState(
89
+ approvalStatusToKey(spec?.vendorApprovalStatus),
90
+ );
91
+ const [vendorApprovalDocsUrl, setVendorApprovalDocsUrl] = useState(
92
+ spec?.vendorApprovalDocsUrl ?? "",
93
+ );
94
+
95
+ const enterEdit = useCallback(() => {
96
+ setProvider(spec?.provider ?? "");
97
+ setClientId(spec?.clientId ?? "");
98
+ setClientSecret("");
99
+ setAuthorizationUrl(spec?.authorizationUrl ?? "");
100
+ setTokenUrl(spec?.tokenUrl ?? "");
101
+ setScopes(spec?.scopes.join(", ") ?? "");
102
+ setUserinfoUrl(spec?.userinfoUrl ?? "");
103
+ setScopeParameterName(spec?.scopeParameterName ?? "");
104
+ setVendorApprovalStatus(approvalStatusToKey(spec?.vendorApprovalStatus));
105
+ setVendorApprovalDocsUrl(spec?.vendorApprovalDocsUrl ?? "");
106
+ clearUpdateError();
107
+ setMode("edit");
108
+ }, [spec, clearUpdateError]);
109
+
110
+ const cancelEdit = useCallback(() => {
111
+ clearUpdateError();
112
+ setMode("view");
113
+ }, [clearUpdateError]);
114
+
115
+ const handleSave = useCallback(
116
+ async (e: FormEvent) => {
117
+ e.preventDefault();
118
+ clearUpdateError();
119
+
120
+ const parsedScopes = scopes
121
+ .split(",")
122
+ .map((s) => s.trim())
123
+ .filter(Boolean);
124
+
125
+ try {
126
+ const updated = await update({
127
+ name: meta?.name ?? "",
128
+ slug: meta?.slug,
129
+ org: meta?.org ?? "",
130
+ provider: provider.trim(),
131
+ clientId: clientId.trim(),
132
+ ...(clientSecret.trim() && { clientSecret: clientSecret.trim() }),
133
+ authorizationUrl: authorizationUrl.trim(),
134
+ tokenUrl: tokenUrl.trim(),
135
+ ...(parsedScopes.length > 0 && { scopes: parsedScopes }),
136
+ ...(userinfoUrl.trim() && { userinfoUrl: userinfoUrl.trim() }),
137
+ ...(scopeParameterName.trim() && {
138
+ scopeParameterName: scopeParameterName.trim(),
139
+ }),
140
+ ...(vendorApprovalStatus !== "unspecified" && {
141
+ vendorApprovalStatus: APPROVAL_STATUS_MAP[vendorApprovalStatus],
142
+ }),
143
+ ...(vendorApprovalDocsUrl.trim() && {
144
+ vendorApprovalDocsUrl: vendorApprovalDocsUrl.trim(),
145
+ }),
146
+ });
147
+ setMode("view");
148
+ onUpdated?.(updated);
149
+ } catch {
150
+ // error state is managed by useUpdateOAuthApp
151
+ }
152
+ },
153
+ [
154
+ meta, provider, clientId, clientSecret, authorizationUrl, tokenUrl,
155
+ scopes, userinfoUrl, scopeParameterName, vendorApprovalStatus,
156
+ vendorApprovalDocsUrl, update, clearUpdateError, onUpdated,
157
+ ],
158
+ );
159
+
160
+ const handleDelete = useCallback(async () => {
161
+ const id = meta?.id;
162
+ if (!id) return;
163
+
164
+ clearDeleteError();
165
+ try {
166
+ await deleteApp(id);
167
+ onDeleted?.();
168
+ } catch {
169
+ // error state is managed by useDeleteOAuthApp
170
+ }
171
+ }, [meta, deleteApp, clearDeleteError, onDeleted]);
172
+
173
+ const canSave =
174
+ provider.trim() !== "" &&
175
+ clientId.trim() !== "" &&
176
+ authorizationUrl.trim() !== "" &&
177
+ tokenUrl.trim() !== "" &&
178
+ !isUpdating;
179
+
180
+ const createdAt = oauthApp.status?.audit?.specAudit?.createdAt;
181
+ const updatedAt = oauthApp.status?.audit?.specAudit?.updatedAt;
182
+
183
+ return (
184
+ <div className={cn("space-y-4", className)}>
185
+ {/* Header */}
186
+ <div className="flex items-start justify-between gap-3">
187
+ <div className="min-w-0">
188
+ {onBack && (
189
+ <button
190
+ type="button"
191
+ onClick={onBack}
192
+ className="text-muted-foreground hover:text-foreground mb-1 flex items-center gap-1 text-xs transition-colors"
193
+ >
194
+ <ArrowLeftIcon />
195
+ Back to list
196
+ </button>
197
+ )}
198
+ <h3 className="text-foreground truncate text-sm font-semibold">
199
+ {spec?.provider || meta?.name || "OAuth App"}
200
+ </h3>
201
+ {meta?.slug && (
202
+ <span className="text-muted-foreground font-mono text-xs">
203
+ {meta.slug}
204
+ </span>
205
+ )}
206
+ </div>
207
+
208
+ {mode === "view" && (
209
+ <div className="flex items-center gap-1">
210
+ <button
211
+ type="button"
212
+ onClick={enterEdit}
213
+ className={cn(
214
+ "shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium",
215
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
216
+ "transition-colors",
217
+ )}
218
+ >
219
+ Edit
220
+ </button>
221
+ <button
222
+ type="button"
223
+ onClick={() => {
224
+ clearDeleteError();
225
+ setConfirmingDelete(true);
226
+ }}
227
+ className={cn(
228
+ "shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium",
229
+ "text-destructive/70 hover:text-destructive hover:bg-destructive/10",
230
+ "transition-colors",
231
+ )}
232
+ >
233
+ Delete
234
+ </button>
235
+ </div>
236
+ )}
237
+ </div>
238
+
239
+ {/* Inline delete confirmation */}
240
+ {confirmingDelete && (
241
+ <div
242
+ className="rounded-md border border-destructive/30 bg-destructive/5 p-3"
243
+ role="alert"
244
+ >
245
+ <p className="text-foreground mb-2 text-xs font-medium">
246
+ Delete this OAuth app?
247
+ </p>
248
+ <p className="text-muted-foreground mb-3 text-[0.65rem]">
249
+ This action is permanent. Any MCP server overrides referencing
250
+ this app will lose their binding.
251
+ </p>
252
+ {deleteError && (
253
+ <p className="text-destructive mb-2 text-[0.65rem]" role="alert">
254
+ {getUserMessage(deleteError)}
255
+ </p>
256
+ )}
257
+ <div className="flex items-center gap-2">
258
+ <button
259
+ type="button"
260
+ onClick={handleDelete}
261
+ disabled={isDeleting}
262
+ className={cn(
263
+ "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium",
264
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
265
+ "disabled:pointer-events-none disabled:opacity-40",
266
+ )}
267
+ >
268
+ {isDeleting && <SpinnerIcon />}
269
+ Delete permanently
270
+ </button>
271
+ <button
272
+ type="button"
273
+ onClick={() => {
274
+ setConfirmingDelete(false);
275
+ clearDeleteError();
276
+ }}
277
+ disabled={isDeleting}
278
+ className={cn(
279
+ "rounded-md px-2.5 py-1.5 text-xs",
280
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
281
+ "disabled:pointer-events-none disabled:opacity-50",
282
+ )}
283
+ >
284
+ Cancel
285
+ </button>
286
+ </div>
287
+ </div>
288
+ )}
289
+
290
+ {/* Body */}
291
+ {mode === "view" ? (
292
+ <ViewMode
293
+ spec={spec}
294
+ createdAt={createdAt}
295
+ updatedAt={updatedAt}
296
+ />
297
+ ) : (
298
+ <form onSubmit={handleSave} className="space-y-3">
299
+ <FieldInput
300
+ id="stgm-oauth-edit-provider"
301
+ label="Provider"
302
+ value={provider}
303
+ onChange={setProvider}
304
+ placeholder="e.g. Slack"
305
+ disabled={isUpdating}
306
+ required
307
+ />
308
+ <FieldInput
309
+ id="stgm-oauth-edit-client-id"
310
+ label="Client ID"
311
+ value={clientId}
312
+ onChange={setClientId}
313
+ placeholder="OAuth client identifier"
314
+ disabled={isUpdating}
315
+ required
316
+ />
317
+ <FieldInput
318
+ id="stgm-oauth-edit-client-secret"
319
+ label="Client secret"
320
+ value={clientSecret}
321
+ onChange={setClientSecret}
322
+ placeholder="Leave empty to keep existing secret"
323
+ type="password"
324
+ hint="Only enter a value to replace the existing secret"
325
+ disabled={isUpdating}
326
+ />
327
+ <FieldInput
328
+ id="stgm-oauth-edit-auth-url"
329
+ label="Authorization URL"
330
+ value={authorizationUrl}
331
+ onChange={setAuthorizationUrl}
332
+ placeholder="https://vendor.com/oauth/authorize"
333
+ disabled={isUpdating}
334
+ required
335
+ />
336
+ <FieldInput
337
+ id="stgm-oauth-edit-token-url"
338
+ label="Token URL"
339
+ value={tokenUrl}
340
+ onChange={setTokenUrl}
341
+ placeholder="https://vendor.com/oauth/token"
342
+ disabled={isUpdating}
343
+ required
344
+ />
345
+ <FieldInput
346
+ id="stgm-oauth-edit-scopes"
347
+ label="Scopes"
348
+ value={scopes}
349
+ onChange={setScopes}
350
+ placeholder="read, write, admin"
351
+ hint="Comma-separated OAuth scopes"
352
+ disabled={isUpdating}
353
+ />
354
+ <FieldInput
355
+ id="stgm-oauth-edit-userinfo-url"
356
+ label="Userinfo URL"
357
+ value={userinfoUrl}
358
+ onChange={setUserinfoUrl}
359
+ placeholder="https://vendor.com/userinfo"
360
+ hint="OIDC endpoint for fetching user profile data (optional)"
361
+ disabled={isUpdating}
362
+ />
363
+ <FieldInput
364
+ id="stgm-oauth-edit-scope-param"
365
+ label="Scope parameter name"
366
+ value={scopeParameterName}
367
+ onChange={setScopeParameterName}
368
+ placeholder="scope"
369
+ hint='Defaults to "scope". Some vendors use a non-standard name.'
370
+ disabled={isUpdating}
371
+ />
372
+
373
+ <div className="space-y-1">
374
+ <label
375
+ htmlFor="stgm-oauth-edit-approval-status"
376
+ className="text-xs font-medium text-foreground"
377
+ >
378
+ Vendor approval status
379
+ </label>
380
+ <select
381
+ id="stgm-oauth-edit-approval-status"
382
+ value={vendorApprovalStatus}
383
+ onChange={(e) =>
384
+ setVendorApprovalStatus(
385
+ e.target.value as typeof vendorApprovalStatus,
386
+ )
387
+ }
388
+ disabled={isUpdating}
389
+ className={cn(
390
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
391
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
392
+ "disabled:pointer-events-none disabled:opacity-50",
393
+ )}
394
+ >
395
+ <option value="unspecified">Unspecified (treated as approved)</option>
396
+ <option value="pending">Pending</option>
397
+ <option value="approved">Approved</option>
398
+ <option value="rejected">Rejected</option>
399
+ </select>
400
+ </div>
401
+
402
+ <FieldInput
403
+ id="stgm-oauth-edit-approval-docs"
404
+ label="Vendor approval docs URL"
405
+ value={vendorApprovalDocsUrl}
406
+ onChange={setVendorApprovalDocsUrl}
407
+ placeholder="https://docs.example.com/byoa"
408
+ hint="Help link shown when vendor approval is pending"
409
+ disabled={isUpdating}
410
+ />
411
+
412
+ {updateError && (
413
+ <p className="text-destructive text-[0.65rem]" role="alert">
414
+ {getUserMessage(updateError)}
415
+ </p>
416
+ )}
417
+
418
+ <div className="flex items-center gap-2 pt-1">
419
+ <button
420
+ type="submit"
421
+ disabled={!canSave}
422
+ className={cn(
423
+ "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium",
424
+ "bg-primary text-primary-foreground hover:bg-primary/90",
425
+ "disabled:pointer-events-none disabled:opacity-40",
426
+ )}
427
+ >
428
+ {isUpdating && <SpinnerIcon />}
429
+ Save changes
430
+ </button>
431
+ <button
432
+ type="button"
433
+ onClick={cancelEdit}
434
+ disabled={isUpdating}
435
+ className={cn(
436
+ "rounded-md px-2.5 py-1.5 text-xs",
437
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
438
+ "disabled:pointer-events-none disabled:opacity-50",
439
+ )}
440
+ >
441
+ Cancel
442
+ </button>
443
+ </div>
444
+ </form>
445
+ )}
446
+ </div>
447
+ );
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Constants
452
+ // ---------------------------------------------------------------------------
453
+
454
+ const APPROVAL_STATUS_MAP = {
455
+ pending: VendorApprovalStatus.PENDING,
456
+ approved: VendorApprovalStatus.APPROVED,
457
+ rejected: VendorApprovalStatus.REJECTED,
458
+ } as const;
459
+
460
+ const APPROVAL_STATUS_LABELS: Record<number, string> = {
461
+ [VendorApprovalStatus.UNSPECIFIED]: "Unspecified",
462
+ [VendorApprovalStatus.PENDING]: "Pending",
463
+ [VendorApprovalStatus.APPROVED]: "Approved",
464
+ [VendorApprovalStatus.REJECTED]: "Rejected",
465
+ };
466
+
467
+ function approvalStatusToKey(
468
+ status?: VendorApprovalStatus,
469
+ ): "unspecified" | "pending" | "approved" | "rejected" {
470
+ switch (status) {
471
+ case VendorApprovalStatus.PENDING:
472
+ return "pending";
473
+ case VendorApprovalStatus.APPROVED:
474
+ return "approved";
475
+ case VendorApprovalStatus.REJECTED:
476
+ return "rejected";
477
+ default:
478
+ return "unspecified";
479
+ }
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // View mode
484
+ // ---------------------------------------------------------------------------
485
+
486
+ function ViewMode({
487
+ spec,
488
+ createdAt,
489
+ updatedAt,
490
+ }: {
491
+ spec: OAuthApp["spec"];
492
+ createdAt?: Timestamp;
493
+ updatedAt?: Timestamp;
494
+ }) {
495
+ return (
496
+ <dl className="space-y-2.5">
497
+ <Field label="Provider" value={spec?.provider} />
498
+ <Field label="Client ID" value={spec?.clientId} mono />
499
+ <Field label="Client secret" value="••••••••" />
500
+ <Field label="Authorization URL" value={spec?.authorizationUrl} mono />
501
+ <Field label="Token URL" value={spec?.tokenUrl} mono />
502
+ {spec?.scopes && spec.scopes.length > 0 && (
503
+ <Field label="Scopes" value={spec.scopes.join(", ")} mono />
504
+ )}
505
+ {spec?.userinfoUrl && (
506
+ <Field label="Userinfo URL" value={spec.userinfoUrl} mono />
507
+ )}
508
+ {spec?.scopeParameterName && (
509
+ <Field
510
+ label="Scope parameter name"
511
+ value={spec.scopeParameterName}
512
+ mono
513
+ />
514
+ )}
515
+ {spec?.vendorApprovalStatus !== undefined &&
516
+ spec.vendorApprovalStatus !== VendorApprovalStatus.UNSPECIFIED && (
517
+ <Field
518
+ label="Vendor approval"
519
+ value={
520
+ APPROVAL_STATUS_LABELS[spec.vendorApprovalStatus] ??
521
+ "Unknown"
522
+ }
523
+ />
524
+ )}
525
+ {spec?.vendorApprovalDocsUrl && (
526
+ <Field label="Approval docs" value={spec.vendorApprovalDocsUrl} mono />
527
+ )}
528
+ <div className="flex gap-6">
529
+ {createdAt && (
530
+ <Field
531
+ label="Created"
532
+ value={formatDate(timestampDate(createdAt))}
533
+ />
534
+ )}
535
+ {updatedAt && (
536
+ <Field
537
+ label="Updated"
538
+ value={formatDate(timestampDate(updatedAt))}
539
+ />
540
+ )}
541
+ </div>
542
+ </dl>
543
+ );
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Shared primitives
548
+ // ---------------------------------------------------------------------------
549
+
550
+ function Field({
551
+ label,
552
+ value,
553
+ mono,
554
+ }: {
555
+ label: string;
556
+ value?: string;
557
+ mono?: boolean;
558
+ }) {
559
+ if (!value) return null;
560
+ return (
561
+ <div>
562
+ <dt className="text-muted-foreground text-[0.65rem] font-medium">
563
+ {label}
564
+ </dt>
565
+ <dd
566
+ className={cn(
567
+ "text-foreground mt-0.5 break-all text-xs",
568
+ mono && "font-mono",
569
+ )}
570
+ >
571
+ {value}
572
+ </dd>
573
+ </div>
574
+ );
575
+ }
576
+
577
+ function FieldInput({
578
+ id,
579
+ label,
580
+ value,
581
+ onChange,
582
+ placeholder,
583
+ hint,
584
+ type = "text",
585
+ disabled,
586
+ required,
587
+ }: {
588
+ id: string;
589
+ label: string;
590
+ value: string;
591
+ onChange: (v: string) => void;
592
+ placeholder: string;
593
+ hint?: string;
594
+ type?: "text" | "password";
595
+ disabled?: boolean;
596
+ required?: boolean;
597
+ }) {
598
+ return (
599
+ <div className="space-y-1">
600
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
601
+ {label}
602
+ </label>
603
+ <input
604
+ id={id}
605
+ type={type}
606
+ value={value}
607
+ onChange={(e) => onChange(e.target.value)}
608
+ placeholder={placeholder}
609
+ disabled={disabled}
610
+ required={required}
611
+ className={cn(
612
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
613
+ "placeholder:text-muted-foreground",
614
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
615
+ "disabled:pointer-events-none disabled:opacity-50",
616
+ )}
617
+ />
618
+ {hint && (
619
+ <p className="text-[0.65rem] text-muted-foreground">{hint}</p>
620
+ )}
621
+ </div>
622
+ );
623
+ }
624
+
625
+ function formatDate(date: Date): string {
626
+ return date.toLocaleDateString(undefined, {
627
+ month: "short",
628
+ day: "numeric",
629
+ year: "numeric",
630
+ });
631
+ }
632
+
633
+ // ---------------------------------------------------------------------------
634
+ // Icons
635
+ // ---------------------------------------------------------------------------
636
+
637
+ function ArrowLeftIcon() {
638
+ return (
639
+ <svg
640
+ width="12"
641
+ height="12"
642
+ viewBox="0 0 16 16"
643
+ fill="none"
644
+ stroke="currentColor"
645
+ strokeWidth="1.5"
646
+ strokeLinecap="round"
647
+ strokeLinejoin="round"
648
+ aria-hidden="true"
649
+ >
650
+ <path d="M10 3L5 8l5 5" />
651
+ </svg>
652
+ );
653
+ }
654
+
655
+ function SpinnerIcon() {
656
+ return (
657
+ <svg
658
+ width="12"
659
+ height="12"
660
+ viewBox="0 0 16 16"
661
+ fill="none"
662
+ stroke="currentColor"
663
+ strokeWidth="2"
664
+ strokeLinecap="round"
665
+ className="animate-spin"
666
+ aria-hidden="true"
667
+ >
668
+ <path d="M8 2a6 6 0 1 0 6 6" />
669
+ </svg>
670
+ );
671
+ }