@clef-sh/ui 0.1.13-beta.88

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 (70) hide show
  1. package/README.md +38 -0
  2. package/dist/client/assets/index-CVpAmirt.js +26 -0
  3. package/dist/client/favicon-96x96.png +0 -0
  4. package/dist/client/favicon.ico +0 -0
  5. package/dist/client/favicon.svg +16 -0
  6. package/dist/client/index.html +50 -0
  7. package/dist/client-lib/api.d.ts +3 -0
  8. package/dist/client-lib/api.d.ts.map +1 -0
  9. package/dist/client-lib/components/Button.d.ts +10 -0
  10. package/dist/client-lib/components/Button.d.ts.map +1 -0
  11. package/dist/client-lib/components/CopyButton.d.ts +6 -0
  12. package/dist/client-lib/components/CopyButton.d.ts.map +1 -0
  13. package/dist/client-lib/components/EnvBadge.d.ts +7 -0
  14. package/dist/client-lib/components/EnvBadge.d.ts.map +1 -0
  15. package/dist/client-lib/components/MatrixGrid.d.ts +13 -0
  16. package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -0
  17. package/dist/client-lib/components/Sidebar.d.ts +16 -0
  18. package/dist/client-lib/components/Sidebar.d.ts.map +1 -0
  19. package/dist/client-lib/components/StatusDot.d.ts +6 -0
  20. package/dist/client-lib/components/StatusDot.d.ts.map +1 -0
  21. package/dist/client-lib/components/TopBar.d.ts +9 -0
  22. package/dist/client-lib/components/TopBar.d.ts.map +1 -0
  23. package/dist/client-lib/index.d.ts +12 -0
  24. package/dist/client-lib/index.d.ts.map +1 -0
  25. package/dist/client-lib/theme.d.ts +42 -0
  26. package/dist/client-lib/theme.d.ts.map +1 -0
  27. package/dist/server/api.d.ts +11 -0
  28. package/dist/server/api.d.ts.map +1 -0
  29. package/dist/server/api.js +1020 -0
  30. package/dist/server/api.js.map +1 -0
  31. package/dist/server/index.d.ts +12 -0
  32. package/dist/server/index.d.ts.map +1 -0
  33. package/dist/server/index.js +231 -0
  34. package/dist/server/index.js.map +1 -0
  35. package/package.json +74 -0
  36. package/src/client/App.tsx +205 -0
  37. package/src/client/api.test.tsx +94 -0
  38. package/src/client/api.ts +30 -0
  39. package/src/client/components/Button.tsx +52 -0
  40. package/src/client/components/CopyButton.test.tsx +43 -0
  41. package/src/client/components/CopyButton.tsx +36 -0
  42. package/src/client/components/EnvBadge.tsx +32 -0
  43. package/src/client/components/MatrixGrid.tsx +265 -0
  44. package/src/client/components/Sidebar.tsx +337 -0
  45. package/src/client/components/StatusDot.tsx +30 -0
  46. package/src/client/components/TopBar.tsx +50 -0
  47. package/src/client/index.html +50 -0
  48. package/src/client/index.ts +18 -0
  49. package/src/client/main.tsx +15 -0
  50. package/src/client/public/favicon-96x96.png +0 -0
  51. package/src/client/public/favicon.ico +0 -0
  52. package/src/client/public/favicon.svg +16 -0
  53. package/src/client/screens/BackendScreen.test.tsx +611 -0
  54. package/src/client/screens/BackendScreen.tsx +836 -0
  55. package/src/client/screens/DiffView.test.tsx +130 -0
  56. package/src/client/screens/DiffView.tsx +547 -0
  57. package/src/client/screens/GitLogView.test.tsx +113 -0
  58. package/src/client/screens/GitLogView.tsx +192 -0
  59. package/src/client/screens/ImportScreen.tsx +710 -0
  60. package/src/client/screens/LintView.test.tsx +143 -0
  61. package/src/client/screens/LintView.tsx +589 -0
  62. package/src/client/screens/MatrixView.test.tsx +138 -0
  63. package/src/client/screens/MatrixView.tsx +143 -0
  64. package/src/client/screens/NamespaceEditor.test.tsx +694 -0
  65. package/src/client/screens/NamespaceEditor.tsx +1122 -0
  66. package/src/client/screens/RecipientsScreen.tsx +696 -0
  67. package/src/client/screens/ScanScreen.test.tsx +323 -0
  68. package/src/client/screens/ScanScreen.tsx +523 -0
  69. package/src/client/screens/ServiceIdentitiesScreen.tsx +1398 -0
  70. package/src/client/theme.ts +48 -0
