@clef-sh/ui 0.1.14 → 0.1.15-beta.97

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.
@@ -0,0 +1,614 @@
1
+ import React, { useState } from "react";
2
+ import { theme } from "../theme";
3
+ import { apiFetch } from "../api";
4
+ import { TopBar } from "../components/TopBar";
5
+ import { Button } from "../components/Button";
6
+ import type { BackendType, ClefManifest, ResetResult, ResetScope } from "@clef-sh/core";
7
+ import type { ViewName } from "../components/Sidebar";
8
+
9
+ interface ResetScreenProps {
10
+ manifest: ClefManifest | null;
11
+ setView: (view: ViewName) => void;
12
+ reloadManifest: () => void;
13
+ }
14
+
15
+ interface ResetResponse {
16
+ success: boolean;
17
+ result: ResetResult;
18
+ }
19
+
20
+ const ALL_BACKENDS = ["age", "awskms", "gcpkms", "azurekv", "pgp"] as const;
21
+ type SelectableBackend = (typeof ALL_BACKENDS)[number];
22
+
23
+ const BACKEND_LABELS: Record<SelectableBackend, string> = {
24
+ age: "age",
25
+ awskms: "AWS KMS",
26
+ gcpkms: "GCP KMS",
27
+ azurekv: "Azure Key Vault",
28
+ pgp: "PGP",
29
+ };
30
+
31
+ const KEY_PLACEHOLDERS: Record<string, string> = {
32
+ awskms: "arn:aws:kms:region:account:key/id",
33
+ gcpkms: "projects/.../locations/.../keyRings/.../cryptoKeys/...",
34
+ azurekv: "https://vault-name.vault.azure.net/keys/key-name/version",
35
+ pgp: "PGP fingerprint",
36
+ };
37
+
38
+ type ScopeKind = "env" | "namespace" | "cell";
39
+ type Phase = "idle" | "running" | "done";
40
+
41
+ export function ResetScreen({ manifest, setView, reloadManifest }: ResetScreenProps) {
42
+ const environments = manifest?.environments ?? [];
43
+ const namespaces = manifest?.namespaces ?? [];
44
+ const firstEnv = environments[0]?.name ?? "";
45
+ const firstNs = namespaces[0]?.name ?? "";
46
+
47
+ const [phase, setPhase] = useState<Phase>("idle");
48
+ const [error, setError] = useState<string | null>(null);
49
+
50
+ const [scopeKind, setScopeKind] = useState<ScopeKind>("env");
51
+ const [envName, setEnvName] = useState(firstEnv);
52
+ const [namespaceName, setNamespaceName] = useState(firstNs);
53
+ const [cellNs, setCellNs] = useState(firstNs);
54
+ const [cellEnv, setCellEnv] = useState(firstEnv);
55
+
56
+ const [switchBackend, setSwitchBackend] = useState(false);
57
+ const [targetBackend, setTargetBackend] = useState<BackendType>("age");
58
+ const [targetKey, setTargetKey] = useState("");
59
+
60
+ const [explicitKeys, setExplicitKeys] = useState("");
61
+ const [typedConfirm, setTypedConfirm] = useState("");
62
+ const [result, setResult] = useState<ResetResult | null>(null);
63
+
64
+ // Mirrors core's `describeScope()` so the typed confirmation works without
65
+ // a server round-trip.
66
+ const scope: ResetScope | null =
67
+ scopeKind === "env"
68
+ ? envName
69
+ ? { kind: "env", name: envName }
70
+ : null
71
+ : scopeKind === "namespace"
72
+ ? namespaceName
73
+ ? { kind: "namespace", name: namespaceName }
74
+ : null
75
+ : cellNs && cellEnv
76
+ ? { kind: "cell", namespace: cellNs, environment: cellEnv }
77
+ : null;
78
+
79
+ const scopeLabel = !scope
80
+ ? ""
81
+ : scope.kind === "env"
82
+ ? `env ${scope.name}`
83
+ : scope.kind === "namespace"
84
+ ? `namespace ${scope.name}`
85
+ : `${scope.namespace}/${scope.environment}`;
86
+
87
+ const backendKeyMissing =
88
+ switchBackend && targetBackend !== "age" && targetKey.trim().length === 0;
89
+ const confirmMatches = typedConfirm === scopeLabel && scopeLabel.length > 0;
90
+ const canSubmit = scope !== null && confirmMatches && !backendKeyMissing && phase === "idle";
91
+
92
+ const pendingCount = result
93
+ ? Object.values(result.pendingKeysByCell).reduce((sum, keys) => sum + keys.length, 0)
94
+ : 0;
95
+
96
+ const handleReset = async () => {
97
+ if (!scope) return;
98
+ setPhase("running");
99
+ setError(null);
100
+
101
+ const explicitKeysList = explicitKeys
102
+ .split(",")
103
+ .map((k) => k.trim())
104
+ .filter((k) => k.length > 0);
105
+
106
+ const body: {
107
+ scope: ResetScope;
108
+ backend?: BackendType;
109
+ key?: string;
110
+ keys?: string[];
111
+ } = { scope };
112
+ if (switchBackend) {
113
+ body.backend = targetBackend;
114
+ if (targetBackend !== "age") body.key = targetKey.trim();
115
+ }
116
+ if (explicitKeysList.length > 0) {
117
+ body.keys = explicitKeysList;
118
+ }
119
+
120
+ try {
121
+ const res = await apiFetch("/api/reset", {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify(body),
125
+ });
126
+
127
+ if (!res.ok) {
128
+ const data = (await res.json().catch(() => ({ error: "Reset failed" }))) as {
129
+ error?: string;
130
+ };
131
+ setError(data.error ?? "Reset failed");
132
+ setPhase("idle");
133
+ return;
134
+ }
135
+
136
+ const data: ResetResponse = await res.json();
137
+ setResult(data.result);
138
+ reloadManifest();
139
+ setPhase("done");
140
+ } catch (err) {
141
+ setError(err instanceof Error ? err.message : "Reset failed");
142
+ setPhase("idle");
143
+ }
144
+ };
145
+
146
+ const handleStartOver = () => {
147
+ setPhase("idle");
148
+ setResult(null);
149
+ setError(null);
150
+ setTypedConfirm("");
151
+ };
152
+
153
+ return (
154
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
155
+ <TopBar title="Reset" subtitle="clef reset — destructively scaffold fresh placeholders" />
156
+
157
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
158
+ <div style={{ maxWidth: 620, margin: "0 auto" }}>
159
+ {error && (
160
+ <div
161
+ data-testid="reset-error"
162
+ style={{
163
+ background: theme.redDim,
164
+ border: `1px solid ${theme.red}44`,
165
+ borderRadius: 8,
166
+ padding: "12px 16px",
167
+ marginBottom: 16,
168
+ fontFamily: theme.sans,
169
+ fontSize: 13,
170
+ color: theme.red,
171
+ }}
172
+ >
173
+ {error}
174
+ </div>
175
+ )}
176
+
177
+ {phase === "idle" && (
178
+ <div>
179
+ <div style={{ marginBottom: 20 }}>
180
+ <Label>Scope</Label>
181
+ <div style={{ display: "flex", gap: 16, marginBottom: 10 }}>
182
+ {(["env", "namespace", "cell"] as const).map((k) => (
183
+ <label
184
+ key={k}
185
+ style={{
186
+ display: "flex",
187
+ alignItems: "center",
188
+ gap: 6,
189
+ cursor: "pointer",
190
+ fontFamily: theme.sans,
191
+ fontSize: 13,
192
+ color: scopeKind === k ? theme.text : theme.textMuted,
193
+ }}
194
+ >
195
+ <input
196
+ type="radio"
197
+ name="reset-scope-kind"
198
+ checked={scopeKind === k}
199
+ onChange={() => {
200
+ setScopeKind(k);
201
+ setTypedConfirm("");
202
+ }}
203
+ data-testid={`reset-scope-${k}`}
204
+ style={{ accentColor: theme.accent }}
205
+ />
206
+ {k === "env" ? "Environment" : k === "namespace" ? "Namespace" : "Cell"}
207
+ </label>
208
+ ))}
209
+ </div>
210
+
211
+ {scopeKind === "env" && (
212
+ <select
213
+ value={envName}
214
+ onChange={(e) => {
215
+ setEnvName(e.target.value);
216
+ setTypedConfirm("");
217
+ }}
218
+ data-testid="reset-env-select"
219
+ style={selectStyle}
220
+ >
221
+ {environments.map((env) => (
222
+ <option key={env.name} value={env.name}>
223
+ {env.name}
224
+ {env.protected ? " (protected)" : ""}
225
+ </option>
226
+ ))}
227
+ </select>
228
+ )}
229
+
230
+ {scopeKind === "namespace" && (
231
+ <select
232
+ value={namespaceName}
233
+ onChange={(e) => {
234
+ setNamespaceName(e.target.value);
235
+ setTypedConfirm("");
236
+ }}
237
+ data-testid="reset-namespace-select"
238
+ style={selectStyle}
239
+ >
240
+ {namespaces.map((ns) => (
241
+ <option key={ns.name} value={ns.name}>
242
+ {ns.name}
243
+ </option>
244
+ ))}
245
+ </select>
246
+ )}
247
+
248
+ {scopeKind === "cell" && (
249
+ <div style={{ display: "flex", gap: 8 }}>
250
+ <select
251
+ value={cellNs}
252
+ onChange={(e) => {
253
+ setCellNs(e.target.value);
254
+ setTypedConfirm("");
255
+ }}
256
+ data-testid="reset-cell-namespace-select"
257
+ style={{ ...selectStyle, flex: 1 }}
258
+ >
259
+ {namespaces.map((ns) => (
260
+ <option key={ns.name} value={ns.name}>
261
+ {ns.name}
262
+ </option>
263
+ ))}
264
+ </select>
265
+ <select
266
+ value={cellEnv}
267
+ onChange={(e) => {
268
+ setCellEnv(e.target.value);
269
+ setTypedConfirm("");
270
+ }}
271
+ data-testid="reset-cell-env-select"
272
+ style={{ ...selectStyle, flex: 1 }}
273
+ >
274
+ {environments.map((env) => (
275
+ <option key={env.name} value={env.name}>
276
+ {env.name}
277
+ {env.protected ? " (protected)" : ""}
278
+ </option>
279
+ ))}
280
+ </select>
281
+ </div>
282
+ )}
283
+ </div>
284
+
285
+ <div style={{ marginBottom: 20 }}>
286
+ <label
287
+ style={{
288
+ display: "flex",
289
+ alignItems: "center",
290
+ gap: 8,
291
+ cursor: "pointer",
292
+ fontFamily: theme.sans,
293
+ fontSize: 13,
294
+ color: theme.text,
295
+ }}
296
+ >
297
+ <input
298
+ type="checkbox"
299
+ checked={switchBackend}
300
+ onChange={(e) => setSwitchBackend(e.target.checked)}
301
+ data-testid="reset-switch-backend"
302
+ style={{ accentColor: theme.accent }}
303
+ />
304
+ Switch backend as part of reset
305
+ </label>
306
+ {switchBackend && (
307
+ <div
308
+ style={{
309
+ marginTop: 12,
310
+ padding: 14,
311
+ background: theme.surface,
312
+ border: `1px solid ${theme.border}`,
313
+ borderRadius: 8,
314
+ }}
315
+ >
316
+ <Label>Target Backend</Label>
317
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
318
+ {ALL_BACKENDS.map((b) => (
319
+ <label
320
+ key={b}
321
+ style={{
322
+ display: "flex",
323
+ alignItems: "center",
324
+ gap: 8,
325
+ cursor: "pointer",
326
+ fontFamily: theme.sans,
327
+ fontSize: 13,
328
+ color: targetBackend === b ? theme.text : theme.textMuted,
329
+ }}
330
+ >
331
+ <input
332
+ type="radio"
333
+ name="reset-target-backend"
334
+ checked={targetBackend === b}
335
+ onChange={() => {
336
+ setTargetBackend(b);
337
+ setTargetKey("");
338
+ }}
339
+ data-testid={`reset-backend-radio-${b}`}
340
+ style={{ accentColor: theme.accent }}
341
+ />
342
+ {BACKEND_LABELS[b]}
343
+ </label>
344
+ ))}
345
+ </div>
346
+ {targetBackend !== "age" && (
347
+ <div style={{ marginTop: 12 }}>
348
+ <Label>Key Identifier</Label>
349
+ <input
350
+ type="text"
351
+ value={targetKey}
352
+ onChange={(e) => setTargetKey(e.target.value)}
353
+ placeholder={KEY_PLACEHOLDERS[targetBackend]}
354
+ data-testid="reset-backend-key-input"
355
+ style={textInputStyle}
356
+ />
357
+ </div>
358
+ )}
359
+ </div>
360
+ )}
361
+ </div>
362
+
363
+ {/* Optional explicit keys */}
364
+ <div style={{ marginBottom: 20 }}>
365
+ <Label>Explicit Keys (optional)</Label>
366
+ <input
367
+ type="text"
368
+ value={explicitKeys}
369
+ onChange={(e) => setExplicitKeys(e.target.value)}
370
+ placeholder="DB_URL, DB_PASSWORD"
371
+ data-testid="reset-keys-input"
372
+ style={textInputStyle}
373
+ />
374
+ <div
375
+ style={{
376
+ fontFamily: theme.sans,
377
+ fontSize: 11,
378
+ color: theme.textMuted,
379
+ marginTop: 6,
380
+ }}
381
+ >
382
+ Comma-separated. Ignored when the namespace has a schema — schema keys are
383
+ authoritative.
384
+ </div>
385
+ </div>
386
+
387
+ <div
388
+ style={{
389
+ background: theme.redDim,
390
+ border: `1px solid ${theme.red}44`,
391
+ borderRadius: 8,
392
+ padding: "14px 16px",
393
+ marginBottom: 16,
394
+ fontFamily: theme.sans,
395
+ fontSize: 13,
396
+ color: theme.red,
397
+ lineHeight: 1.5,
398
+ }}
399
+ >
400
+ {"\u26A0"} This will <strong>ABANDON</strong> the current encrypted contents of the
401
+ affected cells. Decryption will <strong>NOT</strong> be attempted. This cannot be
402
+ undone except via <code>git revert</code>.
403
+ </div>
404
+
405
+ <div style={{ marginBottom: 20 }}>
406
+ <Label>
407
+ Type <code style={{ color: theme.text }}>{scopeLabel || "<scope>"}</code> to
408
+ confirm
409
+ </Label>
410
+ <input
411
+ type="text"
412
+ value={typedConfirm}
413
+ onChange={(e) => setTypedConfirm(e.target.value)}
414
+ placeholder={scopeLabel}
415
+ data-testid="reset-confirm-input"
416
+ disabled={!scope}
417
+ style={textInputStyle}
418
+ />
419
+ </div>
420
+
421
+ <Button
422
+ variant="primary"
423
+ onClick={handleReset}
424
+ disabled={!canSubmit}
425
+ data-testid="reset-submit"
426
+ >
427
+ Reset {scopeLabel || "<scope>"}
428
+ </Button>
429
+ </div>
430
+ )}
431
+
432
+ {phase === "running" && (
433
+ <div
434
+ style={{
435
+ display: "flex",
436
+ flexDirection: "column",
437
+ alignItems: "center",
438
+ paddingTop: 40,
439
+ }}
440
+ data-testid="reset-running"
441
+ >
442
+ <div
443
+ style={{
444
+ width: 40,
445
+ height: 40,
446
+ border: `3px solid ${theme.border}`,
447
+ borderTopColor: theme.accent,
448
+ borderRadius: "50%",
449
+ animation: "spin 1s linear infinite",
450
+ marginBottom: 16,
451
+ }}
452
+ />
453
+ <div
454
+ style={{
455
+ fontFamily: theme.sans,
456
+ fontSize: 14,
457
+ color: theme.textMuted,
458
+ }}
459
+ >
460
+ Resetting {scopeLabel}...
461
+ </div>
462
+ <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
463
+ </div>
464
+ )}
465
+
466
+ {phase === "done" && result && (
467
+ <div data-testid="reset-done">
468
+ <div
469
+ style={{
470
+ display: "flex",
471
+ flexDirection: "column",
472
+ alignItems: "center",
473
+ paddingTop: 20,
474
+ paddingBottom: 24,
475
+ }}
476
+ >
477
+ <div
478
+ style={{
479
+ width: 56,
480
+ height: 56,
481
+ borderRadius: "50%",
482
+ background: theme.greenDim,
483
+ border: `1px solid ${theme.green}44`,
484
+ display: "flex",
485
+ alignItems: "center",
486
+ justifyContent: "center",
487
+ fontSize: 24,
488
+ color: theme.green,
489
+ marginBottom: 16,
490
+ }}
491
+ >
492
+ {"\u2713"}
493
+ </div>
494
+ <div
495
+ style={{
496
+ fontFamily: theme.sans,
497
+ fontWeight: 600,
498
+ fontSize: 16,
499
+ color: theme.green,
500
+ marginBottom: 8,
501
+ }}
502
+ >
503
+ Reset complete
504
+ </div>
505
+ <div
506
+ style={{
507
+ fontFamily: theme.mono,
508
+ fontSize: 12,
509
+ color: theme.textMuted,
510
+ }}
511
+ >
512
+ {result.scaffoldedCells.length} cell
513
+ {result.scaffoldedCells.length === 1 ? "" : "s"} scaffolded
514
+ {pendingCount > 0
515
+ ? `, ${pendingCount} pending placeholder${pendingCount === 1 ? "" : "s"}`
516
+ : ""}
517
+ </div>
518
+ </div>
519
+
520
+ {result.backendChanged && (
521
+ <div
522
+ style={{
523
+ background: theme.surface,
524
+ border: `1px solid ${theme.border}`,
525
+ borderRadius: 6,
526
+ padding: "10px 14px",
527
+ marginBottom: 16,
528
+ fontFamily: theme.mono,
529
+ fontSize: 11,
530
+ color: theme.textMuted,
531
+ }}
532
+ >
533
+ Backend override written for: {result.affectedEnvironments.join(", ")}
534
+ </div>
535
+ )}
536
+
537
+ {pendingCount > 0 && (
538
+ <div
539
+ style={{
540
+ fontFamily: theme.sans,
541
+ fontSize: 12,
542
+ color: theme.textMuted,
543
+ marginBottom: 16,
544
+ lineHeight: 1.5,
545
+ }}
546
+ >
547
+ Run <code>clef set</code> (or use the namespace editor) to replace placeholders
548
+ with real values.
549
+ </div>
550
+ )}
551
+
552
+ <div style={{ display: "flex", gap: 10 }}>
553
+ <Button
554
+ variant="primary"
555
+ onClick={() => setView("matrix")}
556
+ data-testid="reset-view-matrix"
557
+ >
558
+ View in Matrix
559
+ </Button>
560
+ <Button variant="ghost" onClick={handleStartOver} data-testid="reset-start-over">
561
+ Reset another
562
+ </Button>
563
+ </div>
564
+ </div>
565
+ )}
566
+ </div>
567
+ </div>
568
+ </div>
569
+ );
570
+ }
571
+
572
+ function Label({ children }: { children: React.ReactNode }) {
573
+ return (
574
+ <div
575
+ style={{
576
+ fontFamily: theme.sans,
577
+ fontSize: 12,
578
+ fontWeight: 600,
579
+ color: theme.textMuted,
580
+ marginBottom: 8,
581
+ letterSpacing: "0.05em",
582
+ textTransform: "uppercase",
583
+ }}
584
+ >
585
+ {children}
586
+ </div>
587
+ );
588
+ }
589
+
590
+ const selectStyle: React.CSSProperties = {
591
+ width: "100%",
592
+ background: theme.surface,
593
+ border: `1px solid ${theme.border}`,
594
+ borderRadius: 6,
595
+ padding: "7px 10px",
596
+ fontFamily: theme.sans,
597
+ fontSize: 13,
598
+ color: theme.text,
599
+ outline: "none",
600
+ cursor: "pointer",
601
+ };
602
+
603
+ const textInputStyle: React.CSSProperties = {
604
+ width: "100%",
605
+ background: theme.surface,
606
+ border: `1px solid ${theme.border}`,
607
+ borderRadius: 6,
608
+ padding: "8px 12px",
609
+ fontFamily: theme.mono,
610
+ fontSize: 12,
611
+ color: theme.text,
612
+ outline: "none",
613
+ boxSizing: "border-box",
614
+ };