@stigmer/react 0.0.89 → 0.0.91

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 (128) hide show
  1. package/identity-provider/CreateIdentityProviderForm.d.ts.map +1 -1
  2. package/identity-provider/CreateIdentityProviderForm.js +60 -2
  3. package/identity-provider/CreateIdentityProviderForm.js.map +1 -1
  4. package/identity-provider/IdentityProviderDetailPanel.d.ts.map +1 -1
  5. package/identity-provider/IdentityProviderDetailPanel.js +87 -4
  6. package/identity-provider/IdentityProviderDetailPanel.js.map +1 -1
  7. package/identity-provider/IdentityProviderListPanel.js +5 -3
  8. package/identity-provider/IdentityProviderListPanel.js.map +1 -1
  9. package/identity-provider/IdentityProviderWizard.d.ts.map +1 -1
  10. package/identity-provider/IdentityProviderWizard.js +59 -4
  11. package/identity-provider/IdentityProviderWizard.js.map +1 -1
  12. package/index.d.ts +2 -0
  13. package/index.d.ts.map +1 -1
  14. package/index.js +2 -0
  15. package/index.js.map +1 -1
  16. package/package.json +7 -7
  17. package/platform-client/CreatePlatformClientForm.d.ts +42 -0
  18. package/platform-client/CreatePlatformClientForm.d.ts.map +1 -0
  19. package/platform-client/CreatePlatformClientForm.js +148 -0
  20. package/platform-client/CreatePlatformClientForm.js.map +1 -0
  21. package/platform-client/PlatformClientDetailPanel.d.ts +51 -0
  22. package/platform-client/PlatformClientDetailPanel.d.ts.map +1 -0
  23. package/platform-client/PlatformClientDetailPanel.js +247 -0
  24. package/platform-client/PlatformClientDetailPanel.js.map +1 -0
  25. package/platform-client/PlatformClientListPanel.d.ts +41 -0
  26. package/platform-client/PlatformClientListPanel.d.ts.map +1 -0
  27. package/platform-client/PlatformClientListPanel.js +123 -0
  28. package/platform-client/PlatformClientListPanel.js.map +1 -0
  29. package/platform-client/PlatformClientSecretAlert.d.ts +39 -0
  30. package/platform-client/PlatformClientSecretAlert.d.ts.map +1 -0
  31. package/platform-client/PlatformClientSecretAlert.js +74 -0
  32. package/platform-client/PlatformClientSecretAlert.js.map +1 -0
  33. package/platform-client/index.d.ts +11 -0
  34. package/platform-client/index.d.ts.map +1 -0
  35. package/platform-client/index.js +11 -0
  36. package/platform-client/index.js.map +1 -0
  37. package/platform-client/useCreatePlatformClient.d.ts +42 -0
  38. package/platform-client/useCreatePlatformClient.d.ts.map +1 -0
  39. package/platform-client/useCreatePlatformClient.js +49 -0
  40. package/platform-client/useCreatePlatformClient.js.map +1 -0
  41. package/platform-client/useDeletePlatformClient.d.ts +31 -0
  42. package/platform-client/useDeletePlatformClient.d.ts.map +1 -0
  43. package/platform-client/useDeletePlatformClient.js +42 -0
  44. package/platform-client/useDeletePlatformClient.js.map +1 -0
  45. package/platform-client/usePlatformClient.d.ts +37 -0
  46. package/platform-client/usePlatformClient.d.ts.map +1 -0
  47. package/platform-client/usePlatformClient.js +62 -0
  48. package/platform-client/usePlatformClient.js.map +1 -0
  49. package/platform-client/usePlatformClientList.d.ts +42 -0
  50. package/platform-client/usePlatformClientList.d.ts.map +1 -0
  51. package/platform-client/usePlatformClientList.js +71 -0
  52. package/platform-client/usePlatformClientList.js.map +1 -0
  53. package/platform-client/useRotatePlatformClientSecret.d.ts +35 -0
  54. package/platform-client/useRotatePlatformClientSecret.d.ts.map +1 -0
  55. package/platform-client/useRotatePlatformClientSecret.js +43 -0
  56. package/platform-client/useRotatePlatformClientSecret.js.map +1 -0
  57. package/platform-client/useUpdatePlatformClient.d.ts +39 -0
  58. package/platform-client/useUpdatePlatformClient.d.ts.map +1 -0
  59. package/platform-client/useUpdatePlatformClient.js +50 -0
  60. package/platform-client/useUpdatePlatformClient.js.map +1 -0
  61. package/src/identity-provider/CreateIdentityProviderForm.tsx +220 -0
  62. package/src/identity-provider/IdentityProviderDetailPanel.tsx +288 -6
  63. package/src/identity-provider/IdentityProviderListPanel.tsx +9 -2
  64. package/src/identity-provider/IdentityProviderWizard.tsx +231 -25
  65. package/src/index.ts +26 -0
  66. package/src/platform-client/CreatePlatformClientForm.tsx +519 -0
  67. package/src/platform-client/PlatformClientDetailPanel.tsx +898 -0
  68. package/src/platform-client/PlatformClientListPanel.tsx +413 -0
  69. package/src/platform-client/PlatformClientSecretAlert.tsx +252 -0
  70. package/src/platform-client/index.ts +49 -0
  71. package/src/platform-client/useCreatePlatformClient.ts +77 -0
  72. package/src/platform-client/useDeletePlatformClient.ts +64 -0
  73. package/src/platform-client/usePlatformClient.ts +86 -0
  74. package/src/platform-client/usePlatformClientList.ts +96 -0
  75. package/src/platform-client/useRotatePlatformClientSecret.ts +68 -0
  76. package/src/platform-client/useUpdatePlatformClient.ts +70 -0
  77. package/src/test/index.ts +6 -0
  78. package/src/{demo → test}/samples.ts +1 -1
  79. package/styles.css +1 -1
  80. package/test/__tests__/samples.test.d.ts.map +1 -0
  81. package/{demo → test}/__tests__/samples.test.js.map +1 -1
  82. package/test/index.d.ts +2 -0
  83. package/test/index.d.ts.map +1 -0
  84. package/test/index.js +6 -0
  85. package/test/index.js.map +1 -0
  86. package/{demo → test}/samples.d.ts +1 -1
  87. package/{demo → test}/samples.d.ts.map +1 -1
  88. package/{demo → test}/samples.js +1 -1
  89. package/{demo → test}/samples.js.map +1 -1
  90. package/demo/__tests__/demo-client.test.d.ts +0 -2
  91. package/demo/__tests__/demo-client.test.d.ts.map +0 -1
  92. package/demo/__tests__/demo-client.test.js +0 -133
  93. package/demo/__tests__/demo-client.test.js.map +0 -1
  94. package/demo/__tests__/fixtures.test.d.ts +0 -2
  95. package/demo/__tests__/fixtures.test.d.ts.map +0 -1
  96. package/demo/__tests__/fixtures.test.js +0 -135
  97. package/demo/__tests__/fixtures.test.js.map +0 -1
  98. package/demo/__tests__/samples.test.d.ts.map +0 -1
  99. package/demo/client.d.ts +0 -29
  100. package/demo/client.d.ts.map +0 -1
  101. package/demo/client.js +0 -52
  102. package/demo/client.js.map +0 -1
  103. package/demo/fixtures.d.ts +0 -194
  104. package/demo/fixtures.d.ts.map +0 -1
  105. package/demo/fixtures.js +0 -267
  106. package/demo/fixtures.js.map +0 -1
  107. package/demo/index.d.ts +0 -6
  108. package/demo/index.d.ts.map +0 -1
  109. package/demo/index.js +0 -6
  110. package/demo/index.js.map +0 -1
  111. package/demo/transport.d.ts +0 -59
  112. package/demo/transport.d.ts.map +0 -1
  113. package/demo/transport.js +0 -75
  114. package/demo/transport.js.map +0 -1
  115. package/demo/types.d.ts +0 -62
  116. package/demo/types.d.ts.map +0 -1
  117. package/demo/types.js +0 -16
  118. package/demo/types.js.map +0 -1
  119. package/src/demo/__tests__/demo-client.test.tsx +0 -213
  120. package/src/demo/__tests__/fixtures.test.ts +0 -214
  121. package/src/demo/client.ts +0 -78
  122. package/src/demo/fixtures.ts +0 -409
  123. package/src/demo/index.ts +0 -12
  124. package/src/demo/transport.ts +0 -116
  125. package/src/demo/types.ts +0 -69
  126. /package/src/{demo → test}/__tests__/samples.test.ts +0 -0
  127. /package/{demo → test}/__tests__/samples.test.d.ts +0 -0
  128. /package/{demo → test}/__tests__/samples.test.js +0 -0
