@stigmer/react 0.0.89 → 0.0.90

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,519 @@
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 type { PlatformClientCreateResponse } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/io_pb";
13
+ import { useCreatePlatformClient } from "./useCreatePlatformClient";
14
+
15
+ /** Props for {@link CreatePlatformClientForm}. */
16
+ export interface CreatePlatformClientFormProps {
17
+ /** Organization slug — the PlatformClient will be created in this org. */
18
+ readonly org: string;
19
+ /**
20
+ * Fired with the full {@link PlatformClientCreateResponse} on
21
+ * success. The response includes the one-time raw `clientSecret`.
22
+ */
23
+ readonly onCreated?: (response: PlatformClientCreateResponse) => void;
24
+ /** Fired when the user cancels creation. */
25
+ readonly onCancel?: () => void;
26
+ /** Additional CSS class names for the root container. */
27
+ readonly className?: string;
28
+ }
29
+
30
+ /**
31
+ * Form for creating a new platform client within an organization.
32
+ *
33
+ * Collects the required metadata (**name**) and optional spec fields:
34
+ * JIT provisioning toggles, expiry configuration, and allowed
35
+ * origins for CORS.
36
+ *
37
+ * On success it fires `onCreated` with the full
38
+ * {@link PlatformClientCreateResponse}, which includes the one-time
39
+ * raw client secret.
40
+ *
41
+ * All visual properties flow through `--stgm-*` design tokens.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <CreatePlatformClientForm
46
+ * org="acme"
47
+ * onCreated={(resp) => {
48
+ * showSecret(resp.clientSecret);
49
+ * refetch();
50
+ * }}
51
+ * onCancel={() => setShowForm(false)}
52
+ * />
53
+ * ```
54
+ */
55
+ export function CreatePlatformClientForm({
56
+ org,
57
+ onCreated,
58
+ onCancel,
59
+ className,
60
+ }: CreatePlatformClientFormProps) {
61
+ const { create, isCreating, error, clearError } =
62
+ useCreatePlatformClient();
63
+
64
+ const [name, setName] = useState("");
65
+ const [neverExpires, setNeverExpires] = useState(true);
66
+ const [expiresAt, setExpiresAt] = useState("");
67
+
68
+ // JIT provisioning
69
+ const [autoProvision, setAutoProvision] = useState(true);
70
+ const [autoGrant, setAutoGrant] = useState(true);
71
+ const [autoGrantRole, setAutoGrantRole] = useState<IamRole>(
72
+ IamRole.iam_role_unspecified,
73
+ );
74
+
75
+ // CORS
76
+ const [origins, setOrigins] = useState<string[]>([]);
77
+ const [originInput, setOriginInput] = useState("");
78
+
79
+ const handleAutoProvisionChange = useCallback((v: boolean) => {
80
+ setAutoProvision(v);
81
+ if (!v) {
82
+ setAutoGrant(false);
83
+ setAutoGrantRole(IamRole.iam_role_unspecified);
84
+ }
85
+ }, []);
86
+
87
+ const handleAutoGrantChange = useCallback((v: boolean) => {
88
+ setAutoGrant(v);
89
+ if (v) setAutoProvision(true);
90
+ if (!v) setAutoGrantRole(IamRole.iam_role_unspecified);
91
+ }, []);
92
+
93
+ const addOrigin = useCallback(() => {
94
+ const trimmed = originInput.trim();
95
+ if (trimmed && !origins.includes(trimmed)) {
96
+ setOrigins((prev) => [...prev, trimmed]);
97
+ }
98
+ setOriginInput("");
99
+ }, [originInput, origins]);
100
+
101
+ const handleOriginKeyDown = useCallback(
102
+ (e: KeyboardEvent<HTMLInputElement>) => {
103
+ if (e.key === "Enter") {
104
+ e.preventDefault();
105
+ addOrigin();
106
+ }
107
+ },
108
+ [addOrigin],
109
+ );
110
+
111
+ const removeOrigin = useCallback((origin: string) => {
112
+ setOrigins((prev) => prev.filter((o) => o !== origin));
113
+ }, []);
114
+
115
+ const trimmedName = name.trim();
116
+ const canSubmit = trimmedName !== "" && !isCreating;
117
+
118
+ const handleSubmit = useCallback(
119
+ async (e: FormEvent) => {
120
+ e.preventDefault();
121
+ if (!canSubmit) return;
122
+
123
+ clearError();
124
+ try {
125
+ const response = await create({
126
+ name: trimmedName,
127
+ org,
128
+ neverExpires,
129
+ ...(!neverExpires &&
130
+ expiresAt && { expiresAt: new Date(expiresAt).toISOString() }),
131
+ autoProvisionAccounts: autoProvision,
132
+ autoGrantOnOrg: autoGrant,
133
+ ...(autoGrant &&
134
+ autoGrantRole !== IamRole.iam_role_unspecified && {
135
+ autoGrantRole,
136
+ }),
137
+ ...(origins.length > 0 && { allowedOrigins: origins }),
138
+ });
139
+ onCreated?.(response);
140
+ } catch {
141
+ // error state is managed by useCreatePlatformClient
142
+ }
143
+ },
144
+ [
145
+ canSubmit,
146
+ trimmedName,
147
+ org,
148
+ neverExpires,
149
+ expiresAt,
150
+ autoProvision,
151
+ autoGrant,
152
+ autoGrantRole,
153
+ origins,
154
+ create,
155
+ clearError,
156
+ onCreated,
157
+ ],
158
+ );
159
+
160
+ return (
161
+ <form onSubmit={handleSubmit} className={cn("space-y-3", className)}>
162
+ <FormField
163
+ id="stgm-pc-name"
164
+ label="Name"
165
+ value={name}
166
+ onChange={setName}
167
+ placeholder="e.g. my-saas-backend"
168
+ disabled={isCreating}
169
+ required
170
+ />
171
+
172
+ {/* Expiry */}
173
+ <fieldset className="space-y-2" disabled={isCreating}>
174
+ <legend className="text-xs font-medium text-foreground">
175
+ Expiry
176
+ </legend>
177
+ <ToggleSwitch
178
+ checked={neverExpires}
179
+ onChange={setNeverExpires}
180
+ label="Never expires"
181
+ disabled={isCreating}
182
+ />
183
+ {!neverExpires && (
184
+ <div className="space-y-1">
185
+ <label
186
+ htmlFor="stgm-pc-expires-at"
187
+ className="text-xs font-medium text-foreground"
188
+ >
189
+ Expires at
190
+ </label>
191
+ <input
192
+ id="stgm-pc-expires-at"
193
+ type="datetime-local"
194
+ value={expiresAt}
195
+ onChange={(e) => setExpiresAt(e.target.value)}
196
+ disabled={isCreating}
197
+ className={cn(
198
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
199
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
200
+ "disabled:pointer-events-none disabled:opacity-50",
201
+ )}
202
+ />
203
+ </div>
204
+ )}
205
+ </fieldset>
206
+
207
+ {/* JIT provisioning */}
208
+ <fieldset className="space-y-2.5" disabled={isCreating}>
209
+ <hr className="border-border/40" />
210
+ <legend className="text-xs font-medium text-foreground">
211
+ JIT provisioning
212
+ </legend>
213
+ <p className="text-[0.65rem] text-muted-foreground">
214
+ Configure automatic account creation and role assignment for
215
+ users authenticated via this platform client.
216
+ </p>
217
+
218
+ <ToggleSwitch
219
+ checked={autoProvision}
220
+ onChange={handleAutoProvisionChange}
221
+ label="Auto-provision accounts"
222
+ hint="Create a Stigmer identity account automatically on first token mint"
223
+ disabled={isCreating}
224
+ />
225
+
226
+ <ToggleSwitch
227
+ checked={autoGrant}
228
+ onChange={handleAutoGrantChange}
229
+ label="Auto-grant on organization"
230
+ hint="Grant a role on the owning organization when an account is provisioned"
231
+ disabled={isCreating || !autoProvision}
232
+ />
233
+
234
+ {autoGrant && (
235
+ <div className="space-y-1">
236
+ <label
237
+ htmlFor="stgm-pc-grant-role"
238
+ className="text-xs font-medium text-foreground"
239
+ >
240
+ Auto-grant role
241
+ </label>
242
+ <select
243
+ id="stgm-pc-grant-role"
244
+ value={String(autoGrantRole)}
245
+ onChange={(e) =>
246
+ setAutoGrantRole(Number(e.target.value) as IamRole)
247
+ }
248
+ disabled={isCreating}
249
+ className={cn(
250
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
251
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
252
+ "disabled:pointer-events-none disabled:opacity-50",
253
+ )}
254
+ >
255
+ {JIT_ROLE_OPTIONS.map((opt) => (
256
+ <option key={opt.value} value={opt.value}>
257
+ {opt.label}
258
+ </option>
259
+ ))}
260
+ </select>
261
+ <p className="text-[0.65rem] text-muted-foreground">
262
+ Role granted automatically — org admins can upgrade later
263
+ </p>
264
+ </div>
265
+ )}
266
+ </fieldset>
267
+
268
+ {/* Allowed origins */}
269
+ <fieldset className="space-y-2" disabled={isCreating}>
270
+ <hr className="border-border/40" />
271
+ <legend className="text-xs font-medium text-foreground">
272
+ Allowed origins
273
+ </legend>
274
+ <p className="text-[0.65rem] text-muted-foreground">
275
+ Browser origins permitted to use tokens minted by this client.
276
+ Leave empty to allow all origins.
277
+ </p>
278
+
279
+ <div className="flex items-center gap-2">
280
+ <input
281
+ type="text"
282
+ value={originInput}
283
+ onChange={(e) => setOriginInput(e.target.value)}
284
+ onKeyDown={handleOriginKeyDown}
285
+ onBlur={() => {
286
+ if (originInput.trim()) addOrigin();
287
+ }}
288
+ placeholder="https://example.com"
289
+ disabled={isCreating}
290
+ className={cn(
291
+ "min-w-0 flex-1 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
292
+ "placeholder:text-muted-foreground",
293
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
294
+ "disabled:pointer-events-none disabled:opacity-50",
295
+ )}
296
+ />
297
+ <button
298
+ type="button"
299
+ onClick={addOrigin}
300
+ disabled={isCreating || !originInput.trim()}
301
+ className={cn(
302
+ "shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium",
303
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
304
+ "disabled:pointer-events-none disabled:opacity-50",
305
+ "transition-colors",
306
+ )}
307
+ >
308
+ Add
309
+ </button>
310
+ </div>
311
+
312
+ {origins.length > 0 && (
313
+ <div className="flex flex-wrap gap-1.5">
314
+ {origins.map((origin) => (
315
+ <span
316
+ key={origin}
317
+ 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"
318
+ >
319
+ {origin}
320
+ <button
321
+ type="button"
322
+ onClick={() => removeOrigin(origin)}
323
+ disabled={isCreating}
324
+ aria-label={`Remove ${origin}`}
325
+ className="text-muted-foreground hover:text-destructive transition-colors"
326
+ >
327
+ <XIcon />
328
+ </button>
329
+ </span>
330
+ ))}
331
+ </div>
332
+ )}
333
+ </fieldset>
334
+
335
+ {error && (
336
+ <p className="text-destructive text-[0.65rem]" role="alert">
337
+ {getUserMessage(error)}
338
+ </p>
339
+ )}
340
+
341
+ <div className="flex items-center gap-2 pt-1">
342
+ <button
343
+ type="submit"
344
+ disabled={!canSubmit}
345
+ className={cn(
346
+ "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium",
347
+ "bg-primary text-primary-foreground hover:bg-primary/90",
348
+ "disabled:pointer-events-none disabled:opacity-40",
349
+ )}
350
+ >
351
+ {isCreating && <SpinnerIcon />}
352
+ Create platform client
353
+ </button>
354
+
355
+ {onCancel && (
356
+ <button
357
+ type="button"
358
+ onClick={onCancel}
359
+ disabled={isCreating}
360
+ className={cn(
361
+ "rounded-md px-3 py-1.5 text-xs",
362
+ "text-muted-foreground hover:text-foreground hover:bg-accent/50",
363
+ "disabled:pointer-events-none disabled:opacity-50",
364
+ )}
365
+ >
366
+ Cancel
367
+ </button>
368
+ )}
369
+ </div>
370
+ </form>
371
+ );
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Constants
376
+ // ---------------------------------------------------------------------------
377
+
378
+ const JIT_ROLE_OPTIONS: readonly {
379
+ readonly value: string;
380
+ readonly label: string;
381
+ }[] = [
382
+ { value: String(IamRole.iam_role_unspecified), label: "Default (viewer)" },
383
+ { value: String(IamRole.viewer), label: "Viewer" },
384
+ { value: String(IamRole.member), label: "Member" },
385
+ { value: String(IamRole.admin), label: "Admin" },
386
+ ];
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Internal components
390
+ // ---------------------------------------------------------------------------
391
+
392
+ function FormField({
393
+ id,
394
+ label,
395
+ value,
396
+ onChange,
397
+ placeholder,
398
+ hint,
399
+ disabled,
400
+ required,
401
+ }: {
402
+ id: string;
403
+ label: string;
404
+ value: string;
405
+ onChange: (v: string) => void;
406
+ placeholder: string;
407
+ hint?: string;
408
+ disabled: boolean;
409
+ required?: boolean;
410
+ }) {
411
+ return (
412
+ <div className="space-y-1">
413
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
414
+ {label}
415
+ </label>
416
+ <input
417
+ id={id}
418
+ type="text"
419
+ value={value}
420
+ onChange={(e) => onChange(e.target.value)}
421
+ placeholder={placeholder}
422
+ disabled={disabled}
423
+ required={required}
424
+ className={cn(
425
+ "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
426
+ "placeholder:text-muted-foreground",
427
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
428
+ "disabled:pointer-events-none disabled:opacity-50",
429
+ )}
430
+ />
431
+ {hint && (
432
+ <p className="text-[0.65rem] text-muted-foreground">{hint}</p>
433
+ )}
434
+ </div>
435
+ );
436
+ }
437
+
438
+ function ToggleSwitch({
439
+ checked,
440
+ onChange,
441
+ label,
442
+ hint,
443
+ disabled,
444
+ }: {
445
+ checked: boolean;
446
+ onChange: (v: boolean) => void;
447
+ label: string;
448
+ hint?: string;
449
+ disabled?: boolean;
450
+ }) {
451
+ return (
452
+ <div className="space-y-0.5">
453
+ <div className="flex items-center gap-2">
454
+ <button
455
+ type="button"
456
+ role="switch"
457
+ aria-checked={checked}
458
+ onClick={() => onChange(!checked)}
459
+ disabled={disabled}
460
+ className={cn(
461
+ "relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
462
+ checked ? "bg-primary" : "bg-muted",
463
+ "disabled:pointer-events-none disabled:opacity-50",
464
+ )}
465
+ >
466
+ <span
467
+ className={cn(
468
+ "pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm ring-0 transition-transform",
469
+ checked ? "translate-x-4" : "translate-x-0",
470
+ )}
471
+ />
472
+ </button>
473
+ <span className="text-xs font-medium text-foreground">{label}</span>
474
+ </div>
475
+ {hint && (
476
+ <p className="pl-11 text-[0.65rem] text-muted-foreground">{hint}</p>
477
+ )}
478
+ </div>
479
+ );
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Icons
484
+ // ---------------------------------------------------------------------------
485
+
486
+ function XIcon() {
487
+ return (
488
+ <svg
489
+ width="10"
490
+ height="10"
491
+ viewBox="0 0 16 16"
492
+ fill="none"
493
+ stroke="currentColor"
494
+ strokeWidth="2"
495
+ strokeLinecap="round"
496
+ aria-hidden="true"
497
+ >
498
+ <path d="M4 4l8 8M12 4l-8 8" />
499
+ </svg>
500
+ );
501
+ }
502
+
503
+ function SpinnerIcon() {
504
+ return (
505
+ <svg
506
+ width="12"
507
+ height="12"
508
+ viewBox="0 0 16 16"
509
+ fill="none"
510
+ stroke="currentColor"
511
+ strokeWidth="2"
512
+ strokeLinecap="round"
513
+ className="animate-spin"
514
+ aria-hidden="true"
515
+ >
516
+ <path d="M8 2a6 6 0 1 0 6 6" />
517
+ </svg>
518
+ );
519
+ }