@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,696 @@
1
+ import React, { useState, useEffect, useCallback, useRef } 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 { ClefManifest, Recipient, AgeKeyValidation } from "@clef-sh/core";
7
+ import type { ViewName } from "../components/Sidebar";
8
+
9
+ interface RecipientsScreenProps {
10
+ manifest: ClefManifest | null;
11
+ setView: (view: ViewName) => void;
12
+ }
13
+
14
+ export function RecipientsScreen({ manifest: _manifest, setView }: RecipientsScreenProps) {
15
+ const [recipients, setRecipients] = useState<Recipient[]>([]);
16
+ const [totalFiles, setTotalFiles] = useState(0);
17
+ const [showAddForm, setShowAddForm] = useState(false);
18
+ const [addKey, setAddKey] = useState("");
19
+ const [addLabel, setAddLabel] = useState("");
20
+ const [keyValidation, setKeyValidation] = useState<AgeKeyValidation | null>(null);
21
+ const [loading, setLoading] = useState(false);
22
+ const [error, setError] = useState<string | null>(null);
23
+
24
+ // Remove flow state
25
+ const [removeTarget, setRemoveTarget] = useState<Recipient | null>(null);
26
+ const [removeStep, setRemoveStep] = useState<0 | 1 | 2>(0);
27
+ const [acknowledgedWarning, setAcknowledgedWarning] = useState(false);
28
+
29
+ // Post-removal banner
30
+ const [removalBanner, setRemovalBanner] = useState<{
31
+ name: string;
32
+ targets: string[];
33
+ } | null>(null);
34
+
35
+ // Debounce timer ref for key validation
36
+ const validateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
37
+
38
+ const loadRecipients = useCallback(async () => {
39
+ try {
40
+ const res = await apiFetch("/api/recipients");
41
+ if (res.ok) {
42
+ const data = await res.json();
43
+ setRecipients(data.recipients);
44
+ setTotalFiles(data.totalFiles);
45
+ }
46
+ } catch {
47
+ // Silently fail — will show empty state
48
+ }
49
+ }, []);
50
+
51
+ useEffect(() => {
52
+ loadRecipients();
53
+ }, [loadRecipients]);
54
+
55
+ // Real-time key validation with debounce
56
+ useEffect(() => {
57
+ if (!addKey.trim()) {
58
+ setKeyValidation(null);
59
+ return;
60
+ }
61
+
62
+ if (validateTimerRef.current) {
63
+ clearTimeout(validateTimerRef.current);
64
+ }
65
+
66
+ validateTimerRef.current = setTimeout(async () => {
67
+ try {
68
+ const res = await apiFetch(
69
+ `/api/recipients/validate?key=${encodeURIComponent(addKey.trim())}`,
70
+ );
71
+ if (res.ok) {
72
+ const data: AgeKeyValidation = await res.json();
73
+ setKeyValidation(data);
74
+ }
75
+ } catch {
76
+ // Silently fail
77
+ }
78
+ }, 300);
79
+
80
+ return () => {
81
+ if (validateTimerRef.current) {
82
+ clearTimeout(validateTimerRef.current);
83
+ }
84
+ };
85
+ }, [addKey]);
86
+
87
+ const handleAdd = async () => {
88
+ if (!keyValidation?.valid) return;
89
+
90
+ setLoading(true);
91
+ setError(null);
92
+
93
+ try {
94
+ const res = await apiFetch("/api/recipients/add", {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({
98
+ key: addKey.trim(),
99
+ label: addLabel.trim() || undefined,
100
+ }),
101
+ });
102
+
103
+ if (!res.ok) {
104
+ const data = await res.json();
105
+ setError(data.error ?? "Failed to add recipient");
106
+ return;
107
+ }
108
+
109
+ const data = await res.json();
110
+ setRecipients(data.recipients);
111
+ setShowAddForm(false);
112
+ setAddKey("");
113
+ setAddLabel("");
114
+ setKeyValidation(null);
115
+ } catch (err) {
116
+ setError(err instanceof Error ? err.message : "Failed to add recipient");
117
+ } finally {
118
+ setLoading(false);
119
+ }
120
+ };
121
+
122
+ const startRemove = (recipient: Recipient) => {
123
+ setRemoveTarget(recipient);
124
+ setRemoveStep(1);
125
+ setAcknowledgedWarning(false);
126
+ };
127
+
128
+ const cancelRemove = () => {
129
+ setRemoveTarget(null);
130
+ setRemoveStep(0);
131
+ setAcknowledgedWarning(false);
132
+ };
133
+
134
+ const handleRemove = async () => {
135
+ if (!removeTarget) return;
136
+
137
+ setLoading(true);
138
+ setError(null);
139
+
140
+ try {
141
+ const res = await apiFetch("/api/recipients/remove", {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({ key: removeTarget.key }),
145
+ });
146
+
147
+ if (!res.ok) {
148
+ const data = await res.json();
149
+ setError(data.error ?? "Failed to remove recipient");
150
+ return;
151
+ }
152
+
153
+ const data = await res.json();
154
+ setRecipients(data.recipients);
155
+
156
+ // Show rotation reminder
157
+ const name = removeTarget.label ?? removeTarget.preview;
158
+ setRemovalBanner({
159
+ name,
160
+ targets: data.rotationReminder ?? [],
161
+ });
162
+
163
+ cancelRemove();
164
+ } catch (err) {
165
+ setError(err instanceof Error ? err.message : "Failed to remove recipient");
166
+ } finally {
167
+ setLoading(false);
168
+ }
169
+ };
170
+
171
+ return (
172
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
173
+ <TopBar
174
+ title="Recipients"
175
+ subtitle="clef recipients -- manage age encryption keys"
176
+ actions={
177
+ !showAddForm && removeStep === 0 ? (
178
+ <Button variant="primary" onClick={() => setShowAddForm(true)}>
179
+ + Add recipient
180
+ </Button>
181
+ ) : undefined
182
+ }
183
+ />
184
+
185
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
186
+ <div style={{ maxWidth: 620, margin: "0 auto" }}>
187
+ {/* Error banner */}
188
+ {error && (
189
+ <div
190
+ style={{
191
+ background: theme.redDim,
192
+ border: `1px solid ${theme.red}44`,
193
+ borderRadius: 8,
194
+ padding: "12px 16px",
195
+ marginBottom: 16,
196
+ fontFamily: theme.sans,
197
+ fontSize: 13,
198
+ color: theme.red,
199
+ }}
200
+ >
201
+ {error}
202
+ </div>
203
+ )}
204
+
205
+ {/* Post-removal rotation reminder banner */}
206
+ {removalBanner && (
207
+ <div
208
+ data-testid="rotation-banner"
209
+ style={{
210
+ background: theme.yellowDim,
211
+ border: `1px solid ${theme.yellow}44`,
212
+ borderRadius: 8,
213
+ padding: "14px 18px",
214
+ marginBottom: 20,
215
+ }}
216
+ >
217
+ <div
218
+ style={{
219
+ fontFamily: theme.sans,
220
+ fontSize: 13,
221
+ fontWeight: 600,
222
+ color: theme.yellow,
223
+ marginBottom: 8,
224
+ }}
225
+ >
226
+ Rotation reminder
227
+ </div>
228
+ <div
229
+ style={{
230
+ fontFamily: theme.sans,
231
+ fontSize: 13,
232
+ color: theme.text,
233
+ marginBottom: 10,
234
+ lineHeight: 1.5,
235
+ }}
236
+ >
237
+ <strong>{removalBanner.name}</strong> has been removed and files re-encrypted.
238
+ However, the removed key may still decrypt old versions of these files from git
239
+ history. Rotate secret values in the following targets to complete revocation:
240
+ </div>
241
+ {removalBanner.targets.length > 0 && (
242
+ <div
243
+ style={{
244
+ fontFamily: theme.mono,
245
+ fontSize: 11,
246
+ color: theme.textMuted,
247
+ marginBottom: 12,
248
+ paddingLeft: 12,
249
+ }}
250
+ >
251
+ {removalBanner.targets.map((t) => (
252
+ <div key={t} style={{ marginBottom: 2 }}>
253
+ {t}
254
+ </div>
255
+ ))}
256
+ </div>
257
+ )}
258
+ <div style={{ display: "flex", gap: 10 }}>
259
+ <Button variant="primary" onClick={() => setView("matrix")}>
260
+ Go to Matrix to rotate
261
+ </Button>
262
+ <Button variant="ghost" onClick={() => setRemovalBanner(null)}>
263
+ Dismiss
264
+ </Button>
265
+ </div>
266
+ </div>
267
+ )}
268
+
269
+ {/* Add form */}
270
+ {showAddForm && (
271
+ <div
272
+ data-testid="add-form"
273
+ style={{
274
+ background: theme.surface,
275
+ border: `1px solid ${theme.border}`,
276
+ borderRadius: 10,
277
+ padding: 20,
278
+ marginBottom: 24,
279
+ }}
280
+ >
281
+ <div
282
+ style={{
283
+ fontFamily: theme.sans,
284
+ fontSize: 14,
285
+ fontWeight: 600,
286
+ color: theme.text,
287
+ marginBottom: 16,
288
+ }}
289
+ >
290
+ Add recipient
291
+ </div>
292
+
293
+ {/* Key input */}
294
+ <div style={{ marginBottom: 14 }}>
295
+ <Label>Age public key</Label>
296
+ <input
297
+ type="text"
298
+ value={addKey}
299
+ onChange={(e) => setAddKey(e.target.value)}
300
+ placeholder="age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
301
+ data-testid="add-key-input"
302
+ style={{
303
+ width: "100%",
304
+ background: theme.bg,
305
+ border: `1px solid ${
306
+ keyValidation
307
+ ? keyValidation.valid
308
+ ? theme.green + "66"
309
+ : theme.red + "66"
310
+ : theme.border
311
+ }`,
312
+ borderRadius: 6,
313
+ padding: "8px 12px",
314
+ fontFamily: theme.mono,
315
+ fontSize: 12,
316
+ color: theme.text,
317
+ outline: "none",
318
+ boxSizing: "border-box",
319
+ }}
320
+ />
321
+ {keyValidation && !keyValidation.valid && (
322
+ <div
323
+ style={{
324
+ fontFamily: theme.sans,
325
+ fontSize: 11,
326
+ color: theme.red,
327
+ marginTop: 4,
328
+ }}
329
+ >
330
+ {keyValidation.error}
331
+ </div>
332
+ )}
333
+ {keyValidation?.valid && (
334
+ <div
335
+ style={{
336
+ fontFamily: theme.sans,
337
+ fontSize: 11,
338
+ color: theme.green,
339
+ marginTop: 4,
340
+ }}
341
+ >
342
+ Valid age public key
343
+ </div>
344
+ )}
345
+ </div>
346
+
347
+ {/* Label input */}
348
+ <div style={{ marginBottom: 14 }}>
349
+ <Label>Label (optional)</Label>
350
+ <input
351
+ type="text"
352
+ value={addLabel}
353
+ onChange={(e) => setAddLabel(e.target.value)}
354
+ placeholder="e.g. alice@example.com"
355
+ data-testid="add-label-input"
356
+ style={{
357
+ width: "100%",
358
+ background: theme.bg,
359
+ border: `1px solid ${theme.border}`,
360
+ borderRadius: 6,
361
+ padding: "8px 12px",
362
+ fontFamily: theme.sans,
363
+ fontSize: 13,
364
+ color: theme.text,
365
+ outline: "none",
366
+ boxSizing: "border-box",
367
+ }}
368
+ />
369
+ </div>
370
+
371
+ {/* Re-encryption warning */}
372
+ <div
373
+ style={{
374
+ marginBottom: 16,
375
+ padding: "10px 14px",
376
+ background: theme.yellowDim,
377
+ border: `1px solid ${theme.yellow}44`,
378
+ borderRadius: 6,
379
+ fontFamily: theme.sans,
380
+ fontSize: 12,
381
+ color: theme.yellow,
382
+ lineHeight: 1.5,
383
+ }}
384
+ >
385
+ Adding a recipient will re-encrypt {totalFiles} file
386
+ {totalFiles !== 1 ? "s" : ""}. This may take a moment.
387
+ </div>
388
+
389
+ {/* Actions */}
390
+ <div style={{ display: "flex", gap: 10 }}>
391
+ <Button
392
+ variant="ghost"
393
+ onClick={() => {
394
+ setShowAddForm(false);
395
+ setAddKey("");
396
+ setAddLabel("");
397
+ setKeyValidation(null);
398
+ setError(null);
399
+ }}
400
+ >
401
+ Cancel
402
+ </Button>
403
+ <Button
404
+ variant="primary"
405
+ onClick={handleAdd}
406
+ disabled={loading || !keyValidation?.valid}
407
+ >
408
+ {loading ? "Re-encrypting..." : "Add and re-encrypt"}
409
+ </Button>
410
+ </div>
411
+ </div>
412
+ )}
413
+
414
+ {/* Remove flow: Step 1 — Revocation warning */}
415
+ {removeStep === 1 && removeTarget && (
416
+ <div
417
+ data-testid="remove-dialog"
418
+ style={{
419
+ background: theme.surface,
420
+ border: `1px solid ${theme.red}44`,
421
+ borderRadius: 10,
422
+ padding: 20,
423
+ marginBottom: 24,
424
+ }}
425
+ >
426
+ <div
427
+ style={{
428
+ fontFamily: theme.sans,
429
+ fontSize: 14,
430
+ fontWeight: 600,
431
+ color: theme.red,
432
+ marginBottom: 12,
433
+ }}
434
+ >
435
+ Remove recipient
436
+ </div>
437
+
438
+ <div
439
+ style={{
440
+ fontFamily: theme.sans,
441
+ fontSize: 13,
442
+ color: theme.text,
443
+ lineHeight: 1.6,
444
+ marginBottom: 16,
445
+ }}
446
+ >
447
+ You are about to remove{" "}
448
+ <strong style={{ color: theme.accent }}>
449
+ {removeTarget.label ?? removeTarget.preview}
450
+ </strong>{" "}
451
+ ({removeTarget.preview}). All {totalFiles} encrypted file
452
+ {totalFiles !== 1 ? "s" : ""} will be re-encrypted without this key.
453
+ </div>
454
+
455
+ <div
456
+ style={{
457
+ background: theme.redDim,
458
+ border: `1px solid ${theme.red}44`,
459
+ borderRadius: 6,
460
+ padding: "12px 14px",
461
+ marginBottom: 16,
462
+ fontFamily: theme.sans,
463
+ fontSize: 12,
464
+ color: theme.red,
465
+ lineHeight: 1.5,
466
+ }}
467
+ >
468
+ Re-encryption only removes <em>future</em> access. The removed key can still decrypt
469
+ old versions from git history. You must rotate all secret values after removal.
470
+ </div>
471
+
472
+ <label
473
+ style={{
474
+ display: "flex",
475
+ alignItems: "flex-start",
476
+ gap: 10,
477
+ cursor: "pointer",
478
+ fontFamily: theme.sans,
479
+ fontSize: 13,
480
+ color: theme.text,
481
+ marginBottom: 16,
482
+ }}
483
+ >
484
+ <input
485
+ type="checkbox"
486
+ checked={acknowledgedWarning}
487
+ onChange={(e) => setAcknowledgedWarning(e.target.checked)}
488
+ data-testid="acknowledge-checkbox"
489
+ style={{ accentColor: theme.red, marginTop: 2 }}
490
+ />
491
+ I understand — I will rotate secrets after removal
492
+ </label>
493
+
494
+ <div style={{ display: "flex", gap: 10 }}>
495
+ <Button variant="ghost" onClick={cancelRemove}>
496
+ Cancel
497
+ </Button>
498
+ <Button
499
+ variant="danger"
500
+ onClick={() => setRemoveStep(2)}
501
+ disabled={!acknowledgedWarning}
502
+ >
503
+ Continue
504
+ </Button>
505
+ </div>
506
+ </div>
507
+ )}
508
+
509
+ {/* Remove flow: Step 2 — Final confirmation */}
510
+ {removeStep === 2 && removeTarget && (
511
+ <div
512
+ data-testid="remove-confirm-dialog"
513
+ style={{
514
+ background: theme.surface,
515
+ border: `1px solid ${theme.red}44`,
516
+ borderRadius: 10,
517
+ padding: 20,
518
+ marginBottom: 24,
519
+ }}
520
+ >
521
+ <div
522
+ style={{
523
+ fontFamily: theme.sans,
524
+ fontSize: 14,
525
+ fontWeight: 600,
526
+ color: theme.red,
527
+ marginBottom: 12,
528
+ }}
529
+ >
530
+ Confirm removal
531
+ </div>
532
+
533
+ <div
534
+ style={{
535
+ fontFamily: theme.sans,
536
+ fontSize: 13,
537
+ color: theme.text,
538
+ lineHeight: 1.6,
539
+ marginBottom: 16,
540
+ }}
541
+ >
542
+ This will remove{" "}
543
+ <strong style={{ color: theme.accent }}>
544
+ {removeTarget.label ?? removeTarget.preview}
545
+ </strong>{" "}
546
+ and re-encrypt all {totalFiles} file{totalFiles !== 1 ? "s" : ""}. This cannot be
547
+ undone.
548
+ </div>
549
+
550
+ <div style={{ display: "flex", gap: 10 }}>
551
+ <Button variant="ghost" onClick={cancelRemove}>
552
+ Cancel
553
+ </Button>
554
+ <Button variant="danger" onClick={handleRemove} disabled={loading}>
555
+ {loading ? "Re-encrypting..." : "Remove and re-encrypt"}
556
+ </Button>
557
+ </div>
558
+ </div>
559
+ )}
560
+
561
+ {/* Recipients list */}
562
+ {recipients.length === 0 && !showAddForm && (
563
+ <div
564
+ style={{
565
+ textAlign: "center",
566
+ paddingTop: 40,
567
+ fontFamily: theme.sans,
568
+ fontSize: 14,
569
+ color: theme.textMuted,
570
+ }}
571
+ >
572
+ No recipients configured. Add an age public key to get started.
573
+ </div>
574
+ )}
575
+
576
+ {recipients.length > 0 && (
577
+ <div>
578
+ <div
579
+ style={{
580
+ fontFamily: theme.sans,
581
+ fontSize: 12,
582
+ fontWeight: 600,
583
+ color: theme.textMuted,
584
+ letterSpacing: "0.05em",
585
+ textTransform: "uppercase",
586
+ marginBottom: 10,
587
+ }}
588
+ >
589
+ Recipients ({recipients.length})
590
+ </div>
591
+
592
+ {recipients.map((r) => (
593
+ <div
594
+ key={r.key}
595
+ data-testid="recipient-row"
596
+ style={{
597
+ display: "flex",
598
+ alignItems: "center",
599
+ gap: 14,
600
+ padding: "12px 16px",
601
+ background: theme.surface,
602
+ border: `1px solid ${theme.border}`,
603
+ borderRadius: 8,
604
+ marginBottom: 8,
605
+ }}
606
+ >
607
+ <div
608
+ style={{
609
+ width: 32,
610
+ height: 32,
611
+ borderRadius: 6,
612
+ background: theme.accentDim,
613
+ border: `1px solid ${theme.accent}44`,
614
+ display: "flex",
615
+ alignItems: "center",
616
+ justifyContent: "center",
617
+ fontSize: 14,
618
+ flexShrink: 0,
619
+ }}
620
+ >
621
+ {"\uD83D\uDD11"}
622
+ </div>
623
+
624
+ <div style={{ flex: 1, minWidth: 0 }}>
625
+ {r.label && (
626
+ <div
627
+ style={{
628
+ fontFamily: theme.sans,
629
+ fontSize: 13,
630
+ fontWeight: 600,
631
+ color: theme.text,
632
+ marginBottom: 2,
633
+ }}
634
+ >
635
+ {r.label}
636
+ </div>
637
+ )}
638
+ <div
639
+ style={{
640
+ fontFamily: theme.mono,
641
+ fontSize: 11,
642
+ color: theme.textMuted,
643
+ overflow: "hidden",
644
+ textOverflow: "ellipsis",
645
+ whiteSpace: "nowrap",
646
+ }}
647
+ >
648
+ {r.preview}
649
+ </div>
650
+ </div>
651
+
652
+ <Button
653
+ variant="ghost"
654
+ onClick={() => startRemove(r)}
655
+ disabled={removeStep !== 0 || loading}
656
+ >
657
+ Remove
658
+ </Button>
659
+ </div>
660
+ ))}
661
+
662
+ <div
663
+ style={{
664
+ fontFamily: theme.mono,
665
+ fontSize: 11,
666
+ color: theme.textDim,
667
+ marginTop: 12,
668
+ }}
669
+ >
670
+ {totalFiles} encrypted file{totalFiles !== 1 ? "s" : ""} in the matrix
671
+ </div>
672
+ </div>
673
+ )}
674
+ </div>
675
+ </div>
676
+ </div>
677
+ );
678
+ }
679
+
680
+ function Label({ children }: { children: React.ReactNode }) {
681
+ return (
682
+ <div
683
+ style={{
684
+ fontFamily: theme.sans,
685
+ fontSize: 12,
686
+ fontWeight: 600,
687
+ color: theme.textMuted,
688
+ marginBottom: 6,
689
+ letterSpacing: "0.05em",
690
+ textTransform: "uppercase",
691
+ }}
692
+ >
693
+ {children}
694
+ </div>
695
+ );
696
+ }