@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,523 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { theme } from "../theme";
3
+ import { apiFetch } from "../api";
4
+ import { TopBar } from "../components/TopBar";
5
+ import { Button } from "../components/Button";
6
+ import { CopyButton } from "../components/CopyButton";
7
+ import type { ScanResult } from "@clef-sh/core";
8
+
9
+ type ScanState = "idle" | "scanning" | "clean" | "issues";
10
+ type MatchFilter = "all" | "unencrypted" | "pattern" | "entropy";
11
+
12
+ export function ScanScreen() {
13
+ const [scanState, setScanState] = useState<ScanState>("idle");
14
+ const [severity, setSeverity] = useState<"all" | "high">("all");
15
+ const [result, setResult] = useState<ScanResult | null>(null);
16
+ const [lastRunAt, setLastRunAt] = useState<string | null>(null);
17
+ const [dismissed, setDismissed] = useState<number[]>([]);
18
+ const [filter, setFilter] = useState<MatchFilter>("all");
19
+
20
+ // On mount, restore last scan result from session
21
+ useEffect(() => {
22
+ apiFetch("/api/scan/status")
23
+ .then((r) => (r.ok ? r.json() : null))
24
+ .then((data: { lastRun: ScanResult | null; lastRunAt: string | null } | null) => {
25
+ if (data?.lastRun) {
26
+ setResult(data.lastRun);
27
+ setLastRunAt(data.lastRunAt);
28
+ const hasIssues =
29
+ data.lastRun.matches.length > 0 || data.lastRun.unencryptedMatrixFiles.length > 0;
30
+ setScanState(hasIssues ? "issues" : "clean");
31
+ }
32
+ })
33
+ .catch(() => {
34
+ // Silently fail — idle state is fine
35
+ });
36
+ }, []);
37
+
38
+ const runScan = useCallback(async () => {
39
+ setScanState("scanning");
40
+ setDismissed([]);
41
+ try {
42
+ const res = await apiFetch("/api/scan", {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json" },
45
+ body: JSON.stringify({ severity }),
46
+ });
47
+ if (!res.ok) throw new Error("Scan failed");
48
+ const data: ScanResult = await res.json();
49
+ setResult(data);
50
+ setLastRunAt(new Date().toISOString());
51
+ const hasIssues = data.matches.length > 0 || data.unencryptedMatrixFiles.length > 0;
52
+ setScanState(hasIssues ? "issues" : "clean");
53
+ } catch {
54
+ setScanState("idle");
55
+ }
56
+ }, [severity]);
57
+
58
+ const openFile = async (file: string) => {
59
+ try {
60
+ await apiFetch("/api/editor/open", {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({ file }),
64
+ });
65
+ } catch {
66
+ // Non-fatal
67
+ }
68
+ };
69
+
70
+ const formatRunAt = (iso: string | null) => {
71
+ if (!iso) return "";
72
+ const diff = Date.now() - new Date(iso).getTime();
73
+ if (diff < 60_000) return "just now";
74
+ return `${Math.floor(diff / 60_000)}m ago`;
75
+ };
76
+
77
+ const visibleMatches = (result?.matches ?? [])
78
+ .map((m, i) => ({ ...m, _idx: i }))
79
+ .filter((m) => !dismissed.includes(m._idx))
80
+ .filter((m) => {
81
+ if (filter === "pattern") return m.matchType === "pattern";
82
+ if (filter === "entropy") return m.matchType === "entropy";
83
+ return true;
84
+ });
85
+
86
+ const dismissedCount = dismissed.length;
87
+ const totalIssues = (result?.matches.length ?? 0) + (result?.unencryptedMatrixFiles.length ?? 0);
88
+ const durationSec = result ? (result.durationMs / 1000).toFixed(1) : "0.0";
89
+
90
+ return (
91
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
92
+ <TopBar
93
+ title="Scan"
94
+ subtitle="clef scan — detect plaintext secrets"
95
+ actions={
96
+ scanState === "issues" || scanState === "clean" ? (
97
+ <Button onClick={runScan}>&#x21BA; Scan again</Button>
98
+ ) : undefined
99
+ }
100
+ />
101
+
102
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
103
+ {/* ── Idle ────────────────────────────────────────────────────── */}
104
+ {scanState === "idle" && (
105
+ <div
106
+ data-testid="scan-idle"
107
+ style={{
108
+ maxWidth: 520,
109
+ margin: "0 auto",
110
+ paddingTop: 40,
111
+ }}
112
+ >
113
+ <div
114
+ style={{
115
+ fontFamily: theme.sans,
116
+ fontSize: 14,
117
+ color: theme.textMuted,
118
+ marginBottom: 24,
119
+ lineHeight: 1.6,
120
+ }}
121
+ >
122
+ Scans your repository for secrets that have escaped the Clef matrix — plaintext values
123
+ in files that should be encrypted.
124
+ </div>
125
+
126
+ {/* Severity selector */}
127
+ <div style={{ marginBottom: 24 }}>
128
+ <div
129
+ style={{
130
+ fontFamily: theme.sans,
131
+ fontSize: 12,
132
+ fontWeight: 600,
133
+ color: theme.textMuted,
134
+ marginBottom: 10,
135
+ letterSpacing: "0.05em",
136
+ textTransform: "uppercase",
137
+ }}
138
+ >
139
+ Severity
140
+ </div>
141
+ {(["all", "high"] as const).map((sev) => (
142
+ <label
143
+ key={sev}
144
+ style={{
145
+ display: "flex",
146
+ alignItems: "center",
147
+ gap: 10,
148
+ marginBottom: 8,
149
+ cursor: "pointer",
150
+ fontFamily: theme.sans,
151
+ fontSize: 13,
152
+ color: severity === sev ? theme.text : theme.textMuted,
153
+ }}
154
+ >
155
+ <input
156
+ type="radio"
157
+ name="severity"
158
+ value={sev}
159
+ checked={severity === sev}
160
+ onChange={() => setSeverity(sev)}
161
+ style={{ accentColor: theme.accent }}
162
+ data-testid={`severity-${sev}`}
163
+ />
164
+ {sev === "all" ? "All (patterns + entropy)" : "High (patterns only)"}
165
+ </label>
166
+ ))}
167
+ </div>
168
+
169
+ <Button variant="primary" onClick={runScan} data-testid="scan-button">
170
+ Scan repository
171
+ </Button>
172
+
173
+ <div
174
+ style={{
175
+ marginTop: 24,
176
+ padding: "12px 16px",
177
+ background: theme.surface,
178
+ border: `1px solid ${theme.border}`,
179
+ borderRadius: 8,
180
+ fontFamily: theme.sans,
181
+ fontSize: 12,
182
+ color: theme.textMuted,
183
+ }}
184
+ >
185
+ &#x2139;&#xFE0F; <code style={{ fontFamily: theme.mono }}>clef scan</code> runs
186
+ automatically on every commit via the pre-commit hook.
187
+ </div>
188
+ </div>
189
+ )}
190
+
191
+ {/* ── Scanning ────────────────────────────────────────────────── */}
192
+ {scanState === "scanning" && (
193
+ <div
194
+ data-testid="scan-scanning"
195
+ style={{
196
+ display: "flex",
197
+ flexDirection: "column",
198
+ alignItems: "center",
199
+ justifyContent: "center",
200
+ gap: 16,
201
+ paddingTop: 80,
202
+ }}
203
+ >
204
+ <div
205
+ style={{
206
+ width: 40,
207
+ height: 40,
208
+ borderRadius: "50%",
209
+ border: `3px solid ${theme.accent}44`,
210
+ borderTopColor: theme.accent,
211
+ animation: "spin 0.8s linear infinite",
212
+ }}
213
+ />
214
+ <div style={{ fontFamily: theme.sans, fontSize: 14, color: theme.textMuted }}>
215
+ Scanning...
216
+ </div>
217
+ </div>
218
+ )}
219
+
220
+ {/* ── Clean ───────────────────────────────────────────────────── */}
221
+ {scanState === "clean" && result && (
222
+ <div
223
+ data-testid="scan-clean"
224
+ style={{
225
+ display: "flex",
226
+ flexDirection: "column",
227
+ alignItems: "center",
228
+ justifyContent: "center",
229
+ gap: 14,
230
+ paddingTop: 60,
231
+ }}
232
+ >
233
+ <div
234
+ style={{
235
+ width: 56,
236
+ height: 56,
237
+ borderRadius: "50%",
238
+ background: theme.greenDim,
239
+ border: `1px solid ${theme.green}44`,
240
+ display: "flex",
241
+ alignItems: "center",
242
+ justifyContent: "center",
243
+ fontSize: 24,
244
+ color: theme.green,
245
+ }}
246
+ >
247
+ &#x2713;
248
+ </div>
249
+ <div
250
+ style={{ fontFamily: theme.sans, fontWeight: 600, fontSize: 16, color: theme.green }}
251
+ >
252
+ No issues found
253
+ </div>
254
+ <div style={{ fontFamily: theme.mono, fontSize: 12, color: theme.textMuted }}>
255
+ {result.filesScanned} files scanned in {durationSec}s
256
+ </div>
257
+ <div style={{ fontFamily: theme.mono, fontSize: 11, color: theme.textDim }}>
258
+ Last run: {formatRunAt(lastRunAt)}
259
+ </div>
260
+ </div>
261
+ )}
262
+
263
+ {/* ── Issues ──────────────────────────────────────────────────── */}
264
+ {scanState === "issues" && result && (
265
+ <div>
266
+ {/* Summary */}
267
+ <div
268
+ style={{
269
+ display: "flex",
270
+ alignItems: "center",
271
+ gap: 12,
272
+ marginBottom: 20,
273
+ flexWrap: "wrap",
274
+ }}
275
+ >
276
+ <span
277
+ style={{ fontFamily: theme.sans, fontSize: 14, color: theme.text, fontWeight: 600 }}
278
+ >
279
+ {totalIssues} issue{totalIssues !== 1 ? "s" : ""} found in {result.filesScanned}{" "}
280
+ files ({durationSec}s)
281
+ </span>
282
+ <div style={{ flex: 1 }} />
283
+ {/* Filter */}
284
+ {(
285
+ [
286
+ { key: "all", label: "All" },
287
+ { key: "unencrypted", label: "Unencrypted" },
288
+ { key: "pattern", label: "Pattern" },
289
+ { key: "entropy", label: "Entropy" },
290
+ ] as { key: MatchFilter; label: string }[]
291
+ ).map(({ key, label }) => (
292
+ <button
293
+ key={key}
294
+ data-testid={`filter-${key}`}
295
+ onClick={() => setFilter(key)}
296
+ style={{
297
+ padding: "4px 10px",
298
+ borderRadius: 4,
299
+ cursor: "pointer",
300
+ fontFamily: theme.mono,
301
+ fontSize: 11,
302
+ fontWeight: filter === key ? 600 : 400,
303
+ color: filter === key ? theme.accent : theme.textMuted,
304
+ background: filter === key ? theme.accentDim : "transparent",
305
+ border: `1px solid ${filter === key ? theme.accent + "55" : theme.borderLight}`,
306
+ }}
307
+ >
308
+ {label}
309
+ </button>
310
+ ))}
311
+ </div>
312
+
313
+ {/* Unencrypted matrix files */}
314
+ {(filter === "all" || filter === "unencrypted") &&
315
+ result.unencryptedMatrixFiles.map((file) => (
316
+ <IssueCard
317
+ key={`unenc-${file}`}
318
+ type="error"
319
+ typeLabel="UNENCRYPTED FILE"
320
+ file={file}
321
+ message="Missing SOPS metadata — file is in plaintext."
322
+ fixCommand={`clef encrypt ${file.replace(/\.enc\.(yaml|json)$/, "")}`}
323
+ onViewFile={() => openFile(file)}
324
+ />
325
+ ))}
326
+
327
+ {/* Pattern / entropy matches */}
328
+ {visibleMatches.map((match) => (
329
+ <IssueCard
330
+ key={`match-${match._idx}`}
331
+ type="warning"
332
+ typeLabel={
333
+ match.matchType === "pattern"
334
+ ? (match.patternName ?? "Pattern match").toUpperCase()
335
+ : `HIGH ENTROPY (${match.entropy?.toFixed(1)})`
336
+ }
337
+ file={`${match.file}:${match.line}`}
338
+ message={match.preview}
339
+ fixCommand={
340
+ match.matchType === "pattern"
341
+ ? `clef set <namespace>/<env> <KEY>`
342
+ : `clef set <namespace>/<env> ${match.preview.split("=")[0]}`
343
+ }
344
+ onViewFile={() => openFile(match.file)}
345
+ onDismiss={() => setDismissed((d) => [...d, match._idx])}
346
+ />
347
+ ))}
348
+
349
+ {dismissedCount > 0 && (
350
+ <div
351
+ style={{
352
+ fontFamily: theme.mono,
353
+ fontSize: 11,
354
+ color: theme.textDim,
355
+ marginTop: 12,
356
+ }}
357
+ >
358
+ {dismissedCount} dismissed
359
+ </div>
360
+ )}
361
+ </div>
362
+ )}
363
+ </div>
364
+ </div>
365
+ );
366
+ }
367
+
368
+ interface IssueCardProps {
369
+ type: "error" | "warning";
370
+ typeLabel: string;
371
+ file: string;
372
+ message: string;
373
+ fixCommand: string;
374
+ onViewFile?: () => void;
375
+ onDismiss?: () => void;
376
+ }
377
+
378
+ function IssueCard({
379
+ type,
380
+ typeLabel,
381
+ file,
382
+ message,
383
+ fixCommand,
384
+ onViewFile,
385
+ onDismiss,
386
+ }: IssueCardProps) {
387
+ const color = type === "error" ? theme.red : theme.yellow;
388
+
389
+ return (
390
+ <div
391
+ style={{
392
+ background: theme.surface,
393
+ border: `1px solid ${theme.border}`,
394
+ borderLeft: `3px solid ${color}66`,
395
+ borderRadius: 8,
396
+ padding: "14px 18px",
397
+ marginBottom: 12,
398
+ display: "flex",
399
+ alignItems: "flex-start",
400
+ gap: 14,
401
+ }}
402
+ >
403
+ <div style={{ flex: 1, minWidth: 0 }}>
404
+ {/* Type badge + file */}
405
+ <div
406
+ style={{
407
+ display: "flex",
408
+ alignItems: "center",
409
+ gap: 8,
410
+ marginBottom: 6,
411
+ flexWrap: "wrap",
412
+ }}
413
+ >
414
+ <span
415
+ style={{
416
+ fontFamily: theme.mono,
417
+ fontSize: 9,
418
+ fontWeight: 700,
419
+ color,
420
+ background: `${color}18`,
421
+ border: `1px solid ${color}33`,
422
+ borderRadius: 3,
423
+ padding: "2px 6px",
424
+ letterSpacing: "0.07em",
425
+ }}
426
+ >
427
+ {typeLabel}
428
+ </span>
429
+ <span
430
+ style={{
431
+ fontFamily: theme.mono,
432
+ fontSize: 12,
433
+ color: theme.accent,
434
+ cursor: onViewFile ? "pointer" : "default",
435
+ }}
436
+ onClick={onViewFile}
437
+ role={onViewFile ? "button" : undefined}
438
+ tabIndex={onViewFile ? 0 : undefined}
439
+ onKeyDown={
440
+ onViewFile
441
+ ? (e) => {
442
+ if (e.key === "Enter") onViewFile();
443
+ }
444
+ : undefined
445
+ }
446
+ >
447
+ {file}
448
+ </span>
449
+ </div>
450
+
451
+ {/* Message (preview) */}
452
+ <div
453
+ style={{ fontFamily: theme.mono, fontSize: 12, color: theme.text, marginBottom: 10 }}
454
+ data-testid="match-preview"
455
+ >
456
+ {message}
457
+ </div>
458
+
459
+ {/* Fix command */}
460
+ <div
461
+ style={{
462
+ display: "flex",
463
+ alignItems: "center",
464
+ gap: 8,
465
+ background: "#0D0F14",
466
+ border: `1px solid ${theme.borderLight}`,
467
+ borderRadius: 6,
468
+ padding: "6px 10px",
469
+ width: "fit-content",
470
+ }}
471
+ >
472
+ <span style={{ fontFamily: theme.mono, fontSize: 11, color: theme.green }}>$</span>
473
+ <span style={{ fontFamily: theme.mono, fontSize: 11, color: theme.text }}>
474
+ {fixCommand}
475
+ </span>
476
+ <CopyButton text={fixCommand} />
477
+ </div>
478
+
479
+ {/* Actions */}
480
+ {onViewFile && (
481
+ <button
482
+ data-testid="view-file-button"
483
+ onClick={onViewFile}
484
+ style={{
485
+ marginTop: 8,
486
+ background: "none",
487
+ border: `1px solid ${theme.borderLight}`,
488
+ borderRadius: 4,
489
+ cursor: "pointer",
490
+ color: theme.textMuted,
491
+ fontFamily: theme.sans,
492
+ fontSize: 11,
493
+ padding: "3px 8px",
494
+ }}
495
+ >
496
+ View file
497
+ </button>
498
+ )}
499
+ </div>
500
+
501
+ {onDismiss && (
502
+ <button
503
+ data-testid="dismiss-button"
504
+ onClick={onDismiss}
505
+ title="Dismiss"
506
+ aria-label="Dismiss issue"
507
+ style={{
508
+ background: "none",
509
+ border: "none",
510
+ cursor: "pointer",
511
+ color: theme.textDim,
512
+ fontSize: 16,
513
+ flexShrink: 0,
514
+ padding: "0 4px",
515
+ lineHeight: 1,
516
+ }}
517
+ >
518
+ &#x00D7;
519
+ </button>
520
+ )}
521
+ </div>
522
+ );
523
+ }