@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,1122 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { theme, ENV_COLORS } from "../theme";
3
+ import { apiFetch } from "../api";
4
+ import { TopBar } from "../components/TopBar";
5
+ import { Button } from "../components/Button";
6
+ import { EnvBadge } from "../components/EnvBadge";
7
+ import type { ClefManifest, DecryptedFile, LintIssue } from "@clef-sh/core";
8
+
9
+ interface EditorRow {
10
+ key: string;
11
+ value: string;
12
+ type: string;
13
+ required: boolean;
14
+ visible: boolean;
15
+ edited: boolean;
16
+ isNew: boolean;
17
+ pending: boolean;
18
+ }
19
+
20
+ interface NamespaceEditorProps {
21
+ ns: string;
22
+ manifest: ClefManifest | null;
23
+ onCommit: (message: string) => void;
24
+ }
25
+
26
+ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps) {
27
+ const [env, setEnv] = useState("");
28
+ const [rows, setRows] = useState<EditorRow[]>([]);
29
+ const [adding, setAdding] = useState(false);
30
+ const [addMode, setAddMode] = useState<"value" | "random">("value");
31
+ const [newKey, setNewKey] = useState("");
32
+ const [newValue, setNewValue] = useState("");
33
+ const [overflowKey, setOverflowKey] = useState<string | null>(null);
34
+ const [confirmReset, setConfirmReset] = useState<string | null>(null);
35
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
36
+ const [loading, setLoading] = useState(false);
37
+ const [error, setError] = useState<string | null>(null);
38
+ const [commitMessage, setCommitMessage] = useState("");
39
+ const [showCommitInput, setShowCommitInput] = useState(false);
40
+ const [sopsInfo, setSopsInfo] = useState("");
41
+ const [lintIssues, setLintIssues] = useState<LintIssue[]>([]);
42
+ const [protectedConfirm, setProtectedConfirm] = useState<"save" | "add" | null>(null);
43
+
44
+ const revealTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
45
+
46
+ const REVEAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
47
+
48
+ // Clear timeout on unmount
49
+ useEffect(() => () => clearTimeout(revealTimeoutRef.current), []);
50
+
51
+ const environments = manifest?.environments ?? [];
52
+ const isProduction = environments.find((e) => e.name === env)?.protected === true;
53
+
54
+ useEffect(() => {
55
+ if (environments.length > 0 && !env) {
56
+ setEnv(environments[0].name);
57
+ }
58
+ }, [environments, env]);
59
+
60
+ const loadData = useCallback(async () => {
61
+ if (!env || !ns) return;
62
+ setLoading(true);
63
+ setError(null);
64
+ try {
65
+ const res = await apiFetch(`/api/namespace/${ns}/${env}`);
66
+ if (!res.ok) {
67
+ const body = await res.json();
68
+ setError(body.error || "Failed to load");
69
+ setRows([]);
70
+ return;
71
+ }
72
+ const data = (await res.json()) as DecryptedFile & { pending?: string[] };
73
+ const pendingKeys = new Set(data.pending ?? []);
74
+ const newRows: EditorRow[] = Object.entries(data.values).map(([key, value]) => ({
75
+ key,
76
+ value: String(value),
77
+ type: "string",
78
+ required: false,
79
+ visible: false,
80
+ edited: false,
81
+ isNew: false,
82
+ pending: pendingKeys.has(key),
83
+ }));
84
+ setRows(newRows);
85
+ const backend = data.metadata?.backend ?? "age";
86
+ const recipientCount = data.metadata?.recipients?.length ?? 0;
87
+ setSopsInfo(
88
+ `encrypted with ${backend} \u00B7 ${recipientCount} recipient${recipientCount !== 1 ? "s" : ""}`,
89
+ );
90
+ } catch {
91
+ setError("Failed to load namespace data");
92
+ setRows([]);
93
+ } finally {
94
+ setLoading(false);
95
+ }
96
+ }, [ns, env]);
97
+
98
+ useEffect(() => {
99
+ loadData();
100
+ }, [loadData]);
101
+
102
+ useEffect(() => {
103
+ if (!ns) return;
104
+ apiFetch(`/api/lint/${ns}`)
105
+ .then((res) => (res.ok ? res.json() : { issues: [] }))
106
+ .then((data) => setLintIssues(data.issues ?? []))
107
+ .catch(() => setLintIssues([]));
108
+ }, [ns]);
109
+
110
+ const toggleVisible = (key: string) => {
111
+ setRows((r) => r.map((row) => (row.key === key ? { ...row, visible: !row.visible } : row)));
112
+
113
+ // Reset idle timeout on any reveal action
114
+ clearTimeout(revealTimeoutRef.current);
115
+ revealTimeoutRef.current = setTimeout(() => {
116
+ setRows((r) =>
117
+ r.map((row) => ({
118
+ ...row,
119
+ value: "",
120
+ visible: false,
121
+ })),
122
+ );
123
+ }, REVEAL_TIMEOUT_MS);
124
+ };
125
+
126
+ const handleEdit = (key: string, val: string) => {
127
+ setRows((r) => r.map((row) => (row.key === key ? { ...row, value: val, edited: true } : row)));
128
+ };
129
+
130
+ const handleSave = async (confirmed?: boolean) => {
131
+ const dirtyRows = rows.filter((r) => r.edited);
132
+ await Promise.all(
133
+ dirtyRows.map((row) => {
134
+ const payload: Record<string, unknown> = { value: row.value };
135
+ if (confirmed) payload.confirmed = true;
136
+ return apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
137
+ method: "PUT",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify(payload),
140
+ });
141
+ }),
142
+ );
143
+ if (commitMessage) {
144
+ onCommit(commitMessage);
145
+ }
146
+ setShowCommitInput(false);
147
+ setCommitMessage("");
148
+ await loadData();
149
+ };
150
+
151
+ const handleAdd = async (confirmed?: boolean) => {
152
+ if (!newKey.trim()) return;
153
+ const trimmedKey = newKey.trim();
154
+ const body: Record<string, unknown> =
155
+ addMode === "random" ? { random: true } : { value: newValue };
156
+ if (confirmed) body.confirmed = true;
157
+ try {
158
+ const res = await apiFetch(`/api/namespace/${ns}/${env}/${trimmedKey}`, {
159
+ method: "PUT",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify(body),
162
+ });
163
+ if (!res.ok) {
164
+ const data = await res.json();
165
+ setError(data.error || "Failed to add key");
166
+ return;
167
+ }
168
+ const data = await res.json();
169
+ if (data.warning) {
170
+ setError(data.warning);
171
+ }
172
+ // Update local state directly — avoids an extra decrypt round-trip
173
+ setRows((prev) => [
174
+ ...prev,
175
+ {
176
+ key: trimmedKey,
177
+ value: addMode === "random" ? "" : newValue,
178
+ type: "string",
179
+ required: false,
180
+ visible: false,
181
+ edited: false,
182
+ isNew: true,
183
+ pending: addMode === "random",
184
+ },
185
+ ]);
186
+ } catch {
187
+ setError("Failed to add key");
188
+ } finally {
189
+ setAdding(false);
190
+ setAddMode("value");
191
+ setNewKey("");
192
+ setNewValue("");
193
+ }
194
+ };
195
+
196
+ const handleResetToRandom = async (key: string) => {
197
+ try {
198
+ const res = await apiFetch(`/api/namespace/${ns}/${env}/${key}`, {
199
+ method: "PUT",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({ random: true, ...(isProduction && { confirmed: true }) }),
202
+ });
203
+ if (!res.ok) {
204
+ const data = await res.json();
205
+ setError(data.error || "Failed to reset key");
206
+ return;
207
+ }
208
+ // Update local state directly — avoids an extra decrypt round-trip
209
+ setRows((prev) =>
210
+ prev.map((row) =>
211
+ row.key === key ? { ...row, pending: true, value: "", edited: false } : row,
212
+ ),
213
+ );
214
+ } catch {
215
+ setError("Failed to reset key");
216
+ } finally {
217
+ setConfirmReset(null);
218
+ setOverflowKey(null);
219
+ }
220
+ };
221
+
222
+ const handleAccept = async (key: string) => {
223
+ try {
224
+ const res = await apiFetch(`/api/namespace/${ns}/${env}/${key}/accept`, {
225
+ method: "POST",
226
+ });
227
+ if (!res.ok) {
228
+ const data = await res.json();
229
+ setError(data.error || "Failed to accept value");
230
+ return;
231
+ }
232
+ const data = await res.json();
233
+ setRows((prev) =>
234
+ prev.map((row) =>
235
+ row.key === key ? { ...row, pending: false, value: data.value ?? row.value } : row,
236
+ ),
237
+ );
238
+ } catch {
239
+ setError("Failed to accept value");
240
+ }
241
+ };
242
+
243
+ const handleDelete = async (key: string) => {
244
+ try {
245
+ const res = await apiFetch(`/api/namespace/${ns}/${env}/${key}`, {
246
+ method: "DELETE",
247
+ headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify(isProduction ? { confirmed: true } : {}),
249
+ });
250
+ if (!res.ok) {
251
+ const data = await res.json();
252
+ setError(data.error || "Failed to delete key");
253
+ return;
254
+ }
255
+ setRows((prev) => prev.filter((row) => row.key !== key));
256
+ } catch {
257
+ setError("Failed to delete key");
258
+ } finally {
259
+ setConfirmDelete(null);
260
+ }
261
+ };
262
+
263
+ const hasChanges = rows.some((r) => r.edited);
264
+
265
+ return (
266
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
267
+ <TopBar
268
+ title={`/${ns}`}
269
+ subtitle={`Namespace \u00B7 ${rows.length} keys`}
270
+ actions={
271
+ <>
272
+ {hasChanges && !showCommitInput && (
273
+ <Button variant="primary" onClick={() => setShowCommitInput(true)}>
274
+ Commit changes
275
+ </Button>
276
+ )}
277
+ {showCommitInput && (
278
+ <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
279
+ <input
280
+ data-testid="commit-message-input"
281
+ value={commitMessage}
282
+ onChange={(e) => setCommitMessage(e.target.value)}
283
+ placeholder="Commit message..."
284
+ style={{
285
+ background: "#0D0F14",
286
+ border: `1px solid ${theme.borderLight}`,
287
+ borderRadius: 5,
288
+ padding: "5px 10px",
289
+ fontFamily: theme.mono,
290
+ fontSize: 12,
291
+ color: theme.text,
292
+ outline: "none",
293
+ width: 220,
294
+ }}
295
+ />
296
+ <Button
297
+ variant="primary"
298
+ onClick={() => {
299
+ if (isProduction) {
300
+ setProtectedConfirm("save");
301
+ } else {
302
+ handleSave();
303
+ }
304
+ }}
305
+ >
306
+ Save & Commit
307
+ </Button>
308
+ <Button onClick={() => setShowCommitInput(false)}>Cancel</Button>
309
+ </div>
310
+ )}
311
+ <Button variant="primary" data-testid="add-key-btn" onClick={() => setAdding(true)}>
312
+ + Add key
313
+ </Button>
314
+ </>
315
+ }
316
+ />
317
+
318
+ {/* Env tabs */}
319
+ <div
320
+ style={{
321
+ display: "flex",
322
+ gap: 0,
323
+ borderBottom: `1px solid ${theme.border}`,
324
+ padding: "0 24px",
325
+ background: "#0D0F14",
326
+ }}
327
+ >
328
+ {environments.map((e) => {
329
+ const isActive = env === e.name;
330
+ const c = ENV_COLORS[e.name] ?? { color: theme.textMuted };
331
+ return (
332
+ <div
333
+ key={e.name}
334
+ role="tab"
335
+ tabIndex={0}
336
+ onClick={() => setEnv(e.name)}
337
+ onKeyDown={(ev) => {
338
+ if (ev.key === "Enter") setEnv(e.name);
339
+ }}
340
+ style={{
341
+ padding: "10px 18px",
342
+ cursor: "pointer",
343
+ borderBottom: isActive ? `2px solid ${c.color}` : "2px solid transparent",
344
+ display: "flex",
345
+ alignItems: "center",
346
+ gap: 7,
347
+ marginBottom: -1,
348
+ }}
349
+ >
350
+ <EnvBadge env={e.name} small />
351
+ <span
352
+ style={{
353
+ fontFamily: theme.sans,
354
+ fontSize: 13,
355
+ fontWeight: isActive ? 600 : 400,
356
+ color: isActive ? theme.text : theme.textMuted,
357
+ }}
358
+ >
359
+ {e.name}
360
+ </span>
361
+ </div>
362
+ );
363
+ })}
364
+ <div style={{ flex: 1 }} />
365
+ <div
366
+ style={{
367
+ padding: "10px 0",
368
+ display: "flex",
369
+ alignItems: "center",
370
+ }}
371
+ >
372
+ <span
373
+ style={{
374
+ fontFamily: theme.mono,
375
+ fontSize: 10,
376
+ color: theme.textMuted,
377
+ }}
378
+ >
379
+ {sopsInfo}
380
+ </span>
381
+ </div>
382
+ </div>
383
+
384
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
385
+ {/* Production warning */}
386
+ {isProduction && (
387
+ <div
388
+ data-testid="production-warning"
389
+ style={{
390
+ marginBottom: 20,
391
+ padding: "10px 16px",
392
+ background: theme.redDim,
393
+ border: `1px solid ${theme.red}44`,
394
+ borderRadius: 8,
395
+ display: "flex",
396
+ alignItems: "center",
397
+ gap: 10,
398
+ }}
399
+ >
400
+ <span style={{ fontSize: 14 }}>{"\uD83D\uDD12"}</span>
401
+ <span style={{ fontFamily: theme.sans, fontSize: 12, color: theme.red }}>
402
+ <strong>Production environment.</strong> Changes will require confirmation before
403
+ committing.
404
+ </span>
405
+ </div>
406
+ )}
407
+
408
+ {loading && <p style={{ color: theme.textMuted, fontFamily: theme.sans }}>Loading...</p>}
409
+
410
+ {error && (
411
+ <div
412
+ style={{
413
+ padding: "12px 16px",
414
+ background: theme.redDim,
415
+ border: `1px solid ${theme.red}44`,
416
+ borderRadius: 8,
417
+ fontFamily: theme.sans,
418
+ fontSize: 12,
419
+ color: theme.red,
420
+ marginBottom: 20,
421
+ }}
422
+ >
423
+ {error}
424
+ </div>
425
+ )}
426
+
427
+ {!loading && !error && (
428
+ <>
429
+ {/* Keys table */}
430
+ <div
431
+ style={{
432
+ background: theme.surface,
433
+ border: `1px solid ${theme.border}`,
434
+ borderRadius: 10,
435
+ }}
436
+ >
437
+ {/* Header */}
438
+ <div
439
+ style={{
440
+ display: "grid",
441
+ gridTemplateColumns: "260px 1fr 90px 36px",
442
+ background: "#0D0F14",
443
+ padding: "10px 20px",
444
+ borderBottom: `1px solid ${theme.border}`,
445
+ borderRadius: "10px 10px 0 0",
446
+ }}
447
+ >
448
+ {["Key", "Value", "Type", ""].map((h) => (
449
+ <span
450
+ key={h || "actions"}
451
+ style={{
452
+ fontFamily: theme.sans,
453
+ fontSize: 11,
454
+ fontWeight: 600,
455
+ color: theme.textMuted,
456
+ textTransform: "uppercase",
457
+ letterSpacing: "0.07em",
458
+ }}
459
+ >
460
+ {h}
461
+ </span>
462
+ ))}
463
+ </div>
464
+
465
+ {rows.map((row, i) => (
466
+ <div
467
+ key={row.key}
468
+ style={{
469
+ display: "grid",
470
+ gridTemplateColumns: "260px 1fr 90px 36px",
471
+ padding: "0 20px",
472
+ borderBottom: i < rows.length - 1 ? `1px solid ${theme.border}` : "none",
473
+ background: row.pending
474
+ ? "#F0A50012"
475
+ : row.edited
476
+ ? `${theme.accent}08`
477
+ : "transparent",
478
+ borderLeft: row.pending
479
+ ? "3px solid #F0A50088"
480
+ : row.edited
481
+ ? `2px solid ${theme.accent}`
482
+ : "2px solid transparent",
483
+ alignItems: "center",
484
+ minHeight: 48,
485
+ }}
486
+ >
487
+ {/* Key */}
488
+ <div
489
+ style={{
490
+ display: "flex",
491
+ alignItems: "center",
492
+ gap: 8,
493
+ paddingRight: 16,
494
+ }}
495
+ >
496
+ {row.required && (
497
+ <span
498
+ style={{
499
+ color: theme.accent,
500
+ fontSize: 14,
501
+ lineHeight: 1,
502
+ }}
503
+ >
504
+ *
505
+ </span>
506
+ )}
507
+ <span
508
+ style={{
509
+ fontFamily: theme.mono,
510
+ fontSize: 12,
511
+ color: theme.text,
512
+ }}
513
+ >
514
+ {row.key}
515
+ </span>
516
+ {row.edited && (
517
+ <span
518
+ data-testid="dirty-dot"
519
+ style={{
520
+ width: 6,
521
+ height: 6,
522
+ borderRadius: "50%",
523
+ background: theme.accent,
524
+ flexShrink: 0,
525
+ display: "inline-block",
526
+ }}
527
+ />
528
+ )}
529
+ </div>
530
+
531
+ {/* Value */}
532
+ <div
533
+ style={{
534
+ display: "flex",
535
+ alignItems: "center",
536
+ gap: 8,
537
+ paddingRight: 16,
538
+ }}
539
+ >
540
+ {row.pending && !row.visible ? (
541
+ <span
542
+ style={{
543
+ fontFamily: theme.mono,
544
+ fontSize: 11,
545
+ fontStyle: "italic",
546
+ color: theme.accent,
547
+ }}
548
+ >
549
+ PENDING {"\u2014"} not yet set
550
+ </span>
551
+ ) : row.visible ? (
552
+ <input
553
+ type="text"
554
+ data-testid={`value-input-${row.key}`}
555
+ value={row.value}
556
+ onChange={(e) => handleEdit(row.key, e.target.value)}
557
+ autoComplete="off"
558
+ placeholder={row.pending ? "Enter real value..." : undefined}
559
+ style={{
560
+ flex: 1,
561
+ background: "#0D0F14",
562
+ border: `1px solid ${theme.borderLight}`,
563
+ borderRadius: 5,
564
+ padding: "5px 10px",
565
+ fontFamily: theme.mono,
566
+ fontSize: 12,
567
+ color: theme.text,
568
+ outline: "none",
569
+ }}
570
+ />
571
+ ) : (
572
+ <span
573
+ style={{
574
+ fontFamily: theme.mono,
575
+ fontSize: 13,
576
+ color: theme.textMuted,
577
+ letterSpacing: "0.15em",
578
+ }}
579
+ >
580
+ {"\u2022".repeat(Math.min(row.value.length, 20))}
581
+ </span>
582
+ )}
583
+ {row.pending && !row.visible ? (
584
+ <div style={{ display: "flex", gap: 6 }}>
585
+ <button
586
+ data-testid={`set-value-${row.key}`}
587
+ onClick={() => toggleVisible(row.key)}
588
+ style={{
589
+ background: `${theme.accent}18`,
590
+ border: `1px solid ${theme.accent}55`,
591
+ borderRadius: 5,
592
+ cursor: "pointer",
593
+ color: theme.accent,
594
+ padding: "3px 10px",
595
+ fontFamily: theme.sans,
596
+ fontSize: 11,
597
+ fontWeight: 600,
598
+ }}
599
+ >
600
+ Set value
601
+ </button>
602
+ <button
603
+ data-testid={`accept-value-${row.key}`}
604
+ onClick={() => handleAccept(row.key)}
605
+ title="Accept the random value as the final secret"
606
+ style={{
607
+ background: `${theme.green}18`,
608
+ border: `1px solid ${theme.green}55`,
609
+ borderRadius: 5,
610
+ cursor: "pointer",
611
+ color: theme.green,
612
+ padding: "3px 10px",
613
+ fontFamily: theme.sans,
614
+ fontSize: 11,
615
+ fontWeight: 600,
616
+ }}
617
+ >
618
+ Accept random
619
+ </button>
620
+ </div>
621
+ ) : (
622
+ <button
623
+ data-testid={`eye-${row.key}`}
624
+ onClick={() => toggleVisible(row.key)}
625
+ aria-label={row.visible ? "Hide value" : "Reveal value"}
626
+ style={{
627
+ background: "none",
628
+ border: "none",
629
+ cursor: "pointer",
630
+ color: row.visible ? theme.accent : theme.textDim,
631
+ padding: 4,
632
+ display: "flex",
633
+ alignItems: "center",
634
+ fontSize: 13,
635
+ }}
636
+ >
637
+ {"\uD83D\uDC41"}
638
+ </button>
639
+ )}
640
+ </div>
641
+
642
+ {/* Type */}
643
+ <div>
644
+ {row.pending ? (
645
+ <span
646
+ style={{
647
+ fontFamily: theme.mono,
648
+ fontSize: 10,
649
+ fontWeight: 700,
650
+ color: theme.accent,
651
+ background: `${theme.accent}18`,
652
+ border: `1px solid ${theme.accent}33`,
653
+ borderRadius: 3,
654
+ padding: "2px 7px",
655
+ }}
656
+ >
657
+ PENDING
658
+ </span>
659
+ ) : (
660
+ <span
661
+ style={{
662
+ fontFamily: theme.mono,
663
+ fontSize: 10,
664
+ color: theme.blue,
665
+ background: theme.blueDim,
666
+ border: `1px solid ${theme.blue}33`,
667
+ borderRadius: 3,
668
+ padding: "2px 7px",
669
+ }}
670
+ >
671
+ {row.type}
672
+ </span>
673
+ )}
674
+ </div>
675
+
676
+ {/* Actions */}
677
+ <div style={{ display: "flex", justifyContent: "center", position: "relative" }}>
678
+ <button
679
+ data-testid={`overflow-${row.key}`}
680
+ onClick={() => setOverflowKey(overflowKey === row.key ? null : row.key)}
681
+ style={{
682
+ background: "none",
683
+ border: "none",
684
+ cursor: "pointer",
685
+ color: theme.textDim,
686
+ fontSize: 16,
687
+ padding: 4,
688
+ }}
689
+ >
690
+ {"\u22EF"}
691
+ </button>
692
+ {overflowKey === row.key && (
693
+ <div
694
+ data-testid={`overflow-menu-${row.key}`}
695
+ style={{
696
+ position: "absolute",
697
+ top: "100%",
698
+ right: 0,
699
+ zIndex: 10,
700
+ background: theme.surface,
701
+ border: `1px solid ${theme.border}`,
702
+ borderRadius: 6,
703
+ padding: 4,
704
+ minWidth: 200,
705
+ boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
706
+ }}
707
+ >
708
+ <button
709
+ data-testid={`reset-random-${row.key}`}
710
+ onClick={() => {
711
+ setOverflowKey(null);
712
+ setConfirmReset(row.key);
713
+ }}
714
+ title="Use this to immediately invalidate a compromised secret while you arrange a replacement."
715
+ style={{
716
+ display: "block",
717
+ width: "100%",
718
+ textAlign: "left",
719
+ background: "none",
720
+ border: "none",
721
+ cursor: "pointer",
722
+ fontFamily: theme.sans,
723
+ fontSize: 12,
724
+ color: theme.accent,
725
+ padding: "6px 10px",
726
+ borderRadius: 4,
727
+ }}
728
+ onMouseEnter={(e) => {
729
+ (e.currentTarget as HTMLElement).style.background = theme.surfaceHover;
730
+ }}
731
+ onMouseLeave={(e) => {
732
+ (e.currentTarget as HTMLElement).style.background = "none";
733
+ }}
734
+ >
735
+ Reset to random (pending)
736
+ </button>
737
+ <button
738
+ data-testid={`delete-key-${row.key}`}
739
+ onClick={() => {
740
+ setOverflowKey(null);
741
+ setConfirmDelete(row.key);
742
+ }}
743
+ style={{
744
+ display: "block",
745
+ width: "100%",
746
+ textAlign: "left",
747
+ background: "none",
748
+ border: "none",
749
+ cursor: "pointer",
750
+ fontFamily: theme.sans,
751
+ fontSize: 12,
752
+ color: theme.red,
753
+ padding: "6px 10px",
754
+ borderRadius: 4,
755
+ }}
756
+ onMouseEnter={(e) => {
757
+ (e.currentTarget as HTMLElement).style.background = theme.surfaceHover;
758
+ }}
759
+ onMouseLeave={(e) => {
760
+ (e.currentTarget as HTMLElement).style.background = "none";
761
+ }}
762
+ >
763
+ Delete key
764
+ </button>
765
+ </div>
766
+ )}
767
+ </div>
768
+ </div>
769
+ ))}
770
+
771
+ {/* Add key row */}
772
+ {adding && (
773
+ <div
774
+ style={{
775
+ padding: "12px 20px",
776
+ borderTop: `1px solid ${theme.border}`,
777
+ display: "flex",
778
+ flexDirection: "column",
779
+ gap: 10,
780
+ }}
781
+ >
782
+ <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
783
+ <input
784
+ data-testid="new-key-input"
785
+ placeholder="KEY_NAME"
786
+ value={newKey}
787
+ onChange={(e) => setNewKey(e.target.value)}
788
+ style={{
789
+ flex: "0 0 240px",
790
+ background: "#0D0F14",
791
+ border: `1px solid ${theme.accent}66`,
792
+ borderRadius: 5,
793
+ padding: "6px 10px",
794
+ fontFamily: theme.mono,
795
+ fontSize: 12,
796
+ color: theme.text,
797
+ outline: "none",
798
+ }}
799
+ />
800
+ {/* Mode toggle */}
801
+ <div
802
+ role="radiogroup"
803
+ style={{
804
+ display: "flex",
805
+ border: `1px solid ${theme.border}`,
806
+ borderRadius: 6,
807
+ overflow: "hidden",
808
+ }}
809
+ >
810
+ <button
811
+ data-testid="mode-set-value"
812
+ role="radio"
813
+ aria-checked={addMode === "value"}
814
+ onClick={() => {
815
+ setAddMode("value");
816
+ setNewValue("");
817
+ }}
818
+ style={{
819
+ fontFamily: theme.sans,
820
+ fontSize: 11,
821
+ fontWeight: 600,
822
+ padding: "4px 14px",
823
+ border: "none",
824
+ borderRight: `1px solid ${theme.border}`,
825
+ background: addMode === "value" ? `${theme.accent}22` : "transparent",
826
+ color: addMode === "value" ? theme.accent : theme.textMuted,
827
+ cursor: "pointer",
828
+ }}
829
+ >
830
+ Set value
831
+ </button>
832
+ <button
833
+ data-testid="mode-random"
834
+ role="radio"
835
+ aria-checked={addMode === "random"}
836
+ onClick={() => {
837
+ setAddMode("random");
838
+ setNewValue("");
839
+ }}
840
+ style={{
841
+ fontFamily: theme.sans,
842
+ fontSize: 11,
843
+ fontWeight: 600,
844
+ padding: "4px 14px",
845
+ border: "none",
846
+ background: addMode === "random" ? `${theme.accent}22` : "transparent",
847
+ color: addMode === "random" ? theme.accent : theme.textMuted,
848
+ cursor: "pointer",
849
+ }}
850
+ >
851
+ Random (pending)
852
+ </button>
853
+ </div>
854
+ </div>
855
+ <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
856
+ {addMode === "value" ? (
857
+ <input
858
+ data-testid="new-value-input"
859
+ type="password"
860
+ placeholder="value"
861
+ value={newValue}
862
+ onChange={(e) => setNewValue(e.target.value)}
863
+ autoComplete="off"
864
+ style={{
865
+ flex: 1,
866
+ background: "#0D0F14",
867
+ border: `1px solid ${theme.border}`,
868
+ borderRadius: 5,
869
+ padding: "6px 10px",
870
+ fontFamily: theme.mono,
871
+ fontSize: 12,
872
+ color: theme.text,
873
+ outline: "none",
874
+ }}
875
+ />
876
+ ) : (
877
+ <div
878
+ style={{
879
+ flex: 1,
880
+ fontFamily: theme.mono,
881
+ fontSize: 11,
882
+ fontStyle: "italic",
883
+ color: theme.accent,
884
+ padding: "6px 10px",
885
+ }}
886
+ >
887
+ A cryptographically random placeholder will be generated server-side.
888
+ </div>
889
+ )}
890
+ <Button
891
+ variant="primary"
892
+ data-testid="add-key-submit"
893
+ onClick={() => {
894
+ if (isProduction) {
895
+ setProtectedConfirm("add");
896
+ } else {
897
+ handleAdd();
898
+ }
899
+ }}
900
+ >
901
+ {addMode === "random" ? "Generate random value" : "Add"}
902
+ </Button>
903
+ <Button
904
+ onClick={() => {
905
+ setAdding(false);
906
+ setAddMode("value");
907
+ setNewKey("");
908
+ setNewValue("");
909
+ }}
910
+ >
911
+ Cancel
912
+ </Button>
913
+ </div>
914
+ </div>
915
+ )}
916
+ </div>
917
+
918
+ {/* Reset to random confirmation dialog */}
919
+ {confirmReset && (
920
+ <div
921
+ data-testid="confirm-reset-dialog"
922
+ style={{
923
+ marginTop: 16,
924
+ padding: "14px 18px",
925
+ background: `${theme.accent}0A`,
926
+ border: `1px solid ${theme.accent}33`,
927
+ borderRadius: 8,
928
+ fontFamily: theme.sans,
929
+ fontSize: 12,
930
+ }}
931
+ >
932
+ <p style={{ color: theme.text, margin: "0 0 10px 0" }}>
933
+ Reset <strong style={{ fontFamily: theme.mono }}>{confirmReset}</strong> to a
934
+ random placeholder? The current value will be overwritten.
935
+ </p>
936
+ {isProduction && (
937
+ <p
938
+ style={{
939
+ color: theme.red,
940
+ margin: "0 0 10px 0",
941
+ fontSize: 12,
942
+ fontWeight: 600,
943
+ }}
944
+ >
945
+ {"\uD83D\uDD12"} This is a protected environment.
946
+ </p>
947
+ )}
948
+ <div style={{ display: "flex", gap: 8 }}>
949
+ <Button
950
+ variant="primary"
951
+ data-testid="confirm-reset-yes"
952
+ onClick={() => handleResetToRandom(confirmReset)}
953
+ >
954
+ Reset to random
955
+ </Button>
956
+ <Button data-testid="confirm-reset-no" onClick={() => setConfirmReset(null)}>
957
+ Cancel
958
+ </Button>
959
+ </div>
960
+ </div>
961
+ )}
962
+
963
+ {/* Delete key confirmation dialog */}
964
+ {confirmDelete && (
965
+ <div
966
+ data-testid="confirm-delete-dialog"
967
+ style={{
968
+ marginTop: 16,
969
+ padding: "14px 18px",
970
+ background: theme.redDim,
971
+ border: `1px solid ${theme.red}44`,
972
+ borderRadius: 8,
973
+ fontFamily: theme.sans,
974
+ fontSize: 12,
975
+ }}
976
+ >
977
+ <p style={{ color: theme.text, margin: "0 0 10px 0" }}>
978
+ Permanently delete{" "}
979
+ <strong style={{ fontFamily: theme.mono }}>{confirmDelete}</strong> from{" "}
980
+ <strong>{env}</strong>? This cannot be undone.
981
+ </p>
982
+ {isProduction && (
983
+ <p
984
+ style={{
985
+ color: theme.red,
986
+ margin: "0 0 10px 0",
987
+ fontSize: 12,
988
+ fontWeight: 600,
989
+ }}
990
+ >
991
+ {"\uD83D\uDD12"} This is a protected environment.
992
+ </p>
993
+ )}
994
+ <div style={{ display: "flex", gap: 8 }}>
995
+ <Button
996
+ variant="danger"
997
+ data-testid="confirm-delete-yes"
998
+ onClick={() => handleDelete(confirmDelete)}
999
+ >
1000
+ Delete
1001
+ </Button>
1002
+ <Button data-testid="confirm-delete-no" onClick={() => setConfirmDelete(null)}>
1003
+ Cancel
1004
+ </Button>
1005
+ </div>
1006
+ </div>
1007
+ )}
1008
+
1009
+ {/* Protected environment confirmation dialog */}
1010
+ {protectedConfirm && (
1011
+ <div
1012
+ data-testid="confirm-protected-dialog"
1013
+ style={{
1014
+ marginTop: 16,
1015
+ padding: "14px 18px",
1016
+ background: theme.redDim,
1017
+ border: `1px solid ${theme.red}44`,
1018
+ borderRadius: 8,
1019
+ fontFamily: theme.sans,
1020
+ fontSize: 12,
1021
+ }}
1022
+ >
1023
+ <p style={{ color: theme.text, margin: "0 0 10px 0" }}>
1024
+ {"\uD83D\uDD12"}{" "}
1025
+ <strong style={{ color: theme.red }}>Protected environment.</strong> You are about
1026
+ to {protectedConfirm === "save" ? "commit changes to" : "add a key to"}{" "}
1027
+ <strong>{env}</strong>. Are you sure?
1028
+ </p>
1029
+ <div style={{ display: "flex", gap: 8 }}>
1030
+ <Button
1031
+ variant="primary"
1032
+ data-testid="confirm-protected-yes"
1033
+ onClick={async () => {
1034
+ const action = protectedConfirm;
1035
+ setProtectedConfirm(null);
1036
+ if (action === "save") {
1037
+ await handleSave(true);
1038
+ } else {
1039
+ await handleAdd(true);
1040
+ }
1041
+ }}
1042
+ >
1043
+ Confirm
1044
+ </Button>
1045
+ <Button
1046
+ data-testid="confirm-protected-no"
1047
+ onClick={() => setProtectedConfirm(null)}
1048
+ >
1049
+ Cancel
1050
+ </Button>
1051
+ </div>
1052
+ </div>
1053
+ )}
1054
+
1055
+ {/* Schema section */}
1056
+ <div style={{ marginTop: 24 }}>
1057
+ <div
1058
+ style={{
1059
+ fontFamily: theme.sans,
1060
+ fontSize: 11,
1061
+ fontWeight: 600,
1062
+ color: theme.textMuted,
1063
+ textTransform: "uppercase",
1064
+ letterSpacing: "0.08em",
1065
+ marginBottom: 10,
1066
+ }}
1067
+ >
1068
+ Schema {"\u00B7"} schemas/{ns}.yaml
1069
+ </div>
1070
+ <div
1071
+ data-testid="schema-summary"
1072
+ style={{
1073
+ background: theme.surface,
1074
+ border: `1px solid ${theme.border}`,
1075
+ borderRadius: 8,
1076
+ padding: "12px 16px",
1077
+ fontFamily: theme.mono,
1078
+ fontSize: 11,
1079
+ color: theme.textMuted,
1080
+ lineHeight: 1.7,
1081
+ }}
1082
+ >
1083
+ {(() => {
1084
+ const errors = lintIssues.filter((i) => i.severity === "error");
1085
+ const warnings = lintIssues.filter((i) => i.severity === "warning");
1086
+ if (errors.length === 0 && warnings.length === 0) {
1087
+ return (
1088
+ <>
1089
+ <span style={{ color: theme.green }}>{"\u2713"}</span> All required keys
1090
+ present &nbsp;{"\u00B7"}&nbsp;
1091
+ <span style={{ color: theme.green }}>{"\u2713"}</span> All types valid
1092
+ &nbsp;{"\u00B7"}&nbsp;
1093
+ <span style={{ color: theme.textDim }}>0 warnings</span>
1094
+ </>
1095
+ );
1096
+ }
1097
+ return (
1098
+ <>
1099
+ {errors.length > 0 && (
1100
+ <span style={{ color: theme.red }}>
1101
+ {errors.length} error{errors.length !== 1 ? "s" : ""}
1102
+ </span>
1103
+ )}
1104
+ {errors.length > 0 && warnings.length > 0 && (
1105
+ <span> &nbsp;{"\u00B7"}&nbsp; </span>
1106
+ )}
1107
+ {warnings.length > 0 && (
1108
+ <span style={{ color: theme.yellow }}>
1109
+ {warnings.length} warning{warnings.length !== 1 ? "s" : ""}
1110
+ </span>
1111
+ )}
1112
+ </>
1113
+ );
1114
+ })()}
1115
+ </div>
1116
+ </div>
1117
+ </>
1118
+ )}
1119
+ </div>
1120
+ </div>
1121
+ );
1122
+ }