@@ -0,0 +1,898 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useState,
6
+ type FormEvent,
7
+ type KeyboardEvent,
8
+ } from "react";
9
+ import { cn } from "@stigmer/theme";
10
+ import { getUserMessage } from "@stigmer/sdk";
11
+ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
12
+ import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt";
13
+ import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
14
+ import type { PlatformClientCreateResponse } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/io_pb";
15
+ import { useUpdatePlatformClient } from "./useUpdatePlatformClient";
16
+ import { useRotatePlatformClientSecret } from "./useRotatePlatformClientSecret";
17
+ import { useDeletePlatformClient } from "./useDeletePlatformClient";
18
+
19
+ /** Props for {@link PlatformClientDetailPanel}. */
20
+ export interface PlatformClientDetailPanelProps {
21
+ /** The platform client resource to display and edit. */
22
+ readonly platformClient: PlatformClient;
23
+ /** Fired with the updated resource after a successful save. */
24
+ readonly onUpdated?: (pc: PlatformClient) => void;
25
+ /**
26
+ * Fired after a successful secret rotation with the response
27
+ * containing the one-time new raw secret.
28
+ */
29
+ readonly onSecretRotated?: (
30
+ response: PlatformClientCreateResponse,
31
+ ) => void;
32
+ /** Fired after a successful deletion. */
33
+ readonly onDeleted?: () => void;
34
+ /** Fired when the user clicks the back button. */
35
+ readonly onBack?: () => void;
36
+ /** Additional CSS class names for the root container. */
37
+ readonly className?: string;
38
+ }
39
+
40
+ /**
41
+ * View and edit panel for an existing platform client.
42
+ *
43
+ * In **view mode**, displays all configuration fields in a structured
44
+ * label/value layout with "Edit", "Rotate Secret", and "Delete"
45
+ * actions.
46
+ *
47
+ * In **edit mode**, mutable spec fields become editable: JIT
48
+ * provisioning toggles, expiry, auto-grant role, and allowed
49
+ * origins. Credential fields (`clientId`, `secretFingerprint`) are
50
+ * read-only. "Save" submits the update; "Cancel" discards changes.
51
+ *
52
+ * Secret rotation triggers `onSecretRotated` with the full
53
+ * {@link PlatformClientCreateResponse} so the parent can show the
54
+ * one-time secret alert.
55
+ *
56
+ * All visual properties flow through `--stgm-*` design tokens.
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * <PlatformClientDetailPanel
61
+ * platformClient={pc}
62
+ * onUpdated={(updated) => refetch()}
63
+ * onSecretRotated={(resp) => setFlow({ phase: "revealing", resp })}
64
+ * onDeleted={() => setFlow({ phase: "idle" })}
65
+ * onBack={() => setFlow({ phase: "idle" })}
66
+ * />
67
+ * ```
68
+ */
69
+ export function PlatformClientDetailPanel({
70
+ platformClient,
71
+ onUpdated,
72
+ onSecretRotated,
73
+ onDeleted,
74
+ onBack,
75
+ className,
76
+ }: PlatformClientDetailPanelProps) {
77
+ const spec = platformClient.spec;
78
+ const meta = platformClient.metadata;
79
+
80
+ const { update, isUpdating, error: updateError, clearError: clearUpdateError } =
81
+ useUpdatePlatformClient();
82
+ const { rotateSecret, isRotating, error: rotateError, clearError: clearRotateError } =
83
+ useRotatePlatformClientSecret();
84
+ const { deletePlatformClient, isDeleting, error: deleteError } =
85
+ useDeletePlatformClient();
86
+
87
+ const [mode, setMode] = useState<"view" | "edit">("view");
88
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
89
+ const [confirmingRotate, setConfirmingRotate] = useState(false);
90
+
91
+ const isBusy = isUpdating || isRotating || isDeleting;
92
+
93
+ // Edit form state
94
+ const [neverExpires, setNeverExpires] = useState(
95
+ spec?.neverExpires ?? true,
96
+ );
97
+ const [expiresAt, setExpiresAt] = useState(() => {
98
+ if (spec?.expiresAt) {
99
+ return toDatetimeLocalValue(timestampDate(spec.expiresAt));
100
+ }
101
+ return "";
102
+ });
103
+ const [autoProvision, setAutoProvision] = useState(
104
+ spec?.autoProvisionAccounts ?? false,
105
+ );
106
+ const [autoGrant, setAutoGrant] = useState(
107
+ spec?.autoGrantOnOrg ?? false,
108
+ );
109
+ const [autoGrantRole, setAutoGrantRole] = useState<IamRole>(
110
+ spec?.autoGrantRole ?? IamRole.iam_role_unspecified,
111
+ );
112
+ const [origins, setOrigins] = useState<string[]>(
113
+ [...(spec?.allowedOrigins ?? [])],
114
+ );
115
+ const [originInput, setOriginInput] = useState("");
116
+
117
+ const handleAutoProvisionChange = useCallback((v: boolean) => {
118
+ setAutoProvision(v);
119
+ if (!v) {
120
+ setAutoGrant(false);
121
+ setAutoGrantRole(IamRole.iam_role_unspecified);
122
+ }
123
+ }, []);
124
+
125
+ const handleAutoGrantChange = useCallback((v: boolean) => {
126
+ setAutoGrant(v);
127
+ if (v) setAutoProvision(true);
128
+ if (!v) setAutoGrantRole(IamRole.iam_role_unspecified);
129
+ }, []);
130
+
131
+ const addOrigin = useCallback(() => {
132
+ const trimmed = originInput.trim();
133
+ if (trimmed && !origins.includes(trimmed)) {
134
+ setOrigins((prev) => [...prev, trimmed]);
135
+ }
136
+ setOriginInput("");
137
+ }, [originInput, origins]);
138
+
139
+ const handleOriginKeyDown = useCallback(
140
+ (e: KeyboardEvent<HTMLInputElement>) => {
141
+ if (e.key === "Enter") {
142
+ e.preventDefault();
143
+ addOrigin();
144
+ }
145
+ },
146
+ [addOrigin],
147
+ );
148
+
149
+ const removeOrigin = useCallback((origin: string) => {
150
+ setOrigins((prev) => prev.filter((o) => o !== origin));
151
+ }, []);
152
+
153
+ const enterEdit = useCallback(() => {
154
+ setNeverExpires(spec?.neverExpires ?? true);
155
+ setExpiresAt(
156
+ spec?.expiresAt
157
+ ? toDatetimeLocalValue(timestampDate(spec.expiresAt))
158
+ : "",
159
+ );
160
+ setAutoProvision(spec?.autoProvisionAccounts ?? false);
161
+ setAutoGrant(spec?.autoGrantOnOrg ?? false);
162
+ setAutoGrantRole(
163
+ spec?.autoGrantRole ?? IamRole.iam_role_unspecified,
164
+ );
165
+ setOrigins([...(spec?.allowedOrigins ?? [])]);
166
+ setOriginInput("");
167
+ clearUpdateError();
168
+ setMode("edit");
169
+ }, [spec, clearUpdateError]);
170
+
171
+ const cancelEdit = useCallback(() => {
172
+ clearUpdateError();
173
+ setMode("view");
174
+ }, [clearUpdateError]);
175
+
176
+ const handleSave = useCallback(
177
+ async (e: FormEvent) => {
178
+ e.preventDefault();
179
+ clearUpdateError();
180
+ try {
181
+ const updated = await update({
182
+ name: meta?.name ?? "",
183
+ slug: meta?.slug,
184
+ org: meta?.org ?? "",
185
+ neverExpires,
186
+ ...(!neverExpires &&
187
+ expiresAt && {
188
+ expiresAt: new Date(expiresAt).toISOString(),
189
+ }),
190
+ autoProvisionAccounts: autoProvision,
191
+ autoGrantOnOrg: autoGrant,
192
+ ...(autoGrant &&
193
+ autoGrantRole !== IamRole.iam_role_unspecified && {
194
+ autoGrantRole,
195
+ }),
196
+ allowedOrigins: origins,
197
+ });
198
+ setMode("view");
199
+ onUpdated?.(updated);
200
+ } catch {
201
+ // error state is managed by useUpdatePlatformClient
202
+ }
203
+ },
204
+ [
205
+ meta,
206
+ neverExpires,
207
+ expiresAt,
208
+ autoProvision,
209
+ autoGrant,
210
+ autoGrantRole,
211
+ origins,
212
+ update,
213
+ clearUpdateError,
214
+ onUpdated,
215
+ ],
216
+ );
217
+
218
+ const handleRotateSecret = useCallback(async () => {
219
+ clearRotateError();
220
+ try {
221
+ const response = await rotateSecret(meta?.id ?? "");
222
+ setConfirmingRotate(false);
223
+ onSecretRotated?.(response);
224
+ } catch {
225
+ // error state is managed by the hook
226
+ }
227
+ }, [meta?.id, rotateSecret, clearRotateError, onSecretRotated]);
228
+
229
+ const handleDelete = useCallback(async () => {
230
+ try {
231
+ await deletePlatformClient({ resourceId: meta?.id ?? "" });
232
+ onDeleted?.();
233
+ } catch {
234
+ // error state is surfaced via the hook
235
+ }
236
+ }, [meta?.id, deletePlatformClient, onDeleted]);
237
+
238
+ const createdAt = platformClient.status?.audit?.specAudit?.createdAt;
239
+ const updatedAt = platformClient.status?.audit?.specAudit?.updatedAt;
240
+
241
+ return (
242
+ <div className={cn("space-y-4", className)}>
243
+ {/* Header */}
244
+ <div className="flex items-start justify-between gap-3">
245
+ <div className="min-w-0">
246
+ {onBack && (
247
+ <button
248
+ type="button"
249
+ onClick={onBack}
250
+ className="text-muted-foreground hover:text-foreground mb-1 flex items-center gap-1 text-xs transition-colors"
251
+ >
252
+ <ArrowLeftIcon />
253
+ Back to list
254
+ </button>
255
+ )}
256
+ <h3 className="text-foreground truncate text-sm font-semibold">
257
+ {meta?.name ?? "Platform Client"}
258
+ </h3>
259
+ <div className="flex items-center gap-2">
260
+ {meta?.slug && (
261
+ <span className="text-muted-foreground font-mono text-xs">
262
+ {meta.slug}
263
+ </span>
264
+ )}
265
+ {spec?.autoProvisionAccounts && (
266
+ <span className="inline-flex items-center rounded-full border border-primary/30 bg-primary-subtle px-2 py-0.5 text-[0.65rem] font-medium text-primary">
267
+ JIT
268
+ </span>
269
+ )}
270
+ </div>
271
+ </div>
272
+
273
+ {mode === "view" && (
274
+ <button
275
+ type="button"
276
+ onClick={enterEdit}
277
+ className={cn(
278
+ "shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium",
279
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
280
+ "transition-colors",
281
+ )}
282
+ >
283
+ Edit
284
+ </button>
285
+ )}
286
+ </div>
287
+
288
+ {/* Body */}
289
+ {mode === "view" ? (
290
+ <ViewMode
291
+ spec={spec}
292
+ createdAt={createdAt}
293
+ updatedAt={updatedAt}
294
+ />
295
+ ) : (
296
+ <form onSubmit={handleSave} className="space-y-3">
297
+ {/* Read-only credential info */}
298
+ <div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 space-y-1">
299
+ <p className="text-[0.65rem] font-medium text-muted-foreground">
300
+ Client ID
301
+ </p>
302
+ <p className="font-mono text-xs text-foreground">
303
+ {spec?.clientId ?? "—"}
304
+ </p>
305
+ </div>
306
+
307
+ {/* Expiry */}
308
+ <fieldset className="space-y-2" disabled={isUpdating}>
309
+ <legend className="text-xs font-medium text-foreground">
310
+ Expiry
311
+ </legend>
312
+ <ToggleSwitch
313
+ checked={neverExpires}
314
+ onChange={setNeverExpires}
315
+ label="Never expires"
316
+ disabled={isUpdating}
317
+ />
318
+ {!neverExpires && (
319
+ <div className="space-y-1">
320
+ <label
321
+ htmlFor="stgm-pc-edit-expires-at"
322
+ className="text-xs font-medium text-foreground"
323
+ >
324
+ Expires at
325
+ </label>
326
+ <input
327
+ id="stgm-pc-edit-expires-at"
328
+ type="datetime-local"
329
+ value={expiresAt}
330
+ onChange={(e) => setExpiresAt(e.target.value)}
331
+ disabled={isUpdating}
332
+ className={cn(
333
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
334
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
335
+ "disabled:pointer-events-none disabled:opacity-50",
336
+ )}
337
+ />
338
+ </div>
339
+ )}
340
+ </fieldset>
341
+
342
+ {/* JIT provisioning */}
343
+ <fieldset className="space-y-2.5" disabled={isUpdating}>
344
+ <hr className="border-border/40" />
345
+ <legend className="text-xs font-medium text-foreground">
346
+ JIT provisioning
347
+ </legend>
348
+
349
+ <ToggleSwitch
350
+ checked={autoProvision}
351
+ onChange={handleAutoProvisionChange}
352
+ label="Auto-provision accounts"
353
+ hint="Create a Stigmer identity account automatically on first token mint"
354
+ disabled={isUpdating}
355
+ />
356
+
357
+ <ToggleSwitch
358
+ checked={autoGrant}
359
+ onChange={handleAutoGrantChange}
360
+ label="Auto-grant on organization"
361
+ hint="Grant a role on the owning organization when an account is provisioned"
362
+ disabled={isUpdating || !autoProvision}
363
+ />
364
+
365
+ {autoGrant && (
366
+ <div className="space-y-1">
367
+ <label
368
+ htmlFor="stgm-pc-edit-grant-role"
369
+ className="text-xs font-medium text-foreground"
370
+ >
371
+ Auto-grant role
372
+ </label>
373
+ <select
374
+ id="stgm-pc-edit-grant-role"
375
+ value={String(autoGrantRole)}
376
+ onChange={(e) =>
377
+ setAutoGrantRole(
378
+ Number(e.target.value) as IamRole,
379
+ )
380
+ }
381
+ disabled={isUpdating}
382
+ className={cn(
383
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
384
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
385
+ "disabled:pointer-events-none disabled:opacity-50",
386
+ )}
387
+ >
388
+ {JIT_ROLE_OPTIONS.map((opt) => (
389
+ <option key={opt.value} value={opt.value}>
390
+ {opt.label}
391
+ </option>
392
+ ))}
393
+ </select>
394
+ </div>
395
+ )}
396
+ </fieldset>
397
+
398
+ {/* Allowed origins */}
399
+ <fieldset className="space-y-2" disabled={isUpdating}>
400
+ <hr className="border-border/40" />
401
+ <legend className="text-xs font-medium text-foreground">
402
+ Allowed origins
403
+ </legend>
404
+ <p className="text-[0.65rem] text-muted-foreground">
405
+ Browser origins permitted to use tokens minted by this
406
+ client. Leave empty to allow all origins.
407
+ </p>
408
+
409
+ <div className="flex items-center gap-2">
410
+ <input
411
+ type="text"
412
+ value={originInput}
413
+ onChange={(e) => setOriginInput(e.target.value)}
414
+ onKeyDown={handleOriginKeyDown}
415
+ onBlur={() => {
416
+ if (originInput.trim()) addOrigin();
417
+ }}
418
+ placeholder="https://example.com"
419
+ disabled={isUpdating}
420
+ className={cn(
421
+ "min-w-0 flex-1 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
422
+ "placeholder:text-muted-foreground",
423
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
424
+ "disabled:pointer-events-none disabled:opacity-50",
425
+ )}
426
+ />
427
+ <button
428
+ type="button"
429
+ onClick={addOrigin}
430
+ disabled={isUpdating || !originInput.trim()}
431
+ className={cn(
432
+ "shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium",
433
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
434
+ "disabled:pointer-events-none disabled:opacity-50",
435
+ "transition-colors",
436
+ )}
437
+ >
438
+ Add
439
+ </button>
440
+ </div>
441
+
442
+ {origins.length > 0 && (
443
+ <div className="flex flex-wrap gap-1.5">
444
+ {origins.map((origin) => (
445
+ <span
446
+ key={origin}
447
+ className="inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/40 px-2 py-0.5 text-[0.65rem] font-mono text-foreground"
448
+ >
449
+ {origin}
450
+ <button
451
+ type="button"
452
+ onClick={() => removeOrigin(origin)}
453
+ disabled={isUpdating}
454
+ aria-label={`Remove ${origin}`}
455
+ className="text-muted-foreground hover:text-destructive transition-colors"
456
+ >
457
+ <XIcon />
458
+ </button>
459
+ </span>
460
+ ))}
461
+ </div>
462
+ )}
463
+ </fieldset>
464
+
465
+ {updateError && (
466
+ <p className="text-destructive text-[0.65rem]" role="alert">
467
+ {getUserMessage(updateError)}
468
+ </p>
469
+ )}
470
+
471
+ <div className="flex items-center gap-2 pt-1">
472
+ <button
473
+ type="submit"
474
+ disabled={isUpdating}
475
+ className={cn(
476
+ "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium",
477
+ "bg-primary text-primary-foreground hover:bg-primary/90",
478
+ "disabled:pointer-events-none disabled:opacity-40",
479
+ )}
480
+ >
481
+ {isUpdating && <SpinnerIcon />}
482
+ Save changes
483
+ </button>
484
+ <button
485
+ type="button"
486
+ onClick={cancelEdit}
487
+ disabled={isUpdating}
488
+ className={cn(
489
+ "rounded-md px-2.5 py-1.5 text-xs",
490
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
491
+ "disabled:pointer-events-none disabled:opacity-50",
492
+ )}
493
+ >
494
+ Cancel
495
+ </button>
496
+ </div>
497
+ </form>
498
+ )}
499
+
500
+ {/* Actions bar (view mode only) */}
501
+ {mode === "view" && (
502
+ <div className="space-y-2 pt-2">
503
+ <hr className="border-border/40" />
504
+
505
+ {/* Rotate Secret */}
506
+ {confirmingRotate ? (
507
+ <div className="flex items-center justify-between rounded-md border border-warning/30 bg-warning/5 px-3 py-2">
508
+ <p className="text-xs text-foreground">
509
+ Rotate secret? The current secret will be
510
+ <span className="font-medium"> permanently invalidated</span>.
511
+ </p>
512
+ <div className="flex shrink-0 items-center gap-1.5">
513
+ <button
514
+ type="button"
515
+ onClick={handleRotateSecret}
516
+ disabled={isBusy}
517
+ className={cn(
518
+ "inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium",
519
+ "bg-primary text-primary-foreground hover:bg-primary/90",
520
+ "disabled:pointer-events-none disabled:opacity-50",
521
+ )}
522
+ >
523
+ {isRotating && <SpinnerIcon />}
524
+ Rotate
525
+ </button>
526
+ <button
527
+ type="button"
528
+ onClick={() => {
529
+ setConfirmingRotate(false);
530
+ clearRotateError();
531
+ }}
532
+ disabled={isBusy}
533
+ className={cn(
534
+ "rounded-md px-2.5 py-1 text-xs",
535
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
536
+ "disabled:pointer-events-none disabled:opacity-50",
537
+ )}
538
+ >
539
+ Cancel
540
+ </button>
541
+ </div>
542
+ </div>
543
+ ) : (
544
+ <button
545
+ type="button"
546
+ onClick={() => setConfirmingRotate(true)}
547
+ disabled={isBusy}
548
+ className={cn(
549
+ "rounded-md px-2.5 py-1.5 text-xs font-medium",
550
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
551
+ "disabled:pointer-events-none disabled:opacity-50",
552
+ "transition-colors",
553
+ )}
554
+ >
555
+ Rotate secret
556
+ </button>
557
+ )}
558
+ {rotateError && (
559
+ <p className="text-destructive text-[0.65rem]" role="alert">
560
+ {getUserMessage(rotateError)}
561
+ </p>
562
+ )}
563
+
564
+ {/* Delete */}
565
+ {confirmingDelete ? (
566
+ <div className="flex items-center justify-between rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
567
+ <div className="min-w-0 flex-1">
568
+ <p className="text-xs text-foreground">
569
+ Delete{" "}
570
+ <span className="font-medium">{meta?.name}</span>?
571
+ This action is permanent.
572
+ </p>
573
+ {deleteError && (
574
+ <p className="mt-0.5 text-[0.65rem] text-destructive">
575
+ {getUserMessage(deleteError)}
576
+ </p>
577
+ )}
578
+ </div>
579
+ <div className="flex shrink-0 items-center gap-1.5">
580
+ <button
581
+ type="button"
582
+ onClick={handleDelete}
583
+ disabled={isBusy}
584
+ className={cn(
585
+ "inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium",
586
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
587
+ "disabled:pointer-events-none disabled:opacity-50",
588
+ )}
589
+ >
590
+ {isDeleting && <SpinnerIcon />}
591
+ Delete
592
+ </button>
593
+ <button
594
+ type="button"
595
+ onClick={() => setConfirmingDelete(false)}
596
+ disabled={isBusy}
597
+ className={cn(
598
+ "rounded-md px-2.5 py-1 text-xs",
599
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
600
+ "disabled:pointer-events-none disabled:opacity-50",
601
+ )}
602
+ >
603
+ Cancel
604
+ </button>
605
+ </div>
606
+ </div>
607
+ ) : (
608
+ <button
609
+ type="button"
610
+ onClick={() => setConfirmingDelete(true)}
611
+ disabled={isBusy}
612
+ className={cn(
613
+ "rounded-md px-2.5 py-1.5 text-xs font-medium",
614
+ "text-destructive hover:text-destructive-foreground hover:bg-destructive/90",
615
+ "disabled:pointer-events-none disabled:opacity-50",
616
+ "transition-colors",
617
+ )}
618
+ >
619
+ Delete platform client
620
+ </button>
621
+ )}
622
+ </div>
623
+ )}
624
+ </div>
625
+ );
626
+ }
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // View mode
630
+ // ---------------------------------------------------------------------------
631
+
632
+ function ViewMode({
633
+ spec,
634
+ createdAt,
635
+ updatedAt,
636
+ }: {
637
+ spec: PlatformClient["spec"];
638
+ createdAt?: Timestamp;
639
+ updatedAt?: Timestamp;
640
+ }) {
641
+ return (
642
+ <dl className="space-y-2.5">
643
+ <Field label="Client ID" value={spec?.clientId} mono />
644
+ <Field
645
+ label="Secret fingerprint"
646
+ value={
647
+ spec?.secretFingerprint
648
+ ? `••••${spec.secretFingerprint.slice(-4)}`
649
+ : undefined
650
+ }
651
+ mono
652
+ />
653
+
654
+ {/* Expiry */}
655
+ {spec?.neverExpires ? (
656
+ <Field label="Expiry" value="Never expires" />
657
+ ) : spec?.expiresAt ? (
658
+ <Field
659
+ label="Expires"
660
+ value={formatDate(timestampDate(spec.expiresAt))}
661
+ />
662
+ ) : null}
663
+
664
+ {/* JIT provisioning */}
665
+ <hr className="border-border/40" />
666
+ <Field
667
+ label="Auto-provision accounts"
668
+ value={spec?.autoProvisionAccounts ? "Enabled" : "Disabled"}
669
+ />
670
+ <Field
671
+ label="Auto-grant on organization"
672
+ value={spec?.autoGrantOnOrg ? "Enabled" : "Disabled"}
673
+ />
674
+ {spec?.autoGrantOnOrg && (
675
+ <Field
676
+ label="Auto-grant role"
677
+ value={formatIamRole(spec.autoGrantRole)}
678
+ />
679
+ )}
680
+
681
+ {/* Allowed origins */}
682
+ {(spec?.allowedOrigins.length ?? 0) > 0 && (
683
+ <>
684
+ <hr className="border-border/40" />
685
+ <div>
686
+ <dt className="text-muted-foreground text-[0.65rem] font-medium">
687
+ Allowed origins
688
+ </dt>
689
+ <dd className="mt-0.5 flex flex-wrap gap-1.5">
690
+ {spec!.allowedOrigins.map((origin) => (
691
+ <span
692
+ key={origin}
693
+ className="inline-block rounded-full border border-border/60 bg-muted/40 px-2 py-0.5 text-[0.65rem] font-mono text-foreground"
694
+ >
695
+ {origin}
696
+ </span>
697
+ ))}
698
+ </dd>
699
+ </div>
700
+ </>
701
+ )}
702
+
703
+ {/* Timestamps */}
704
+ <div className="flex gap-6">
705
+ {createdAt && (
706
+ <Field
707
+ label="Created"
708
+ value={formatDate(timestampDate(createdAt))}
709
+ />
710
+ )}
711
+ {updatedAt && (
712
+ <Field
713
+ label="Updated"
714
+ value={formatDate(timestampDate(updatedAt))}
715
+ />
716
+ )}
717
+ </div>
718
+ </dl>
719
+ );
720
+ }
721
+
722
+ // ---------------------------------------------------------------------------
723
+ // Shared primitives
724
+ // ---------------------------------------------------------------------------
725
+
726
+ function Field({
727
+ label,
728
+ value,
729
+ mono,
730
+ }: {
731
+ label: string;
732
+ value?: string;
733
+ mono?: boolean;
734
+ }) {
735
+ if (!value) return null;
736
+ return (
737
+ <div>
738
+ <dt className="text-muted-foreground text-[0.65rem] font-medium">
739
+ {label}
740
+ </dt>
741
+ <dd
742
+ className={cn(
743
+ "text-foreground mt-0.5 break-all text-xs",
744
+ mono && "font-mono",
745
+ )}
746
+ >
747
+ {value}
748
+ </dd>
749
+ </div>
750
+ );
751
+ }
752
+
753
+ function ToggleSwitch({
754
+ checked,
755
+ onChange,
756
+ label,
757
+ hint,
758
+ disabled,
759
+ }: {
760
+ checked: boolean;
761
+ onChange: (v: boolean) => void;
762
+ label: string;
763
+ hint?: string;
764
+ disabled?: boolean;
765
+ }) {
766
+ return (
767
+ <div className="space-y-0.5">
768
+ <div className="flex items-center gap-2">
769
+ <button
770
+ type="button"
771
+ role="switch"
772
+ aria-checked={checked}
773
+ onClick={() => onChange(!checked)}
774
+ disabled={disabled}
775
+ className={cn(
776
+ "relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
777
+ checked ? "bg-primary" : "bg-muted",
778
+ "disabled:pointer-events-none disabled:opacity-50",
779
+ )}
780
+ >
781
+ <span
782
+ className={cn(
783
+ "pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm ring-0 transition-transform",
784
+ checked ? "translate-x-4" : "translate-x-0",
785
+ )}
786
+ />
787
+ </button>
788
+ <span className="text-xs font-medium text-foreground">{label}</span>
789
+ </div>
790
+ {hint && (
791
+ <p className="pl-11 text-[0.65rem] text-muted-foreground">{hint}</p>
792
+ )}
793
+ </div>
794
+ );
795
+ }
796
+
797
+ // ---------------------------------------------------------------------------
798
+ // Constants
799
+ // ---------------------------------------------------------------------------
800
+
801
+ const JIT_ROLE_OPTIONS: readonly {
802
+ readonly value: string;
803
+ readonly label: string;
804
+ }[] = [
805
+ { value: String(IamRole.iam_role_unspecified), label: "Default (viewer)" },
806
+ { value: String(IamRole.viewer), label: "Viewer" },
807
+ { value: String(IamRole.member), label: "Member" },
808
+ { value: String(IamRole.admin), label: "Admin" },
809
+ ];
810
+
811
+ // ---------------------------------------------------------------------------
812
+ // Helpers
813
+ // ---------------------------------------------------------------------------
814
+
815
+ function formatIamRole(role: IamRole): string {
816
+ switch (role) {
817
+ case IamRole.viewer:
818
+ return "Viewer";
819
+ case IamRole.member:
820
+ return "Member";
821
+ case IamRole.admin:
822
+ return "Admin";
823
+ case IamRole.owner:
824
+ return "Owner";
825
+ default:
826
+ return "Viewer (default)";
827
+ }
828
+ }
829
+
830
+ function formatDate(date: Date): string {
831
+ return date.toLocaleDateString(undefined, {
832
+ month: "short",
833
+ day: "numeric",
834
+ year: "numeric",
835
+ });
836
+ }
837
+
838
+ function toDatetimeLocalValue(date: Date): string {
839
+ const pad = (n: number) => String(n).padStart(2, "0");
840
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
841
+ }
842
+
843
+ // ---------------------------------------------------------------------------
844
+ // Icons
845
+ // ---------------------------------------------------------------------------
846
+
847
+ function XIcon() {
848
+ return (
849
+ <svg
850
+ width="10"
851
+ height="10"
852
+ viewBox="0 0 16 16"
853
+ fill="none"
854
+ stroke="currentColor"
855
+ strokeWidth="2"
856
+ strokeLinecap="round"
857
+ aria-hidden="true"
858
+ >
859
+ <path d="M4 4l8 8M12 4l-8 8" />
860
+ </svg>
861
+ );
862
+ }
863
+
864
+ function ArrowLeftIcon() {
865
+ return (
866
+ <svg
867
+ width="12"
868
+ height="12"
869
+ viewBox="0 0 16 16"
870
+ fill="none"
871
+ stroke="currentColor"
872
+ strokeWidth="1.5"
873
+ strokeLinecap="round"
874
+ strokeLinejoin="round"
875
+ aria-hidden="true"
876
+ >
877
+ <path d="M10 3L5 8l5 5" />
878
+ </svg>
879
+ );
880
+ }
881
+
882
+ function SpinnerIcon() {
883
+ return (
884
+ <svg
885
+ width="12"
886
+ height="12"
887
+ viewBox="0 0 16 16"
888
+ fill="none"
889
+ stroke="currentColor"
890
+ strokeWidth="2"
891
+ strokeLinecap="round"
892
+ className="animate-spin"
893
+ aria-hidden="true"
894
+ >
895
+ <path d="M8 2a6 6 0 1 0 6 6" />
896
+ </svg>
897
+ );
898
+ }