@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,836 @@
1
+ import React, { useState, useEffect } 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 {
7
+ ClefManifest,
8
+ BackendType,
9
+ MigrationResult,
10
+ MigrationProgressEvent,
11
+ } from "@clef-sh/core";
12
+ import type { ViewName } from "../components/Sidebar";
13
+
14
+ interface BackendScreenProps {
15
+ manifest: ClefManifest | null;
16
+ setView: (view: ViewName) => void;
17
+ reloadManifest: () => void;
18
+ }
19
+
20
+ interface BackendConfigResponse {
21
+ global: {
22
+ default_backend: BackendType;
23
+ aws_kms_arn?: string;
24
+ gcp_kms_resource_id?: string;
25
+ azure_kv_url?: string;
26
+ pgp_fingerprint?: string;
27
+ };
28
+ environments: Array<{
29
+ name: string;
30
+ protected: boolean;
31
+ effective: { backend: BackendType };
32
+ hasOverride: boolean;
33
+ }>;
34
+ }
35
+
36
+ interface MigrationResponse {
37
+ success: boolean;
38
+ result: MigrationResult;
39
+ events: MigrationProgressEvent[];
40
+ }
41
+
42
+ const BACKEND_LABELS: Record<BackendType, string> = {
43
+ age: "age",
44
+ awskms: "AWS KMS",
45
+ gcpkms: "GCP KMS",
46
+ azurekv: "Azure Key Vault",
47
+ pgp: "PGP",
48
+ cloud: "Cloud KMS",
49
+ };
50
+
51
+ const KEY_PLACEHOLDERS: Record<string, string> = {
52
+ awskms: "arn:aws:kms:region:account:key/id",
53
+ gcpkms: "projects/.../locations/.../keyRings/.../cryptoKeys/...",
54
+ azurekv: "https://vault-name.vault.azure.net/keys/key-name/version",
55
+ pgp: "PGP fingerprint",
56
+ };
57
+
58
+ const ALL_BACKENDS: BackendType[] = ["age", "awskms", "gcpkms", "azurekv", "pgp"];
59
+ export function BackendScreen({ manifest, setView, reloadManifest }: BackendScreenProps) {
60
+ const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
61
+ const [config, setConfig] = useState<BackendConfigResponse | null>(null);
62
+
63
+ // Step 1 state
64
+ const [targetBackend, setTargetBackend] = useState<BackendType>("age");
65
+ const [targetKey, setTargetKey] = useState("");
66
+ const [scope, setScope] = useState<"all" | "single">("all");
67
+ const [selectedEnv, setSelectedEnv] = useState("");
68
+
69
+ // Step 2 state
70
+ const [previewResult, setPreviewResult] = useState<MigrationResponse | null>(null);
71
+ const [needsConfirmation, setNeedsConfirmation] = useState(false);
72
+ const [confirmed, setConfirmed] = useState(false);
73
+
74
+ // Step 3/4 state
75
+ const [applyResult, setApplyResult] = useState<MigrationResponse | null>(null);
76
+
77
+ const [loading, setLoading] = useState(false);
78
+ const [error, setError] = useState<string | null>(null);
79
+
80
+ useEffect(() => {
81
+ loadConfig();
82
+ }, []);
83
+
84
+ useEffect(() => {
85
+ if (manifest && manifest.environments.length > 0 && !selectedEnv) {
86
+ setSelectedEnv(manifest.environments[0].name);
87
+ }
88
+ }, [manifest, selectedEnv]);
89
+
90
+ const loadConfig = async () => {
91
+ try {
92
+ const res = await apiFetch("/api/backend-config");
93
+ if (res.ok) {
94
+ setConfig(await res.json());
95
+ }
96
+ } catch {
97
+ // Silently fail
98
+ }
99
+ };
100
+
101
+ const handlePreview = async (withConfirmation = false) => {
102
+ setLoading(true);
103
+ setError(null);
104
+
105
+ const body = {
106
+ target: {
107
+ backend: targetBackend,
108
+ key: targetBackend !== "age" ? targetKey : undefined,
109
+ },
110
+ environment: scope === "single" ? selectedEnv : undefined,
111
+ confirmed: withConfirmation || undefined,
112
+ };
113
+
114
+ try {
115
+ const res = await apiFetch("/api/migrate-backend/preview", {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify(body),
119
+ });
120
+
121
+ if (res.status === 409) {
122
+ setNeedsConfirmation(true);
123
+ setLoading(false);
124
+ return;
125
+ }
126
+
127
+ if (!res.ok) {
128
+ const data = await res.json();
129
+ setError(data.error ?? "Preview failed");
130
+ setLoading(false);
131
+ return;
132
+ }
133
+
134
+ const data: MigrationResponse = await res.json();
135
+ setPreviewResult(data);
136
+ setNeedsConfirmation(false);
137
+ setConfirmed(false);
138
+ setStep(2);
139
+ } catch (err) {
140
+ setError(err instanceof Error ? err.message : "Preview failed");
141
+ } finally {
142
+ setLoading(false);
143
+ }
144
+ };
145
+
146
+ const handleApply = async () => {
147
+ setStep(3);
148
+ setError(null);
149
+
150
+ const body = {
151
+ target: {
152
+ backend: targetBackend,
153
+ key: targetBackend !== "age" ? targetKey : undefined,
154
+ },
155
+ environment: scope === "single" ? selectedEnv : undefined,
156
+ confirmed: true,
157
+ };
158
+
159
+ try {
160
+ const res = await apiFetch("/api/migrate-backend/apply", {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify(body),
164
+ });
165
+
166
+ if (!res.ok) {
167
+ const data = await res.json();
168
+ setError(data.error ?? "Migration failed");
169
+ setStep(2);
170
+ return;
171
+ }
172
+
173
+ const data: MigrationResponse = await res.json();
174
+ setApplyResult(data);
175
+ reloadManifest();
176
+ setStep(4);
177
+ } catch (err) {
178
+ setError(err instanceof Error ? err.message : "Migration failed");
179
+ setStep(2);
180
+ }
181
+ };
182
+
183
+ const handleReset = () => {
184
+ setStep(1);
185
+ setPreviewResult(null);
186
+ setApplyResult(null);
187
+ setNeedsConfirmation(false);
188
+ setConfirmed(false);
189
+ setError(null);
190
+ loadConfig();
191
+ };
192
+
193
+ const environments = manifest?.environments ?? [];
194
+ const migrateCount =
195
+ previewResult?.events.filter((e) => e.type === "info" && e.message.startsWith("Would"))
196
+ .length ?? 0;
197
+
198
+ return (
199
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
200
+ <TopBar title="Backend" subtitle="clef migrate-backend — change encryption backend" />
201
+
202
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
203
+ <div style={{ maxWidth: 620, margin: "0 auto" }}>
204
+ {/* Step indicator */}
205
+ <div
206
+ style={{
207
+ display: "flex",
208
+ alignItems: "center",
209
+ gap: 0,
210
+ marginBottom: 32,
211
+ }}
212
+ >
213
+ {([1, 2, 3, 4] as const).map((s, i) => (
214
+ <React.Fragment key={s}>
215
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
216
+ <div
217
+ style={{
218
+ width: 24,
219
+ height: 24,
220
+ borderRadius: "50%",
221
+ background: step >= s ? theme.accent : theme.surface,
222
+ border: `1px solid ${step >= s ? theme.accent : theme.border}`,
223
+ display: "flex",
224
+ alignItems: "center",
225
+ justifyContent: "center",
226
+ fontFamily: theme.mono,
227
+ fontSize: 11,
228
+ fontWeight: 700,
229
+ color: step >= s ? "#000" : theme.textDim,
230
+ }}
231
+ >
232
+ {s}
233
+ </div>
234
+ <span
235
+ style={{
236
+ fontFamily: theme.sans,
237
+ fontSize: 12,
238
+ color: step >= s ? theme.text : theme.textDim,
239
+ fontWeight: step === s ? 600 : 400,
240
+ }}
241
+ >
242
+ {s === 1 ? "Configure" : s === 2 ? "Preview" : s === 3 ? "Migrate" : "Done"}
243
+ </span>
244
+ </div>
245
+ {i < 3 && (
246
+ <div
247
+ style={{
248
+ flex: 1,
249
+ height: 1,
250
+ background: step > s ? theme.accent : theme.border,
251
+ margin: "0 12px",
252
+ minWidth: 20,
253
+ }}
254
+ />
255
+ )}
256
+ </React.Fragment>
257
+ ))}
258
+ </div>
259
+
260
+ {error && (
261
+ <div
262
+ style={{
263
+ background: theme.redDim,
264
+ border: `1px solid ${theme.red}44`,
265
+ borderRadius: 8,
266
+ padding: "12px 16px",
267
+ marginBottom: 16,
268
+ fontFamily: theme.sans,
269
+ fontSize: 13,
270
+ color: theme.red,
271
+ }}
272
+ >
273
+ {error}
274
+ </div>
275
+ )}
276
+
277
+ {/* ── Step 1: Configure ─────────────────────────────────────── */}
278
+ {step === 1 && (
279
+ <div>
280
+ {/* Current config */}
281
+ {config && (
282
+ <div style={{ marginBottom: 24 }}>
283
+ <Label>Current Configuration</Label>
284
+ <div
285
+ style={{
286
+ background: theme.surface,
287
+ border: `1px solid ${theme.border}`,
288
+ borderRadius: 8,
289
+ padding: 14,
290
+ }}
291
+ >
292
+ <div
293
+ style={{
294
+ fontFamily: theme.mono,
295
+ fontSize: 12,
296
+ color: theme.text,
297
+ marginBottom: 8,
298
+ }}
299
+ >
300
+ Default backend:{" "}
301
+ <span style={{ color: theme.accent, fontWeight: 600 }}>
302
+ {BACKEND_LABELS[config.global.default_backend]}
303
+ </span>
304
+ </div>
305
+ {config.environments.map((env) => (
306
+ <div
307
+ key={env.name}
308
+ style={{
309
+ display: "flex",
310
+ alignItems: "center",
311
+ gap: 8,
312
+ fontFamily: theme.mono,
313
+ fontSize: 11,
314
+ color: theme.textMuted,
315
+ marginBottom: 2,
316
+ }}
317
+ >
318
+ <span>
319
+ {env.protected ? "\uD83D\uDD12 " : ""}
320
+ {env.name}
321
+ </span>
322
+ <span style={{ color: theme.textDim }}>{"\u2192"}</span>
323
+ <span style={{ color: env.hasOverride ? theme.yellow : theme.textMuted }}>
324
+ {BACKEND_LABELS[env.effective.backend]}
325
+ {env.hasOverride ? " (override)" : ""}
326
+ </span>
327
+ </div>
328
+ ))}
329
+ </div>
330
+ </div>
331
+ )}
332
+
333
+ {/* Target backend */}
334
+ <div style={{ marginBottom: 20 }}>
335
+ <Label>Target Backend</Label>
336
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
337
+ {ALL_BACKENDS.map((b) => (
338
+ <label
339
+ key={b}
340
+ style={{
341
+ display: "flex",
342
+ alignItems: "center",
343
+ gap: 8,
344
+ cursor: "pointer",
345
+ fontFamily: theme.sans,
346
+ fontSize: 13,
347
+ color: targetBackend === b ? theme.text : theme.textMuted,
348
+ }}
349
+ >
350
+ <input
351
+ type="radio"
352
+ name="backend"
353
+ value={b}
354
+ checked={targetBackend === b}
355
+ onChange={() => {
356
+ setTargetBackend(b);
357
+ setTargetKey("");
358
+ }}
359
+ style={{ accentColor: theme.accent }}
360
+ data-testid={`backend-radio-${b}`}
361
+ />
362
+ {BACKEND_LABELS[b]}
363
+ </label>
364
+ ))}
365
+ </div>
366
+ </div>
367
+
368
+ {/* Key input (non-age) */}
369
+ {targetBackend !== "age" && (
370
+ <div style={{ marginBottom: 20 }}>
371
+ <Label>Key Identifier</Label>
372
+ <input
373
+ type="text"
374
+ value={targetKey}
375
+ onChange={(e) => setTargetKey(e.target.value)}
376
+ placeholder={KEY_PLACEHOLDERS[targetBackend]}
377
+ data-testid="backend-key-input"
378
+ style={{
379
+ width: "100%",
380
+ background: theme.surface,
381
+ border: `1px solid ${theme.border}`,
382
+ borderRadius: 6,
383
+ padding: "8px 12px",
384
+ fontFamily: theme.mono,
385
+ fontSize: 12,
386
+ color: theme.text,
387
+ outline: "none",
388
+ boxSizing: "border-box",
389
+ }}
390
+ />
391
+ </div>
392
+ )}
393
+
394
+ {/* Scope */}
395
+ <div style={{ marginBottom: 24 }}>
396
+ <Label>Scope</Label>
397
+ <div style={{ display: "flex", gap: 16, marginBottom: 8 }}>
398
+ <label
399
+ style={{
400
+ display: "flex",
401
+ alignItems: "center",
402
+ gap: 6,
403
+ cursor: "pointer",
404
+ fontFamily: theme.sans,
405
+ fontSize: 13,
406
+ color: scope === "all" ? theme.text : theme.textMuted,
407
+ }}
408
+ >
409
+ <input
410
+ type="radio"
411
+ name="scope"
412
+ checked={scope === "all"}
413
+ onChange={() => setScope("all")}
414
+ style={{ accentColor: theme.accent }}
415
+ />
416
+ All environments
417
+ </label>
418
+ <label
419
+ style={{
420
+ display: "flex",
421
+ alignItems: "center",
422
+ gap: 6,
423
+ cursor: "pointer",
424
+ fontFamily: theme.sans,
425
+ fontSize: 13,
426
+ color: scope === "single" ? theme.text : theme.textMuted,
427
+ }}
428
+ >
429
+ <input
430
+ type="radio"
431
+ name="scope"
432
+ checked={scope === "single"}
433
+ onChange={() => setScope("single")}
434
+ style={{ accentColor: theme.accent }}
435
+ />
436
+ Single environment
437
+ </label>
438
+ </div>
439
+ {scope === "single" && (
440
+ <select
441
+ value={selectedEnv}
442
+ onChange={(e) => setSelectedEnv(e.target.value)}
443
+ data-testid="env-select"
444
+ style={{
445
+ width: "100%",
446
+ background: theme.surface,
447
+ border: `1px solid ${theme.border}`,
448
+ borderRadius: 6,
449
+ padding: "7px 10px",
450
+ fontFamily: theme.sans,
451
+ fontSize: 13,
452
+ color: theme.text,
453
+ outline: "none",
454
+ cursor: "pointer",
455
+ }}
456
+ >
457
+ {environments.map((env) => (
458
+ <option key={env.name} value={env.name}>
459
+ {env.name}
460
+ {env.protected ? " (protected)" : ""}
461
+ </option>
462
+ ))}
463
+ </select>
464
+ )}
465
+ </div>
466
+
467
+ {/* Protected env confirmation */}
468
+ {needsConfirmation && (
469
+ <div
470
+ style={{
471
+ background: theme.yellowDim,
472
+ border: `1px solid ${theme.yellow}44`,
473
+ borderRadius: 8,
474
+ padding: "12px 16px",
475
+ marginBottom: 16,
476
+ }}
477
+ >
478
+ <div
479
+ style={{
480
+ fontFamily: theme.sans,
481
+ fontSize: 13,
482
+ color: theme.yellow,
483
+ marginBottom: 8,
484
+ }}
485
+ >
486
+ This migration affects protected environments.
487
+ </div>
488
+ <label
489
+ style={{
490
+ display: "flex",
491
+ alignItems: "center",
492
+ gap: 8,
493
+ cursor: "pointer",
494
+ fontFamily: theme.sans,
495
+ fontSize: 12,
496
+ color: theme.text,
497
+ }}
498
+ >
499
+ <input
500
+ type="checkbox"
501
+ checked={confirmed}
502
+ onChange={(e) => setConfirmed(e.target.checked)}
503
+ style={{ accentColor: theme.yellow }}
504
+ data-testid="protected-confirm"
505
+ />
506
+ I understand and want to proceed
507
+ </label>
508
+ </div>
509
+ )}
510
+
511
+ <Button
512
+ variant="primary"
513
+ onClick={() => handlePreview(confirmed)}
514
+ disabled={loading || (targetBackend !== "age" && !targetKey.trim())}
515
+ >
516
+ {loading
517
+ ? "Loading..."
518
+ : needsConfirmation && confirmed
519
+ ? "Confirm & Preview"
520
+ : "Preview"}
521
+ </Button>
522
+ </div>
523
+ )}
524
+
525
+ {/* ── Step 2: Preview ───────────────────────────────────────── */}
526
+ {step === 2 && previewResult && (
527
+ <div>
528
+ <div
529
+ style={{
530
+ fontFamily: theme.sans,
531
+ fontSize: 13,
532
+ color: theme.textMuted,
533
+ marginBottom: 20,
534
+ }}
535
+ >
536
+ Migrating to{" "}
537
+ <span style={{ color: theme.accent, fontWeight: 600 }}>
538
+ {BACKEND_LABELS[targetBackend]}
539
+ </span>
540
+ {scope === "single" ? ` (${selectedEnv} only)` : " (all environments)"}
541
+ </div>
542
+
543
+ {/* Events / files to migrate */}
544
+ {previewResult.events.filter((e) => e.type === "info").length > 0 && (
545
+ <div style={{ marginBottom: 16 }}>
546
+ <SectionLabel color={theme.green}>
547
+ Files to migrate ({previewResult.events.filter((e) => e.type === "info").length}
548
+ )
549
+ </SectionLabel>
550
+ {previewResult.events
551
+ .filter((e) => e.type === "info")
552
+ .map((e, i) => (
553
+ <FileRow key={i} icon={"\u2192"} iconColor={theme.green} label={e.message} />
554
+ ))}
555
+ </div>
556
+ )}
557
+
558
+ {/* Skipped files */}
559
+ {previewResult.events.filter((e) => e.type === "skip").length > 0 && (
560
+ <div style={{ marginBottom: 16 }}>
561
+ <SectionLabel color={theme.textDim}>
562
+ Already on target (
563
+ {previewResult.events.filter((e) => e.type === "skip").length})
564
+ </SectionLabel>
565
+ {previewResult.events
566
+ .filter((e) => e.type === "skip")
567
+ .map((e, i) => (
568
+ <FileRow
569
+ key={i}
570
+ icon={"\u21B7"}
571
+ iconColor={theme.textDim}
572
+ label={e.message}
573
+ />
574
+ ))}
575
+ </div>
576
+ )}
577
+
578
+ {/* Warnings */}
579
+ {previewResult.result.warnings.length > 0 && (
580
+ <div style={{ marginBottom: 16 }}>
581
+ {previewResult.result.warnings.map((w, i) => (
582
+ <div
583
+ key={i}
584
+ style={{
585
+ fontFamily: theme.mono,
586
+ fontSize: 11,
587
+ color: theme.yellow,
588
+ marginBottom: 4,
589
+ }}
590
+ >
591
+ {"\u26A0"} {w}
592
+ </div>
593
+ ))}
594
+ </div>
595
+ )}
596
+
597
+ <div style={{ display: "flex", gap: 10, marginTop: 24 }}>
598
+ <Button variant="ghost" onClick={handleReset}>
599
+ Back
600
+ </Button>
601
+ <Button
602
+ variant="primary"
603
+ onClick={handleApply}
604
+ disabled={migrateCount === 0}
605
+ data-testid="apply-button"
606
+ >
607
+ Migrate {migrateCount} file{migrateCount !== 1 ? "s" : ""}
608
+ </Button>
609
+ </div>
610
+ </div>
611
+ )}
612
+
613
+ {/* ── Step 3: Executing ─────────────────────────────────────── */}
614
+ {step === 3 && (
615
+ <div
616
+ style={{
617
+ display: "flex",
618
+ flexDirection: "column",
619
+ alignItems: "center",
620
+ paddingTop: 40,
621
+ }}
622
+ >
623
+ <div
624
+ style={{
625
+ width: 40,
626
+ height: 40,
627
+ border: `3px solid ${theme.border}`,
628
+ borderTopColor: theme.accent,
629
+ borderRadius: "50%",
630
+ animation: "spin 1s linear infinite",
631
+ marginBottom: 16,
632
+ }}
633
+ />
634
+ <div
635
+ style={{
636
+ fontFamily: theme.sans,
637
+ fontSize: 14,
638
+ color: theme.textMuted,
639
+ }}
640
+ >
641
+ Migrating... this may take a moment
642
+ </div>
643
+ <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
644
+ </div>
645
+ )}
646
+
647
+ {/* ── Step 4: Result ────────────────────────────────────────── */}
648
+ {step === 4 && applyResult && (
649
+ <div>
650
+ <div
651
+ style={{
652
+ display: "flex",
653
+ flexDirection: "column",
654
+ alignItems: "center",
655
+ paddingTop: 20,
656
+ paddingBottom: 32,
657
+ }}
658
+ >
659
+ <div
660
+ style={{
661
+ width: 56,
662
+ height: 56,
663
+ borderRadius: "50%",
664
+ background: applyResult.result.rolledBack ? theme.redDim : theme.greenDim,
665
+ border: `1px solid ${applyResult.result.rolledBack ? theme.red + "44" : theme.green + "44"}`,
666
+ display: "flex",
667
+ alignItems: "center",
668
+ justifyContent: "center",
669
+ fontSize: 24,
670
+ color: applyResult.result.rolledBack ? theme.red : theme.green,
671
+ marginBottom: 16,
672
+ }}
673
+ >
674
+ {applyResult.result.rolledBack ? "\u26A0" : "\u2713"}
675
+ </div>
676
+
677
+ <div
678
+ style={{
679
+ fontFamily: theme.sans,
680
+ fontWeight: 600,
681
+ fontSize: 16,
682
+ color: applyResult.result.rolledBack ? theme.red : theme.green,
683
+ marginBottom: 8,
684
+ }}
685
+ >
686
+ {applyResult.result.rolledBack ? "Migration failed" : "Migration complete"}
687
+ </div>
688
+
689
+ {applyResult.result.rolledBack && applyResult.result.error && (
690
+ <div
691
+ style={{
692
+ fontFamily: theme.mono,
693
+ fontSize: 12,
694
+ color: theme.red,
695
+ marginBottom: 8,
696
+ textAlign: "center",
697
+ }}
698
+ >
699
+ {applyResult.result.error}
700
+ </div>
701
+ )}
702
+
703
+ {applyResult.result.rolledBack && (
704
+ <div
705
+ style={{
706
+ fontFamily: theme.sans,
707
+ fontSize: 12,
708
+ color: theme.textMuted,
709
+ marginBottom: 8,
710
+ }}
711
+ >
712
+ All changes have been rolled back.
713
+ </div>
714
+ )}
715
+
716
+ {!applyResult.result.rolledBack && (
717
+ <div
718
+ style={{
719
+ fontFamily: theme.mono,
720
+ fontSize: 12,
721
+ color: theme.textMuted,
722
+ }}
723
+ >
724
+ {applyResult.result.migratedFiles.length} migrated,{" "}
725
+ {applyResult.result.skippedFiles.length} skipped,{" "}
726
+ {applyResult.result.verifiedFiles.length} verified
727
+ </div>
728
+ )}
729
+ </div>
730
+
731
+ {/* Warnings */}
732
+ {applyResult.result.warnings.length > 0 && (
733
+ <div style={{ marginBottom: 16 }}>
734
+ {applyResult.result.warnings.map((w, i) => (
735
+ <div
736
+ key={i}
737
+ style={{
738
+ fontFamily: theme.mono,
739
+ fontSize: 11,
740
+ color: theme.yellow,
741
+ marginBottom: 4,
742
+ }}
743
+ >
744
+ {"\u26A0"} {w}
745
+ </div>
746
+ ))}
747
+ </div>
748
+ )}
749
+
750
+ {!applyResult.result.rolledBack && (
751
+ <div
752
+ style={{
753
+ background: theme.surface,
754
+ border: `1px solid ${theme.border}`,
755
+ borderRadius: 6,
756
+ padding: "10px 14px",
757
+ marginBottom: 24,
758
+ fontFamily: theme.mono,
759
+ fontSize: 11,
760
+ color: theme.textMuted,
761
+ }}
762
+ >
763
+ git add clef.yaml .sops.yaml secrets/ && git commit -m "chore: migrate backend to{" "}
764
+ {targetBackend}"
765
+ </div>
766
+ )}
767
+
768
+ <div style={{ display: "flex", gap: 10 }}>
769
+ <Button variant="primary" onClick={() => setView("matrix")}>
770
+ View in Matrix
771
+ </Button>
772
+ <Button variant="ghost" onClick={handleReset}>
773
+ Migrate again
774
+ </Button>
775
+ </div>
776
+ </div>
777
+ )}
778
+ </div>
779
+ </div>
780
+ </div>
781
+ );
782
+ }
783
+
784
+ function Label({ children }: { children: React.ReactNode }) {
785
+ return (
786
+ <div
787
+ style={{
788
+ fontFamily: theme.sans,
789
+ fontSize: 12,
790
+ fontWeight: 600,
791
+ color: theme.textMuted,
792
+ marginBottom: 8,
793
+ letterSpacing: "0.05em",
794
+ textTransform: "uppercase",
795
+ }}
796
+ >
797
+ {children}
798
+ </div>
799
+ );
800
+ }
801
+
802
+ function SectionLabel({ children, color }: { children: React.ReactNode; color: string }) {
803
+ return (
804
+ <div
805
+ style={{
806
+ fontFamily: theme.sans,
807
+ fontSize: 11,
808
+ fontWeight: 600,
809
+ color,
810
+ letterSpacing: "0.06em",
811
+ textTransform: "uppercase",
812
+ marginBottom: 8,
813
+ }}
814
+ >
815
+ {children}
816
+ </div>
817
+ );
818
+ }
819
+
820
+ function FileRow({ icon, iconColor, label }: { icon: string; iconColor: string; label: string }) {
821
+ return (
822
+ <div
823
+ style={{
824
+ display: "flex",
825
+ alignItems: "center",
826
+ gap: 10,
827
+ padding: "5px 10px",
828
+ borderRadius: 6,
829
+ marginBottom: 3,
830
+ }}
831
+ >
832
+ <span style={{ color: iconColor, fontFamily: theme.mono, fontSize: 13 }}>{icon}</span>
833
+ <span style={{ fontFamily: theme.mono, fontSize: 12, color: theme.text }}>{label}</span>
834
+ </div>
835
+ );
836
+ }