@@ -0,0 +1,1398 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { theme } from "../theme";
3
+ import { apiFetch } from "../api";
4
+ import { TopBar } from "../components/TopBar";
5
+ import { EnvBadge } from "../components/EnvBadge";
6
+ import { Button } from "../components/Button";
7
+ import { CopyButton } from "../components/CopyButton";
8
+ import type { ClefManifest } from "@clef-sh/core";
9
+
10
+ interface EnvInfo {
11
+ type: string;
12
+ publicKey?: string;
13
+ kms?: { provider: string; keyId: string };
14
+ protected?: boolean;
15
+ }
16
+
17
+ interface IdentityInfo {
18
+ name: string;
19
+ description: string;
20
+ namespaces: string[];
21
+ environments: Record<string, EnvInfo>;
22
+ }
23
+
24
+ interface EnvBackendConfig {
25
+ type: "age" | "kms";
26
+ provider: string;
27
+ keyId: string;
28
+ }
29
+
30
+ interface UpdateEnvState extends EnvBackendConfig {
31
+ originalType: "age" | "kms";
32
+ originalKeyId: string;
33
+ }
34
+
35
+ interface ServiceIdentitiesScreenProps {
36
+ manifest: ClefManifest | null;
37
+ }
38
+
39
+ type View = "list" | "detail" | "create" | "keys" | "update" | "rotate-keys" | "delete-confirm";
40
+
41
+ export function ServiceIdentitiesScreen({ manifest }: ServiceIdentitiesScreenProps) {
42
+ const [view, setView] = useState<View>("list");
43
+ const [identities, setIdentities] = useState<IdentityInfo[]>([]);
44
+ const [selected, setSelected] = useState<string | null>(null);
45
+ const [error, setError] = useState("");
46
+
47
+ // Create form state
48
+ const [name, setName] = useState("");
49
+ const [description, setDescription] = useState("");
50
+ const [selectedNamespaces, setSelectedNamespaces] = useState<Set<string>>(new Set());
51
+ const [envBackends, setEnvBackends] = useState<Record<string, EnvBackendConfig>>({});
52
+ const [creating, setCreating] = useState(false);
53
+ const [createError, setCreateError] = useState("");
54
+
55
+ // Post-create / post-rotate keys
56
+ const [privateKeys, setPrivateKeys] = useState<Record<string, string>>({});
57
+ const [createdName, setCreatedName] = useState("");
58
+
59
+ // Update form state
60
+ const [updateEnvBackends, setUpdateEnvBackends] = useState<Record<string, UpdateEnvState>>({});
61
+ const [updating, setUpdating] = useState(false);
62
+ const [updateError, setUpdateError] = useState("");
63
+
64
+ // Rotate state
65
+ const [rotatingEnv, setRotatingEnv] = useState<string | undefined>(undefined);
66
+ const [rotatedKeys, setRotatedKeys] = useState<Record<string, string>>({});
67
+
68
+ // Delete state
69
+ const [deleting, setDeleting] = useState(false);
70
+ const [deleteError, setDeleteError] = useState("");
71
+
72
+ const load = useCallback(async () => {
73
+ try {
74
+ const res = await apiFetch("/api/service-identities");
75
+ if (res.ok) {
76
+ const data = await res.json();
77
+ setIdentities(data.identities);
78
+ }
79
+ } catch {
80
+ // Silently fail
81
+ }
82
+ }, []);
83
+
84
+ useEffect(() => {
85
+ load();
86
+ }, [load]);
87
+
88
+ const selectedIdentity = identities.find((i) => i.name === selected);
89
+
90
+ const openCreate = useCallback(() => {
91
+ setName("");
92
+ setDescription("");
93
+ setSelectedNamespaces(new Set());
94
+ const defaults: Record<string, EnvBackendConfig> = {};
95
+ for (const env of manifest?.environments ?? []) {
96
+ defaults[env.name] = { type: "age", provider: "aws", keyId: "" };
97
+ }
98
+ setEnvBackends(defaults);
99
+ setCreateError("");
100
+ setView("create");
101
+ }, [manifest]);
102
+
103
+ const openUpdate = useCallback((identity: IdentityInfo) => {
104
+ const defaults: Record<string, UpdateEnvState> = {};
105
+ for (const [envName, envInfo] of Object.entries(identity.environments)) {
106
+ const t: "age" | "kms" = envInfo.type === "kms" ? "kms" : "age";
107
+ defaults[envName] = {
108
+ type: t,
109
+ provider: envInfo.kms?.provider ?? "aws",
110
+ keyId: envInfo.kms?.keyId ?? "",
111
+ originalType: t,
112
+ originalKeyId: envInfo.kms?.keyId ?? "",
113
+ };
114
+ }
115
+ setUpdateEnvBackends(defaults);
116
+ setUpdateError("");
117
+ setView("update");
118
+ }, []);
119
+
120
+ const goList = useCallback(() => {
121
+ setSelected(null);
122
+ setError("");
123
+ setView("list");
124
+ }, []);
125
+
126
+ const goDetail = useCallback(() => {
127
+ setError("");
128
+ setDeleteError("");
129
+ setView("detail");
130
+ }, []);
131
+
132
+ // ── Handlers ──────────────────────────────────────────────────────────────────
133
+
134
+ async function handleCreate() {
135
+ setCreating(true);
136
+ setCreateError("");
137
+ try {
138
+ const kmsEnvConfigs: Record<string, { provider: string; keyId: string }> = {};
139
+ for (const [envName, cfg] of Object.entries(envBackends)) {
140
+ if (cfg.type === "kms") {
141
+ kmsEnvConfigs[envName] = { provider: cfg.provider, keyId: cfg.keyId };
142
+ }
143
+ }
144
+ const body: Record<string, unknown> = {
145
+ name: name.trim(),
146
+ description: description.trim(),
147
+ namespaces: Array.from(selectedNamespaces),
148
+ };
149
+ if (Object.keys(kmsEnvConfigs).length > 0) {
150
+ body.kmsEnvConfigs = kmsEnvConfigs;
151
+ }
152
+ const res = await apiFetch("/api/service-identities", {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json" },
155
+ body: JSON.stringify(body),
156
+ });
157
+ const data = await res.json();
158
+ if (!res.ok) {
159
+ setCreateError(data.error ?? "Failed to create service identity.");
160
+ return;
161
+ }
162
+ setCreatedName(data.identity.name);
163
+ setPrivateKeys(data.privateKeys ?? {});
164
+ setView("keys");
165
+ } catch {
166
+ setCreateError("Network error. Check that the UI server is running.");
167
+ } finally {
168
+ setCreating(false);
169
+ }
170
+ }
171
+
172
+ async function handleUpdate() {
173
+ if (!selected) return;
174
+ setUpdating(true);
175
+ setUpdateError("");
176
+ try {
177
+ const kmsEnvConfigs: Record<string, { provider: string; keyId: string }> = {};
178
+ for (const [envName, state] of Object.entries(updateEnvBackends)) {
179
+ if (state.type === "kms") {
180
+ if (state.originalType !== "kms" || state.keyId !== state.originalKeyId) {
181
+ kmsEnvConfigs[envName] = { provider: state.provider, keyId: state.keyId };
182
+ }
183
+ }
184
+ }
185
+ if (Object.keys(kmsEnvConfigs).length === 0) {
186
+ setUpdateError("No changes to apply.");
187
+ return;
188
+ }
189
+ const res = await apiFetch(`/api/service-identities/${encodeURIComponent(selected)}`, {
190
+ method: "PATCH",
191
+ headers: { "Content-Type": "application/json" },
192
+ body: JSON.stringify({ kmsEnvConfigs }),
193
+ });
194
+ const data = await res.json();
195
+ if (!res.ok) {
196
+ setUpdateError(data.error ?? "Failed to update service identity.");
197
+ return;
198
+ }
199
+ await load();
200
+ goDetail();
201
+ } catch {
202
+ setUpdateError("Network error. Check that the UI server is running.");
203
+ } finally {
204
+ setUpdating(false);
205
+ }
206
+ }
207
+
208
+ async function handleRotate(envName: string) {
209
+ if (!selected) return;
210
+ setRotatingEnv(envName);
211
+ setError("");
212
+ try {
213
+ const res = await apiFetch(`/api/service-identities/${encodeURIComponent(selected)}/rotate`, {
214
+ method: "POST",
215
+ headers: { "Content-Type": "application/json" },
216
+ body: JSON.stringify({ environment: envName }),
217
+ });
218
+ const data = await res.json();
219
+ if (!res.ok) {
220
+ setError(data.error ?? "Failed to rotate key.");
221
+ return;
222
+ }
223
+ setRotatedKeys(data.privateKeys ?? {});
224
+ await load();
225
+ setView("rotate-keys");
226
+ } catch {
227
+ setError("Network error. Check that the UI server is running.");
228
+ } finally {
229
+ setRotatingEnv(undefined);
230
+ }
231
+ }
232
+
233
+ async function handleDelete() {
234
+ if (!selected) return;
235
+ setDeleting(true);
236
+ setDeleteError("");
237
+ try {
238
+ const res = await apiFetch(`/api/service-identities/${encodeURIComponent(selected)}`, {
239
+ method: "DELETE",
240
+ });
241
+ if (!res.ok) {
242
+ const data = await res.json();
243
+ setDeleteError(data.error ?? "Failed to delete service identity.");
244
+ return;
245
+ }
246
+ await load();
247
+ goList();
248
+ } catch {
249
+ setDeleteError("Network error. Check that the UI server is running.");
250
+ } finally {
251
+ setDeleting(false);
252
+ }
253
+ }
254
+
255
+ // ── List view ─────────────────────────────────────────────────────────────────
256
+ if (view === "list") {
257
+ return (
258
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
259
+ <TopBar
260
+ title="Service Identities"
261
+ subtitle="Per-service cryptographic access scoping"
262
+ actions={
263
+ manifest && (
264
+ <Button variant="primary" onClick={openCreate}>
265
+ + New identity
266
+ </Button>
267
+ )
268
+ }
269
+ />
270
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
271
+ <div style={{ maxWidth: 620, margin: "0 auto" }}>
272
+ {error && <ErrorBanner>{error}</ErrorBanner>}
273
+
274
+ {identities.length === 0 && (
275
+ <div
276
+ style={{
277
+ textAlign: "center",
278
+ padding: "48px 24px",
279
+ color: theme.textMuted,
280
+ fontFamily: theme.sans,
281
+ fontSize: 13,
282
+ }}
283
+ >
284
+ <div style={{ fontSize: 28, marginBottom: 12, opacity: 0.4 }}>{"\uD83D\uDD11"}</div>
285
+ No service identities configured.
286
+ {manifest && (
287
+ <div style={{ marginTop: 16 }}>
288
+ <Button variant="primary" onClick={openCreate}>
289
+ Create the first one
290
+ </Button>
291
+ </div>
292
+ )}
293
+ </div>
294
+ )}
295
+
296
+ {identities.map((si) => (
297
+ <div
298
+ key={si.name}
299
+ role="button"
300
+ tabIndex={0}
301
+ data-testid={`si-${si.name}`}
302
+ onClick={() => {
303
+ setSelected(si.name);
304
+ setError("");
305
+ setView("detail");
306
+ }}
307
+ onKeyDown={(e) => {
308
+ if (e.key === "Enter") {
309
+ setSelected(si.name);
310
+ setView("detail");
311
+ }
312
+ }}
313
+ style={{
314
+ background: theme.surface,
315
+ border: `1px solid ${theme.border}`,
316
+ borderRadius: 8,
317
+ padding: "16px 20px",
318
+ marginBottom: 8,
319
+ cursor: "pointer",
320
+ transition: "all 0.12s",
321
+ }}
322
+ onMouseEnter={(e) => {
323
+ (e.currentTarget as HTMLDivElement).style.borderColor = theme.borderLight;
324
+ }}
325
+ onMouseLeave={(e) => {
326
+ (e.currentTarget as HTMLDivElement).style.borderColor = theme.border;
327
+ }}
328
+ >
329
+ <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
330
+ <span
331
+ style={{
332
+ fontFamily: theme.sans,
333
+ fontWeight: 600,
334
+ fontSize: 14,
335
+ color: theme.text,
336
+ }}
337
+ >
338
+ {si.name}
339
+ </span>
340
+ </div>
341
+ <div
342
+ style={{
343
+ fontFamily: theme.sans,
344
+ fontSize: 12,
345
+ color: theme.textMuted,
346
+ marginBottom: 10,
347
+ }}
348
+ >
349
+ Scoped to: <span style={{ color: theme.text }}>{si.namespaces.join(", ")}</span>
350
+ </div>
351
+ <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
352
+ {Object.entries(si.environments).map(([envName, envInfo]) => (
353
+ <span
354
+ key={envName}
355
+ style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
356
+ >
357
+ <EnvBadge env={envName} small />
358
+ <span
359
+ style={{
360
+ fontFamily: theme.mono,
361
+ fontSize: 9,
362
+ color: envInfo.type === "kms" ? theme.purple : theme.textDim,
363
+ }}
364
+ >
365
+ {envInfo.type === "kms" ? "KMS" : "age"}
366
+ </span>
367
+ </span>
368
+ ))}
369
+ </div>
370
+ </div>
371
+ ))}
372
+ </div>
373
+ </div>
374
+ </div>
375
+ );
376
+ }
377
+
378
+ // ── Detail view ───────────────────────────────────────────────────────────────
379
+ if (view === "detail") {
380
+ return (
381
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
382
+ <TopBar
383
+ title={selectedIdentity?.name ?? selected ?? ""}
384
+ subtitle={selectedIdentity?.description}
385
+ actions={
386
+ <div style={{ display: "flex", gap: 6 }}>
387
+ {selectedIdentity && (
388
+ <Button
389
+ data-testid="update-backends-btn"
390
+ variant="ghost"
391
+ onClick={() => openUpdate(selectedIdentity)}
392
+ >
393
+ Update backends
394
+ </Button>
395
+ )}
396
+ <button
397
+ data-testid="back-button"
398
+ onClick={goList}
399
+ style={{
400
+ background: "none",
401
+ border: `1px solid ${theme.borderLight}`,
402
+ borderRadius: 6,
403
+ padding: "4px 12px",
404
+ cursor: "pointer",
405
+ fontFamily: theme.sans,
406
+ fontSize: 12,
407
+ color: theme.textMuted,
408
+ transition: "all 0.12s",
409
+ }}
410
+ >
411
+ {"\u2190"} Back
412
+ </button>
413
+ </div>
414
+ }
415
+ />
416
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
417
+ <div style={{ maxWidth: 620, margin: "0 auto" }}>
418
+ {error && <ErrorBanner>{error}</ErrorBanner>}
419
+
420
+ {selectedIdentity && (
421
+ <>
422
+ <div style={{ marginBottom: 20 }}>
423
+ <Label>Scoped namespaces</Label>
424
+ <div style={{ display: "flex", gap: 6 }}>
425
+ {selectedIdentity.namespaces.map((ns) => (
426
+ <span
427
+ key={ns}
428
+ style={{
429
+ fontFamily: theme.mono,
430
+ fontSize: 11,
431
+ color: theme.accent,
432
+ background: theme.accentDim,
433
+ border: `1px solid ${theme.accent}33`,
434
+ borderRadius: 4,
435
+ padding: "2px 8px",
436
+ }}
437
+ >
438
+ {ns}
439
+ </span>
440
+ ))}
441
+ </div>
442
+ </div>
443
+
444
+ <Label>Environment keys</Label>
445
+
446
+ {manifest?.environments.map((env) => {
447
+ const envInfo = selectedIdentity.environments[env.name];
448
+ if (!envInfo) return null;
449
+ const isProtected = envInfo.protected ?? false;
450
+ const isRotating = rotatingEnv === env.name;
451
+
452
+ return (
453
+ <div
454
+ key={env.name}
455
+ data-testid={`env-${env.name}`}
456
+ style={{
457
+ background: theme.surface,
458
+ border: `1px solid ${theme.border}`,
459
+ borderRadius: 8,
460
+ padding: "16px 20px",
461
+ marginBottom: 10,
462
+ }}
463
+ >
464
+ <div
465
+ style={{
466
+ display: "flex",
467
+ alignItems: "center",
468
+ justifyContent: "space-between",
469
+ marginBottom: 12,
470
+ }}
471
+ >
472
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
473
+ <EnvBadge env={env.name} />
474
+ {isProtected && (
475
+ <span style={{ fontSize: 12, color: theme.red }}>{"\uD83D\uDD12"}</span>
476
+ )}
477
+ {envInfo.type === "kms" && (
478
+ <span
479
+ style={{
480
+ fontFamily: theme.mono,
481
+ fontSize: 10,
482
+ color: theme.purple,
483
+ background: theme.purpleDim,
484
+ border: `1px solid ${theme.purple}33`,
485
+ borderRadius: 3,
486
+ padding: "1px 6px",
487
+ }}
488
+ >
489
+ KMS
490
+ </span>
491
+ )}
492
+ </div>
493
+ {envInfo.type === "age" && (
494
+ <button
495
+ data-testid={`rotate-${env.name}`}
496
+ disabled={isRotating}
497
+ onClick={() => handleRotate(env.name)}
498
+ style={{
499
+ background: "none",
500
+ border: `1px solid ${theme.borderLight}`,
501
+ borderRadius: 5,
502
+ padding: "3px 10px",
503
+ cursor: isRotating ? "default" : "pointer",
504
+ fontFamily: theme.sans,
505
+ fontSize: 11,
506
+ color: isRotating ? theme.textDim : theme.textMuted,
507
+ opacity: isRotating ? 0.5 : 1,
508
+ }}
509
+ >
510
+ {isRotating ? "Rotating…" : "Rotate key"}
511
+ </button>
512
+ )}
513
+ </div>
514
+
515
+ {envInfo.type === "kms" && envInfo.kms && (
516
+ <div
517
+ style={{ fontFamily: theme.mono, fontSize: 11, color: theme.textMuted }}
518
+ >
519
+ <div style={{ marginBottom: 8 }}>
520
+ Authentication: <span style={{ color: theme.purple }}>IAM + KMS</span>
521
+ </div>
522
+ <div>
523
+ Provider:{" "}
524
+ <span style={{ color: theme.text }}>{envInfo.kms.provider}</span>
525
+ </div>
526
+ <div style={{ marginTop: 4 }}>
527
+ Key ID:{" "}
528
+ <span style={{ color: theme.text, wordBreak: "break-all" }}>
529
+ {envInfo.kms.keyId}
530
+ </span>
531
+ </div>
532
+ <div
533
+ style={{
534
+ marginTop: 10,
535
+ padding: "8px 12px",
536
+ background: theme.purpleDim,
537
+ border: `1px solid ${theme.purple}33`,
538
+ borderRadius: 4,
539
+ fontSize: 11,
540
+ color: theme.purple,
541
+ fontFamily: theme.sans,
542
+ }}
543
+ >
544
+ No keys to provision. CI and runtime authenticate via IAM role with
545
+ kms:Decrypt permission.
546
+ </div>
547
+ </div>
548
+ )}
549
+
550
+ {envInfo.type === "age" && (
551
+ <div
552
+ style={{ fontFamily: theme.mono, fontSize: 11, color: theme.textMuted }}
553
+ >
554
+ <div style={{ marginBottom: 8 }}>
555
+ Authentication: <span style={{ color: theme.green }}>age key</span>
556
+ </div>
557
+ <div>
558
+ Public key:{" "}
559
+ <span style={{ color: theme.text }}>
560
+ {envInfo.publicKey
561
+ ? `${envInfo.publicKey.slice(0, 12)}...${envInfo.publicKey.slice(-6)}`
562
+ : "unknown"}
563
+ </span>
564
+ </div>
565
+ </div>
566
+ )}
567
+ </div>
568
+ );
569
+ })}
570
+
571
+ <div
572
+ style={{
573
+ marginTop: 32,
574
+ paddingTop: 20,
575
+ borderTop: `1px solid ${theme.border}`,
576
+ display: "flex",
577
+ justifyContent: "flex-end",
578
+ }}
579
+ >
580
+ <Button
581
+ data-testid="delete-identity-btn"
582
+ variant="danger"
583
+ onClick={() => {
584
+ setDeleteError("");
585
+ setView("delete-confirm");
586
+ }}
587
+ >
588
+ Delete identity
589
+ </Button>
590
+ </div>
591
+ </>
592
+ )}
593
+ </div>
594
+ </div>
595
+ </div>
596
+ );
597
+ }
598
+
599
+ // ── Delete confirm view ───────────────────────────────────────────────────────
600
+ if (view === "delete-confirm") {
601
+ return (
602
+ <div
603
+ data-testid="delete-confirm-view"
604
+ style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
605
+ >
606
+ <TopBar title="Delete service identity" subtitle="This action cannot be undone" />
607
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
608
+ <div style={{ maxWidth: 560, margin: "0 auto" }}>
609
+ {deleteError && <ErrorBanner>{deleteError}</ErrorBanner>}
610
+
611
+ <div
612
+ style={{
613
+ background: "#1a0a0a",
614
+ border: `1px solid ${theme.red}55`,
615
+ borderRadius: 8,
616
+ padding: "16px 20px",
617
+ marginBottom: 24,
618
+ fontFamily: theme.sans,
619
+ fontSize: 13,
620
+ color: theme.red,
621
+ }}
622
+ >
623
+ <div style={{ fontWeight: 600, marginBottom: 8 }}>
624
+ Delete <span style={{ fontFamily: theme.mono }}>{selected}</span>?
625
+ </div>
626
+ <div style={{ color: theme.textMuted, fontSize: 12, lineHeight: 1.6 }}>
627
+ This will remove the identity from{" "}
628
+ <span style={{ fontFamily: theme.mono }}>clef.yaml</span> and de-register its
629
+ recipients from all scoped encrypted files. Any runtimes currently using this
630
+ identity's private key will lose access on the next artifact refresh.
631
+ </div>
632
+ </div>
633
+
634
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
635
+ <Button
636
+ data-testid="cancel-delete-btn"
637
+ variant="ghost"
638
+ onClick={goDetail}
639
+ disabled={deleting}
640
+ >
641
+ Cancel
642
+ </Button>
643
+ <Button
644
+ data-testid="confirm-delete-btn"
645
+ variant="danger"
646
+ onClick={handleDelete}
647
+ disabled={deleting}
648
+ >
649
+ {deleting ? "Deleting…" : "Delete identity"}
650
+ </Button>
651
+ </div>
652
+ </div>
653
+ </div>
654
+ </div>
655
+ );
656
+ }
657
+
658
+ // ── Rotate keys result view ───────────────────────────────────────────────────
659
+ if (view === "rotate-keys") {
660
+ return (
661
+ <div
662
+ data-testid="rotate-keys-view"
663
+ style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
664
+ >
665
+ <TopBar title="Key rotated" subtitle={`New keys for ${selected}`} />
666
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
667
+ <div style={{ maxWidth: 620, margin: "0 auto" }}>
668
+ <div
669
+ style={{
670
+ background: "#1a1200",
671
+ border: `1px solid ${theme.yellow}55`,
672
+ borderRadius: 8,
673
+ padding: "14px 18px",
674
+ marginBottom: 20,
675
+ fontFamily: theme.sans,
676
+ fontSize: 13,
677
+ color: theme.yellow,
678
+ display: "flex",
679
+ gap: 10,
680
+ alignItems: "flex-start",
681
+ }}
682
+ >
683
+ <span style={{ fontSize: 16, flexShrink: 0 }}>⚠</span>
684
+ <span>
685
+ Copy the new private key now — it will not be shown again. Provision it to the
686
+ runtime and invalidate the old key.
687
+ </span>
688
+ </div>
689
+
690
+ <Label>New private keys</Label>
691
+ {Object.entries(rotatedKeys).map(([envName, key]) => (
692
+ <div
693
+ key={envName}
694
+ style={{
695
+ background: theme.surface,
696
+ border: `1px solid ${theme.border}`,
697
+ borderRadius: 8,
698
+ padding: "14px 18px",
699
+ marginBottom: 10,
700
+ }}
701
+ >
702
+ <div
703
+ style={{
704
+ display: "flex",
705
+ alignItems: "center",
706
+ justifyContent: "space-between",
707
+ marginBottom: 10,
708
+ }}
709
+ >
710
+ <EnvBadge env={envName} />
711
+ <CopyButton text={key} />
712
+ </div>
713
+ <div
714
+ style={{
715
+ fontFamily: theme.mono,
716
+ fontSize: 11,
717
+ color: theme.textMuted,
718
+ wordBreak: "break-all",
719
+ background: theme.bg,
720
+ borderRadius: 4,
721
+ padding: "8px 10px",
722
+ }}
723
+ >
724
+ {key}
725
+ </div>
726
+ </div>
727
+ ))}
728
+
729
+ <div style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
730
+ <Button data-testid="rotate-done-btn" variant="primary" onClick={goDetail}>
731
+ Done
732
+ </Button>
733
+ </div>
734
+ </div>
735
+ </div>
736
+ </div>
737
+ );
738
+ }
739
+
740
+ // ── Update backends view ──────────────────────────────────────────────────────
741
+ if (view === "update") {
742
+ const environments = manifest?.environments ?? [];
743
+
744
+ const changedCount = Object.values(updateEnvBackends).filter((state) => {
745
+ if (state.type !== "kms") return false;
746
+ return state.originalType !== "kms" || state.keyId !== state.originalKeyId;
747
+ }).length;
748
+
749
+ const canUpdate =
750
+ changedCount > 0 &&
751
+ Object.entries(updateEnvBackends).every(([, state]) => {
752
+ if (state.type !== "kms") return true;
753
+ return state.keyId.trim() !== "";
754
+ });
755
+
756
+ return (
757
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
758
+ <TopBar
759
+ title="Update backends"
760
+ subtitle={`Environment backends for ${selected}`}
761
+ actions={
762
+ <button
763
+ onClick={goDetail}
764
+ style={{
765
+ background: "none",
766
+ border: `1px solid ${theme.borderLight}`,
767
+ borderRadius: 6,
768
+ padding: "4px 12px",
769
+ cursor: "pointer",
770
+ fontFamily: theme.sans,
771
+ fontSize: 12,
772
+ color: theme.textMuted,
773
+ }}
774
+ >
775
+ {"\u2190"} Cancel
776
+ </button>
777
+ }
778
+ />
779
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
780
+ <div style={{ maxWidth: 560, margin: "0 auto" }}>
781
+ {updateError && <ErrorBanner>{updateError}</ErrorBanner>}
782
+
783
+ <div
784
+ style={{
785
+ fontFamily: theme.sans,
786
+ fontSize: 12,
787
+ color: theme.textMuted,
788
+ marginBottom: 16,
789
+ lineHeight: 1.6,
790
+ }}
791
+ >
792
+ Switch age environments to KMS, or update an existing KMS key ID. To revert KMS to
793
+ age, delete and recreate the identity.
794
+ </div>
795
+
796
+ <div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 28 }}>
797
+ {environments.map((env) => {
798
+ const state = updateEnvBackends[env.name];
799
+ if (!state) return null;
800
+
801
+ return (
802
+ <div
803
+ key={env.name}
804
+ style={{
805
+ background: theme.surface,
806
+ border: `1px solid ${theme.border}`,
807
+ borderRadius: 8,
808
+ padding: "14px 16px",
809
+ }}
810
+ >
811
+ <div
812
+ style={{
813
+ display: "flex",
814
+ alignItems: "center",
815
+ justifyContent: "space-between",
816
+ marginBottom: state.type === "kms" ? 12 : 0,
817
+ }}
818
+ >
819
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
820
+ <EnvBadge env={env.name} />
821
+ {env.protected && (
822
+ <span style={{ fontSize: 11, color: theme.red }}>{"\uD83D\uDD12"}</span>
823
+ )}
824
+ </div>
825
+ <div style={{ display: "flex", gap: 4 }}>
826
+ {(["age", "kms"] as const).map((t) => {
827
+ const locked = state.originalType === "kms" && t === "age";
828
+ return (
829
+ <button
830
+ key={t}
831
+ data-testid={
832
+ t === "kms" ? `update-kms-toggle-${env.name}` : undefined
833
+ }
834
+ disabled={locked}
835
+ onClick={() => {
836
+ if (locked) return;
837
+ setUpdateEnvBackends((prev) => ({
838
+ ...prev,
839
+ [env.name]: { ...state, type: t },
840
+ }));
841
+ }}
842
+ title={locked ? "KMS → age requires delete and recreate" : undefined}
843
+ style={{
844
+ background:
845
+ state.type === t
846
+ ? t === "kms"
847
+ ? theme.purple
848
+ : theme.accent
849
+ : "transparent",
850
+ border: `1px solid ${
851
+ state.type === t
852
+ ? t === "kms"
853
+ ? theme.purple
854
+ : theme.accent
855
+ : theme.border
856
+ }`,
857
+ borderRadius: 4,
858
+ padding: "3px 10px",
859
+ cursor: locked ? "not-allowed" : "pointer",
860
+ fontFamily: theme.mono,
861
+ fontSize: 11,
862
+ color: state.type === t ? "#fff" : theme.textMuted,
863
+ opacity: locked ? 0.4 : 1,
864
+ transition: "all 0.1s",
865
+ }}
866
+ >
867
+ {t.toUpperCase()}
868
+ </button>
869
+ );
870
+ })}
871
+ </div>
872
+ </div>
873
+
874
+ {state.type === "kms" && (
875
+ <div style={{ display: "flex", gap: 8 }}>
876
+ <select
877
+ value={state.provider}
878
+ onChange={(e) =>
879
+ setUpdateEnvBackends((prev) => ({
880
+ ...prev,
881
+ [env.name]: { ...state, provider: e.target.value },
882
+ }))
883
+ }
884
+ style={{
885
+ ...inputStyle,
886
+ width: 90,
887
+ flexShrink: 0,
888
+ padding: "7px 8px",
889
+ }}
890
+ >
891
+ <option value="aws">AWS</option>
892
+ <option value="gcp">GCP</option>
893
+ <option value="azure">Azure</option>
894
+ </select>
895
+ <input
896
+ data-testid={`update-keyid-${env.name}`}
897
+ value={state.keyId}
898
+ onChange={(e) =>
899
+ setUpdateEnvBackends((prev) => ({
900
+ ...prev,
901
+ [env.name]: { ...state, keyId: e.target.value },
902
+ }))
903
+ }
904
+ placeholder="arn:aws:kms:… or key resource ID"
905
+ style={{ ...inputStyle, flex: 1 }}
906
+ />
907
+ </div>
908
+ )}
909
+ </div>
910
+ );
911
+ })}
912
+ </div>
913
+
914
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
915
+ <Button
916
+ data-testid="update-cancel-btn"
917
+ variant="ghost"
918
+ onClick={goDetail}
919
+ disabled={updating}
920
+ >
921
+ Cancel
922
+ </Button>
923
+ <Button
924
+ data-testid="update-submit-btn"
925
+ variant="primary"
926
+ onClick={handleUpdate}
927
+ disabled={!canUpdate || updating}
928
+ >
929
+ {updating ? "Saving…" : "Save changes"}
930
+ </Button>
931
+ </div>
932
+ </div>
933
+ </div>
934
+ </div>
935
+ );
936
+ }
937
+
938
+ // ── Keys result view (post-creation) ─────────────────────────────────────────
939
+ if (view === "keys") {
940
+ const hasAgeKeys = Object.keys(privateKeys).length > 0;
941
+ return (
942
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
943
+ <TopBar title={`${createdName} created`} subtitle="Service identity ready" />
944
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
945
+ <div style={{ maxWidth: 620, margin: "0 auto" }}>
946
+ {hasAgeKeys && (
947
+ <div
948
+ style={{
949
+ background: "#1a1200",
950
+ border: `1px solid ${theme.yellow}55`,
951
+ borderRadius: 8,
952
+ padding: "14px 18px",
953
+ marginBottom: 20,
954
+ fontFamily: theme.sans,
955
+ fontSize: 13,
956
+ color: theme.yellow,
957
+ display: "flex",
958
+ gap: 10,
959
+ alignItems: "flex-start",
960
+ }}
961
+ >
962
+ <span style={{ fontSize: 16, flexShrink: 0 }}>⚠</span>
963
+ <span>
964
+ Copy these private keys now — they will not be shown again. Store each key
965
+ securely and provision it to the relevant runtime.
966
+ </span>
967
+ </div>
968
+ )}
969
+
970
+ {!hasAgeKeys && (
971
+ <div
972
+ style={{
973
+ background: theme.purpleDim,
974
+ border: `1px solid ${theme.purple}44`,
975
+ borderRadius: 8,
976
+ padding: "14px 18px",
977
+ marginBottom: 20,
978
+ fontFamily: theme.sans,
979
+ fontSize: 13,
980
+ color: theme.purple,
981
+ }}
982
+ >
983
+ All environments use KMS. No private keys to provision — runtimes authenticate via
984
+ IAM role.
985
+ </div>
986
+ )}
987
+
988
+ <Label>Private keys</Label>
989
+ {Object.entries(privateKeys).map(([envName, key]) => (
990
+ <div
991
+ key={envName}
992
+ style={{
993
+ background: theme.surface,
994
+ border: `1px solid ${theme.border}`,
995
+ borderRadius: 8,
996
+ padding: "14px 18px",
997
+ marginBottom: 10,
998
+ }}
999
+ >
1000
+ <div
1001
+ style={{
1002
+ display: "flex",
1003
+ alignItems: "center",
1004
+ justifyContent: "space-between",
1005
+ marginBottom: 10,
1006
+ }}
1007
+ >
1008
+ <EnvBadge env={envName} />
1009
+ <CopyButton text={key} />
1010
+ </div>
1011
+ <div
1012
+ style={{
1013
+ fontFamily: theme.mono,
1014
+ fontSize: 11,
1015
+ color: theme.textMuted,
1016
+ wordBreak: "break-all",
1017
+ background: theme.bg,
1018
+ borderRadius: 4,
1019
+ padding: "8px 10px",
1020
+ }}
1021
+ >
1022
+ {key}
1023
+ </div>
1024
+ </div>
1025
+ ))}
1026
+
1027
+ <div style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
1028
+ <Button
1029
+ variant="primary"
1030
+ onClick={() => {
1031
+ load();
1032
+ goList();
1033
+ }}
1034
+ >
1035
+ Done
1036
+ </Button>
1037
+ </div>
1038
+ </div>
1039
+ </div>
1040
+ </div>
1041
+ );
1042
+ }
1043
+
1044
+ // ── Create form ───────────────────────────────────────────────────────────────
1045
+ const namespaces = manifest?.namespaces ?? [];
1046
+ const environments = manifest?.environments ?? [];
1047
+
1048
+ const nameError =
1049
+ name.trim() && identities.some((i) => i.name === name.trim())
1050
+ ? "A service identity with this name already exists."
1051
+ : "";
1052
+ const canSubmit =
1053
+ name.trim() !== "" &&
1054
+ !nameError &&
1055
+ selectedNamespaces.size > 0 &&
1056
+ environments.every((env) => {
1057
+ const cfg = envBackends[env.name];
1058
+ return cfg?.type === "age" || (cfg?.type === "kms" && cfg.provider && cfg.keyId.trim());
1059
+ });
1060
+
1061
+ return (
1062
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
1063
+ <TopBar
1064
+ title="New service identity"
1065
+ subtitle="Scope cryptographic access to specific namespaces"
1066
+ actions={
1067
+ <button
1068
+ onClick={goList}
1069
+ style={{
1070
+ background: "none",
1071
+ border: `1px solid ${theme.borderLight}`,
1072
+ borderRadius: 6,
1073
+ padding: "4px 12px",
1074
+ cursor: "pointer",
1075
+ fontFamily: theme.sans,
1076
+ fontSize: 12,
1077
+ color: theme.textMuted,
1078
+ }}
1079
+ >
1080
+ {"\u2190"} Cancel
1081
+ </button>
1082
+ }
1083
+ />
1084
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
1085
+ <div style={{ maxWidth: 560, margin: "0 auto" }}>
1086
+ {createError && <ErrorBanner>{createError}</ErrorBanner>}
1087
+
1088
+ {/* Name */}
1089
+ <div style={{ marginBottom: 20 }}>
1090
+ <FieldLabel>Name</FieldLabel>
1091
+ <input
1092
+ data-testid="si-name-input"
1093
+ value={name}
1094
+ onChange={(e) => setName(e.target.value)}
1095
+ placeholder="e.g. api-gateway"
1096
+ style={inputStyle}
1097
+ />
1098
+ {nameError && (
1099
+ <div
1100
+ style={{
1101
+ fontFamily: theme.sans,
1102
+ fontSize: 12,
1103
+ color: theme.red,
1104
+ marginTop: 6,
1105
+ }}
1106
+ >
1107
+ {nameError}
1108
+ </div>
1109
+ )}
1110
+ </div>
1111
+
1112
+ {/* Description */}
1113
+ <div style={{ marginBottom: 24 }}>
1114
+ <FieldLabel>Description (optional)</FieldLabel>
1115
+ <input
1116
+ value={description}
1117
+ onChange={(e) => setDescription(e.target.value)}
1118
+ placeholder="e.g. API gateway service account"
1119
+ style={inputStyle}
1120
+ />
1121
+ </div>
1122
+
1123
+ {/* Namespaces */}
1124
+ <div style={{ marginBottom: 24 }}>
1125
+ <FieldLabel>Namespaces</FieldLabel>
1126
+ <div
1127
+ style={{
1128
+ fontFamily: theme.sans,
1129
+ fontSize: 12,
1130
+ color: theme.textMuted,
1131
+ marginBottom: 10,
1132
+ }}
1133
+ >
1134
+ This identity can decrypt secrets only from the selected namespaces.
1135
+ </div>
1136
+ {namespaces.length === 0 && (
1137
+ <div style={{ fontFamily: theme.sans, fontSize: 12, color: theme.textDim }}>
1138
+ No namespaces defined in manifest.
1139
+ </div>
1140
+ )}
1141
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
1142
+ {namespaces.map((ns) => {
1143
+ const checked = selectedNamespaces.has(ns.name);
1144
+ return (
1145
+ <label
1146
+ key={ns.name}
1147
+ data-testid={`ns-checkbox-${ns.name}`}
1148
+ style={{
1149
+ display: "flex",
1150
+ alignItems: "center",
1151
+ gap: 10,
1152
+ padding: "10px 14px",
1153
+ background: checked ? theme.accentDim : theme.surface,
1154
+ border: `1px solid ${checked ? theme.accent + "55" : theme.border}`,
1155
+ borderRadius: 6,
1156
+ cursor: "pointer",
1157
+ transition: "all 0.1s",
1158
+ }}
1159
+ >
1160
+ <input
1161
+ type="checkbox"
1162
+ checked={checked}
1163
+ onChange={(e) => {
1164
+ const next = new Set(selectedNamespaces);
1165
+ if (e.target.checked) next.add(ns.name);
1166
+ else next.delete(ns.name);
1167
+ setSelectedNamespaces(next);
1168
+ }}
1169
+ style={{ accentColor: theme.accent }}
1170
+ />
1171
+ <span
1172
+ style={{
1173
+ fontFamily: theme.mono,
1174
+ fontSize: 12,
1175
+ color: checked ? theme.accent : theme.text,
1176
+ }}
1177
+ >
1178
+ {ns.name}
1179
+ </span>
1180
+ {ns.description && (
1181
+ <span
1182
+ style={{ fontFamily: theme.sans, fontSize: 11, color: theme.textMuted }}
1183
+ >
1184
+ — {ns.description}
1185
+ </span>
1186
+ )}
1187
+ </label>
1188
+ );
1189
+ })}
1190
+ </div>
1191
+ </div>
1192
+
1193
+ {/* Per-environment backend */}
1194
+ <div style={{ marginBottom: 28 }}>
1195
+ <FieldLabel>Environment backends</FieldLabel>
1196
+ <div
1197
+ style={{
1198
+ fontFamily: theme.sans,
1199
+ fontSize: 12,
1200
+ color: theme.textMuted,
1201
+ marginBottom: 10,
1202
+ }}
1203
+ >
1204
+ Age generates a key pair per environment. KMS uses your cloud provider — no key
1205
+ material is provisioned.
1206
+ </div>
1207
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
1208
+ {environments.map((env) => {
1209
+ const cfg = envBackends[env.name] ?? { type: "age", provider: "aws", keyId: "" };
1210
+ return (
1211
+ <div
1212
+ key={env.name}
1213
+ style={{
1214
+ background: theme.surface,
1215
+ border: `1px solid ${theme.border}`,
1216
+ borderRadius: 8,
1217
+ padding: "14px 16px",
1218
+ }}
1219
+ >
1220
+ <div
1221
+ style={{
1222
+ display: "flex",
1223
+ alignItems: "center",
1224
+ justifyContent: "space-between",
1225
+ marginBottom: cfg.type === "kms" ? 12 : 0,
1226
+ }}
1227
+ >
1228
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
1229
+ <EnvBadge env={env.name} />
1230
+ {env.protected && (
1231
+ <span style={{ fontSize: 11, color: theme.red }}>{"\uD83D\uDD12"}</span>
1232
+ )}
1233
+ </div>
1234
+ <div style={{ display: "flex", gap: 4 }}>
1235
+ {(["age", "kms"] as const).map((t) => (
1236
+ <button
1237
+ key={t}
1238
+ onClick={() =>
1239
+ setEnvBackends((prev) => ({
1240
+ ...prev,
1241
+ [env.name]: { ...cfg, type: t },
1242
+ }))
1243
+ }
1244
+ style={{
1245
+ background:
1246
+ cfg.type === t
1247
+ ? t === "kms"
1248
+ ? theme.purple
1249
+ : theme.accent
1250
+ : "transparent",
1251
+ border: `1px solid ${
1252
+ cfg.type === t
1253
+ ? t === "kms"
1254
+ ? theme.purple
1255
+ : theme.accent
1256
+ : theme.border
1257
+ }`,
1258
+ borderRadius: 4,
1259
+ padding: "3px 10px",
1260
+ cursor: "pointer",
1261
+ fontFamily: theme.mono,
1262
+ fontSize: 11,
1263
+ color: cfg.type === t ? "#fff" : theme.textMuted,
1264
+ transition: "all 0.1s",
1265
+ }}
1266
+ >
1267
+ {t.toUpperCase()}
1268
+ </button>
1269
+ ))}
1270
+ </div>
1271
+ </div>
1272
+
1273
+ {cfg.type === "kms" && (
1274
+ <div style={{ display: "flex", gap: 8 }}>
1275
+ <select
1276
+ value={cfg.provider}
1277
+ onChange={(e) =>
1278
+ setEnvBackends((prev) => ({
1279
+ ...prev,
1280
+ [env.name]: { ...cfg, provider: e.target.value },
1281
+ }))
1282
+ }
1283
+ style={{
1284
+ ...inputStyle,
1285
+ width: 90,
1286
+ flexShrink: 0,
1287
+ padding: "7px 8px",
1288
+ }}
1289
+ >
1290
+ <option value="aws">AWS</option>
1291
+ <option value="gcp">GCP</option>
1292
+ <option value="azure">Azure</option>
1293
+ </select>
1294
+ <input
1295
+ value={cfg.keyId}
1296
+ onChange={(e) =>
1297
+ setEnvBackends((prev) => ({
1298
+ ...prev,
1299
+ [env.name]: { ...cfg, keyId: e.target.value },
1300
+ }))
1301
+ }
1302
+ placeholder="arn:aws:kms:… or key resource ID"
1303
+ style={{ ...inputStyle, flex: 1 }}
1304
+ />
1305
+ </div>
1306
+ )}
1307
+ </div>
1308
+ );
1309
+ })}
1310
+ </div>
1311
+ </div>
1312
+
1313
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
1314
+ <Button variant="ghost" onClick={goList} disabled={creating}>
1315
+ Cancel
1316
+ </Button>
1317
+ <Button
1318
+ data-testid="create-si-submit"
1319
+ variant="primary"
1320
+ onClick={handleCreate}
1321
+ disabled={!canSubmit || creating}
1322
+ >
1323
+ {creating ? "Creating…" : "Create identity"}
1324
+ </Button>
1325
+ </div>
1326
+ </div>
1327
+ </div>
1328
+ </div>
1329
+ );
1330
+ }
1331
+
1332
+ // ── Shared helpers ─────────────────────────────────────────────────────────────
1333
+
1334
+ function Label({ children }: { children: React.ReactNode }) {
1335
+ return (
1336
+ <div
1337
+ style={{
1338
+ fontFamily: theme.sans,
1339
+ fontSize: 12,
1340
+ fontWeight: 600,
1341
+ color: theme.textMuted,
1342
+ marginBottom: 6,
1343
+ letterSpacing: "0.05em",
1344
+ textTransform: "uppercase",
1345
+ }}
1346
+ >
1347
+ {children}
1348
+ </div>
1349
+ );
1350
+ }
1351
+
1352
+ function FieldLabel({ children }: { children: React.ReactNode }) {
1353
+ return (
1354
+ <div
1355
+ style={{
1356
+ fontFamily: theme.sans,
1357
+ fontSize: 12,
1358
+ fontWeight: 600,
1359
+ color: theme.textMuted,
1360
+ marginBottom: 6,
1361
+ }}
1362
+ >
1363
+ {children}
1364
+ </div>
1365
+ );
1366
+ }
1367
+
1368
+ function ErrorBanner({ children }: { children: React.ReactNode }) {
1369
+ return (
1370
+ <div
1371
+ style={{
1372
+ background: theme.redDim,
1373
+ border: `1px solid ${theme.red}44`,
1374
+ borderRadius: 8,
1375
+ padding: "12px 16px",
1376
+ marginBottom: 16,
1377
+ fontFamily: theme.sans,
1378
+ fontSize: 13,
1379
+ color: theme.red,
1380
+ }}
1381
+ >
1382
+ {children}
1383
+ </div>
1384
+ );
1385
+ }
1386
+
1387
+ const inputStyle: React.CSSProperties = {
1388
+ width: "100%",
1389
+ background: theme.surface,
1390
+ border: `1px solid ${theme.border}`,
1391
+ borderRadius: 6,
1392
+ padding: "8px 12px",
1393
+ fontFamily: theme.mono,
1394
+ fontSize: 12,
1395
+ color: theme.text,
1396
+ outline: "none",
1397
+ boxSizing: "border-box",
1398
+ };