@clef-sh/ui 0.1.16 → 0.1.18

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,731 @@
1
+ import React, { useState, useEffect, useCallback, useMemo } 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 { ViewName } from "../components/Sidebar";
8
+ import type { PolicyDocument, FileRotationStatus, KeyRotationStatus } from "@clef-sh/core";
9
+
10
+ interface PolicyViewProps {
11
+ setView: (view: ViewName) => void;
12
+ setNs: (ns: string) => void;
13
+ }
14
+
15
+ interface PolicyCheckResponse {
16
+ files: FileRotationStatus[];
17
+ summary: {
18
+ total_files: number;
19
+ compliant: number;
20
+ rotation_overdue: number;
21
+ unknown_metadata: number;
22
+ };
23
+ policy: PolicyDocument;
24
+ source: "file" | "default";
25
+ }
26
+
27
+ type StatusFilter = "all" | "overdue" | "unknown" | "ok";
28
+
29
+ const MS_PER_DAY = 86_400_000;
30
+
31
+ const STATUS_META = {
32
+ overdue: { color: theme.red, bg: theme.redDim, label: "Overdue", icon: "\u2715" },
33
+ unknown: { color: theme.yellow, bg: theme.yellowDim, label: "Unknown", icon: "?" },
34
+ ok: { color: theme.green, bg: theme.greenDim, label: "OK", icon: "\u2713" },
35
+ } as const;
36
+
37
+ /**
38
+ * A flattened row — one per (file, key) pair. The PolicyView renders these
39
+ * grouped by per-key status so users see the actual policy signal rather
40
+ * than a file-level aggregate.
41
+ */
42
+ interface KeyRow {
43
+ key: KeyRotationStatus;
44
+ file: FileRotationStatus;
45
+ }
46
+
47
+ function keyRowStatus(k: KeyRotationStatus): "overdue" | "unknown" | "ok" {
48
+ // Unknown is checked before overdue because `rotation_overdue` is only
49
+ // meaningful when `last_rotated_known` is true.
50
+ if (!k.last_rotated_known) return "unknown";
51
+ if (k.rotation_overdue) return "overdue";
52
+ return "ok";
53
+ }
54
+
55
+ function ageInDays(iso: string): number {
56
+ return Math.floor((Date.now() - new Date(iso).getTime()) / MS_PER_DAY);
57
+ }
58
+
59
+ /** Derive max_age_days for a key from its rotation_due vs last_rotated_at. */
60
+ function keyLimitDays(k: KeyRotationStatus): number | null {
61
+ if (!k.last_rotated_at || !k.rotation_due) return null;
62
+ const due = new Date(k.rotation_due).getTime();
63
+ const last = new Date(k.last_rotated_at).getTime();
64
+ return Math.round((due - last) / MS_PER_DAY);
65
+ }
66
+
67
+ export function PolicyView({ setView, setNs }: PolicyViewProps) {
68
+ const [data, setData] = useState<PolicyCheckResponse | null>(null);
69
+ const [rawYaml, setRawYaml] = useState<string>("");
70
+ const [loading, setLoading] = useState(false);
71
+ const [filter, setFilter] = useState<StatusFilter>("all");
72
+ const [showYaml, setShowYaml] = useState(false);
73
+
74
+ const loadPolicy = useCallback(async () => {
75
+ setLoading(true);
76
+ try {
77
+ const [checkRes, policyRes] = await Promise.all([
78
+ apiFetch("/api/policy/check"),
79
+ apiFetch("/api/policy"),
80
+ ]);
81
+ if (checkRes.ok) {
82
+ setData((await checkRes.json()) as PolicyCheckResponse);
83
+ }
84
+ if (policyRes.ok) {
85
+ const p = (await policyRes.json()) as { rawYaml: string };
86
+ setRawYaml(p.rawYaml);
87
+ }
88
+ } catch {
89
+ // Silently fail — user can retry
90
+ } finally {
91
+ setLoading(false);
92
+ }
93
+ }, []);
94
+
95
+ useEffect(() => {
96
+ loadPolicy();
97
+ }, [loadPolicy]);
98
+
99
+ // Extract the namespace from a cell path. The cell path is shaped like
100
+ // `{prefix...}/<namespace>/<environment>.enc.yaml` per the manifest's
101
+ // file_pattern — so the second-to-last segment is always the namespace,
102
+ // regardless of how many leading directories the repo uses. Mirrors
103
+ // LintView's handleNavigate.
104
+ const namespaceFromPath = (filePath: string): string | undefined => {
105
+ const parts = filePath.split("/");
106
+ return parts.length >= 2 ? parts[parts.length - 2] : parts[0];
107
+ };
108
+
109
+ const handleNavigate = (file: FileRotationStatus) => {
110
+ const ns = namespaceFromPath(file.path);
111
+ if (ns) {
112
+ setNs(ns);
113
+ setView("editor");
114
+ }
115
+ };
116
+
117
+ const files = data?.files ?? [];
118
+ const summary = data?.summary;
119
+ const policy = data?.policy;
120
+ const source = data?.source;
121
+
122
+ // Flatten (file, key) pairs so we can group by per-key status. This is the
123
+ // authoritative view of rotation compliance — unknown rotation state on a
124
+ // single key fails the gate regardless of how many other keys are fresh.
125
+ const allRows: KeyRow[] = useMemo(
126
+ () => files.flatMap((f) => f.keys.map((k) => ({ file: f, key: k }))),
127
+ [files],
128
+ );
129
+
130
+ const visible = useMemo(
131
+ () => (filter === "all" ? allRows : allRows.filter((r) => keyRowStatus(r.key) === filter)),
132
+ [allRows, filter],
133
+ );
134
+
135
+ const counts = useMemo(() => {
136
+ let overdue = 0;
137
+ let unknown = 0;
138
+ let ok = 0;
139
+ for (const r of allRows) {
140
+ const s = keyRowStatus(r.key);
141
+ if (s === "overdue") overdue++;
142
+ else if (s === "unknown") unknown++;
143
+ else ok++;
144
+ }
145
+ return { overdue, unknown, ok, total: allRows.length };
146
+ }, [allRows]);
147
+
148
+ const allCompliant = counts.total > 0 && counts.overdue === 0 && counts.unknown === 0;
149
+ const noFiles = !loading && files.length === 0;
150
+
151
+ return (
152
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
153
+ <TopBar
154
+ title="Policy"
155
+ subtitle={"clef policy check \u2014 rotation verdicts"}
156
+ actions={<Button onClick={loadPolicy}>{"\u21BB"} Re-run</Button>}
157
+ />
158
+
159
+ {/* Policy summary card */}
160
+ {policy && (
161
+ <div
162
+ style={{
163
+ padding: "16px 24px",
164
+ background: theme.surface,
165
+ borderBottom: `1px solid ${theme.border}`,
166
+ }}
167
+ >
168
+ <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
169
+ <span
170
+ style={{
171
+ fontFamily: theme.sans,
172
+ fontSize: 11,
173
+ fontWeight: 600,
174
+ color: theme.textDim,
175
+ letterSpacing: "0.08em",
176
+ textTransform: "uppercase",
177
+ }}
178
+ >
179
+ Default
180
+ </span>
181
+ <span
182
+ style={{
183
+ fontFamily: theme.mono,
184
+ fontSize: 13,
185
+ color: theme.text,
186
+ }}
187
+ >
188
+ {policy.rotation?.max_age_days ?? "\u2014"}
189
+ <span style={{ color: theme.textMuted, marginLeft: 2 }}>d</span>
190
+ </span>
191
+
192
+ {policy.rotation?.environments &&
193
+ Object.entries(policy.rotation.environments).map(([env, cfg]) => {
194
+ const c = ENV_COLORS[env] ?? { color: theme.textMuted, bg: "transparent" };
195
+ return (
196
+ <span
197
+ key={env}
198
+ style={{
199
+ display: "inline-flex",
200
+ alignItems: "center",
201
+ gap: 6,
202
+ padding: "2px 8px",
203
+ borderRadius: 4,
204
+ background: c.bg,
205
+ border: `1px solid ${c.color}33`,
206
+ fontFamily: theme.mono,
207
+ fontSize: 11,
208
+ color: c.color,
209
+ }}
210
+ >
211
+ <span style={{ fontWeight: 700, letterSpacing: "0.06em" }}>
212
+ {env.toUpperCase()}
213
+ </span>
214
+ <span>{cfg.max_age_days}d</span>
215
+ </span>
216
+ );
217
+ })}
218
+
219
+ <div style={{ flex: 1 }} />
220
+
221
+ <span
222
+ data-testid="policy-source"
223
+ style={{
224
+ fontFamily: theme.mono,
225
+ fontSize: 10,
226
+ color: source === "file" ? theme.green : theme.textMuted,
227
+ background: source === "file" ? theme.greenDim : "transparent",
228
+ border: `1px solid ${source === "file" ? `${theme.green}44` : theme.border}`,
229
+ borderRadius: 3,
230
+ padding: "2px 8px",
231
+ }}
232
+ >
233
+ {source === "file" ? ".clef/policy.yaml" : "Built-in default"}
234
+ </span>
235
+
236
+ {rawYaml && (
237
+ <button
238
+ data-testid="toggle-yaml"
239
+ onClick={() => setShowYaml((v) => !v)}
240
+ style={{
241
+ fontFamily: theme.sans,
242
+ fontSize: 11,
243
+ color: theme.accent,
244
+ background: "transparent",
245
+ border: `1px solid ${theme.accent}33`,
246
+ borderRadius: 4,
247
+ padding: "3px 9px",
248
+ cursor: "pointer",
249
+ }}
250
+ >
251
+ {showYaml ? "Hide YAML" : "View YAML"}
252
+ </button>
253
+ )}
254
+ </div>
255
+
256
+ {showYaml && rawYaml && (
257
+ <pre
258
+ data-testid="raw-yaml"
259
+ style={{
260
+ marginTop: 12,
261
+ padding: "12px 14px",
262
+ background: "#0D0F14",
263
+ border: `1px solid ${theme.borderLight}`,
264
+ borderRadius: 6,
265
+ fontFamily: theme.mono,
266
+ fontSize: 11,
267
+ color: theme.text,
268
+ overflow: "auto",
269
+ maxHeight: 200,
270
+ }}
271
+ >
272
+ {rawYaml}
273
+ </pre>
274
+ )}
275
+ </div>
276
+ )}
277
+
278
+ {/* Summary chips — per-key counts, not per-file */}
279
+ {!loading && counts.total > 0 && (
280
+ <div
281
+ style={{
282
+ padding: "14px 24px",
283
+ background: "#0D0F14",
284
+ borderBottom: `1px solid ${theme.border}`,
285
+ display: "flex",
286
+ alignItems: "center",
287
+ gap: 10,
288
+ flexWrap: "wrap",
289
+ }}
290
+ >
291
+ {[
292
+ {
293
+ key: "all" as const,
294
+ label: "All keys",
295
+ count: counts.total,
296
+ color: theme.textMuted,
297
+ },
298
+ {
299
+ key: "overdue" as const,
300
+ label: "Overdue",
301
+ count: counts.overdue,
302
+ color: theme.red,
303
+ },
304
+ {
305
+ key: "unknown" as const,
306
+ label: "Unknown",
307
+ count: counts.unknown,
308
+ color: theme.yellow,
309
+ },
310
+ {
311
+ key: "ok" as const,
312
+ label: "Compliant",
313
+ count: counts.ok,
314
+ color: theme.green,
315
+ },
316
+ ].map((f) => (
317
+ <button
318
+ key={f.key}
319
+ data-testid={`filter-${f.key}`}
320
+ onClick={() => setFilter(f.key)}
321
+ style={{
322
+ display: "flex",
323
+ alignItems: "center",
324
+ gap: 6,
325
+ padding: "5px 12px",
326
+ borderRadius: 20,
327
+ cursor: "pointer",
328
+ fontFamily: theme.sans,
329
+ fontSize: 12,
330
+ fontWeight: filter === f.key ? 600 : 400,
331
+ color: filter === f.key ? f.color : theme.textMuted,
332
+ background: filter === f.key ? `${f.color}18` : "transparent",
333
+ border: `1px solid ${filter === f.key ? `${f.color}55` : theme.border}`,
334
+ transition: "all 0.12s",
335
+ }}
336
+ >
337
+ <span
338
+ style={{
339
+ fontFamily: theme.mono,
340
+ fontSize: 11,
341
+ fontWeight: 700,
342
+ color: f.color,
343
+ }}
344
+ >
345
+ {f.count}
346
+ </span>
347
+ {f.label}
348
+ </button>
349
+ ))}
350
+ </div>
351
+ )}
352
+
353
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
354
+ {loading && (
355
+ <>
356
+ <style>{`
357
+ @keyframes clef-policy-line {
358
+ 0% { transform: scaleX(0); opacity: 0; }
359
+ 10% { opacity: 1; }
360
+ 50% { transform: scaleX(1); opacity: 1; }
361
+ 80% { transform: scaleX(1); opacity: 0.3; }
362
+ 100% { transform: scaleX(0); opacity: 0; }
363
+ }
364
+ @keyframes clef-policy-glow {
365
+ 0%, 100% { opacity: 0.4; }
366
+ 50% { opacity: 1; }
367
+ }
368
+ `}</style>
369
+ <div
370
+ style={{
371
+ display: "flex",
372
+ alignItems: "center",
373
+ justifyContent: "center",
374
+ padding: "48px 24px",
375
+ }}
376
+ >
377
+ <div
378
+ style={{
379
+ background: theme.surface,
380
+ border: `1px solid ${theme.border}`,
381
+ borderRadius: 10,
382
+ padding: "28px 40px",
383
+ textAlign: "center",
384
+ minWidth: 200,
385
+ }}
386
+ >
387
+ <div style={{ marginBottom: 16, display: "flex", flexDirection: "column", gap: 6 }}>
388
+ {[0, 0.3, 0.6].map((delay, i) => (
389
+ <div
390
+ key={i}
391
+ style={{
392
+ height: 3,
393
+ borderRadius: 2,
394
+ background: theme.accent,
395
+ transformOrigin: "left",
396
+ animation: `clef-policy-line 1.8s ease-in-out ${delay}s infinite`,
397
+ opacity: 0,
398
+ width: [120, 90, 105][i],
399
+ }}
400
+ />
401
+ ))}
402
+ </div>
403
+ <div
404
+ style={{
405
+ fontFamily: theme.mono,
406
+ fontSize: 11,
407
+ color: theme.textMuted,
408
+ animation: "clef-policy-glow 1.8s ease-in-out infinite",
409
+ }}
410
+ >
411
+ Evaluating policy...
412
+ </div>
413
+ </div>
414
+ </div>
415
+ </>
416
+ )}
417
+
418
+ {!loading && noFiles && (
419
+ <div
420
+ data-testid="no-files"
421
+ style={{
422
+ display: "flex",
423
+ flexDirection: "column",
424
+ alignItems: "center",
425
+ justifyContent: "center",
426
+ gap: 14,
427
+ padding: "60px 0",
428
+ color: theme.textMuted,
429
+ fontFamily: theme.sans,
430
+ fontSize: 13,
431
+ }}
432
+ >
433
+ No matrix files to evaluate.
434
+ </div>
435
+ )}
436
+
437
+ {!loading && allCompliant && (
438
+ <div
439
+ data-testid="all-compliant"
440
+ style={{
441
+ display: "flex",
442
+ flexDirection: "column",
443
+ alignItems: "center",
444
+ justifyContent: "center",
445
+ gap: 14,
446
+ padding: "60px 0",
447
+ }}
448
+ >
449
+ <div
450
+ style={{
451
+ width: 56,
452
+ height: 56,
453
+ borderRadius: "50%",
454
+ background: theme.greenDim,
455
+ border: `1px solid ${theme.green}44`,
456
+ display: "flex",
457
+ alignItems: "center",
458
+ justifyContent: "center",
459
+ fontSize: 24,
460
+ }}
461
+ >
462
+ {"\u2713"}
463
+ </div>
464
+ <div
465
+ style={{
466
+ fontFamily: theme.sans,
467
+ fontWeight: 600,
468
+ fontSize: 16,
469
+ color: theme.green,
470
+ }}
471
+ >
472
+ All compliant
473
+ </div>
474
+ <div
475
+ style={{
476
+ fontFamily: theme.mono,
477
+ fontSize: 12,
478
+ color: theme.textMuted,
479
+ }}
480
+ >
481
+ {counts.total} key{counts.total === 1 ? "" : "s"} within rotation window across{" "}
482
+ {summary?.total_files ?? 0} file{summary?.total_files === 1 ? "" : "s"}
483
+ </div>
484
+ </div>
485
+ )}
486
+
487
+ {/* Grouped per-key rows */}
488
+ {!loading &&
489
+ !allCompliant &&
490
+ !noFiles &&
491
+ policy &&
492
+ (["overdue", "unknown", "ok"] as const).map((status) => {
493
+ if (filter !== "all" && filter !== status) return null;
494
+ const group = visible.filter((r) => keyRowStatus(r.key) === status);
495
+ if (!group.length) return null;
496
+ const meta = STATUS_META[status];
497
+
498
+ return (
499
+ <div key={status} style={{ marginBottom: 24 }}>
500
+ <div
501
+ style={{
502
+ display: "flex",
503
+ alignItems: "center",
504
+ gap: 10,
505
+ marginBottom: 10,
506
+ }}
507
+ >
508
+ <div
509
+ style={{
510
+ width: 22,
511
+ height: 22,
512
+ borderRadius: "50%",
513
+ background: meta.bg,
514
+ border: `1px solid ${meta.color}44`,
515
+ display: "flex",
516
+ alignItems: "center",
517
+ justifyContent: "center",
518
+ fontFamily: theme.mono,
519
+ fontSize: 11,
520
+ fontWeight: 700,
521
+ color: meta.color,
522
+ }}
523
+ >
524
+ {meta.icon}
525
+ </div>
526
+ <span
527
+ style={{
528
+ fontFamily: theme.sans,
529
+ fontWeight: 600,
530
+ fontSize: 13,
531
+ color: meta.color,
532
+ }}
533
+ >
534
+ {meta.label}
535
+ </span>
536
+ <span
537
+ style={{
538
+ fontFamily: theme.mono,
539
+ fontSize: 10,
540
+ color: meta.color,
541
+ background: meta.bg,
542
+ border: `1px solid ${meta.color}33`,
543
+ borderRadius: 10,
544
+ padding: "1px 8px",
545
+ }}
546
+ >
547
+ {group.length}
548
+ </span>
549
+ </div>
550
+
551
+ <div
552
+ style={{
553
+ background: theme.surface,
554
+ border: `1px solid ${theme.border}`,
555
+ borderRadius: 10,
556
+ overflow: "hidden",
557
+ }}
558
+ >
559
+ {group.map((row, i) => {
560
+ const { file, key } = row;
561
+ const limit = keyLimitDays(key);
562
+ const nsHint = namespaceFromPath(file.path) ?? "<namespace>";
563
+ const message =
564
+ status === "unknown"
565
+ ? `No rotation record \u00B7 run clef set ${nsHint}/${file.environment} ${key.key} to establish`
566
+ : key.last_rotated_at
567
+ ? `Last rotated ${ageInDays(key.last_rotated_at)}d ago \u00B7 limit ${limit ?? "?"}d \u00B7 ${key.rotation_count} rotation${key.rotation_count === 1 ? "" : "s"}`
568
+ : `Rotation state inconsistent`;
569
+ const statusTag =
570
+ status === "overdue" ? `${meta.label} ${key.days_overdue}d` : meta.label;
571
+
572
+ return (
573
+ <div
574
+ key={`${file.path}-${key.key}-${i}`}
575
+ style={{
576
+ display: "flex",
577
+ alignItems: "flex-start",
578
+ borderBottom: i < group.length - 1 ? `1px solid ${theme.border}` : "none",
579
+ borderLeft: `3px solid ${meta.color}66`,
580
+ transition: "background 0.1s",
581
+ padding: "14px 18px",
582
+ gap: 14,
583
+ }}
584
+ >
585
+ <div style={{ flexShrink: 0, paddingTop: 2 }}>
586
+ <span
587
+ style={{
588
+ fontFamily: theme.mono,
589
+ fontSize: 9,
590
+ fontWeight: 700,
591
+ color: meta.color,
592
+ background: `${meta.color}18`,
593
+ border: `1px solid ${meta.color}33`,
594
+ borderRadius: 3,
595
+ padding: "2px 6px",
596
+ letterSpacing: "0.07em",
597
+ textTransform: "uppercase",
598
+ }}
599
+ >
600
+ {statusTag}
601
+ </span>
602
+ </div>
603
+
604
+ <div style={{ flex: 1, minWidth: 0 }}>
605
+ <div
606
+ style={{
607
+ display: "flex",
608
+ alignItems: "center",
609
+ gap: 8,
610
+ marginBottom: 4,
611
+ flexWrap: "wrap",
612
+ }}
613
+ >
614
+ <span
615
+ data-testid={`key-ref-${key.key}`}
616
+ style={{
617
+ fontFamily: theme.mono,
618
+ fontSize: 13,
619
+ fontWeight: 700,
620
+ color: theme.text,
621
+ }}
622
+ >
623
+ {key.key}
624
+ </span>
625
+ <span
626
+ style={{
627
+ fontFamily: theme.mono,
628
+ fontSize: 10,
629
+ color: theme.textMuted,
630
+ }}
631
+ >
632
+ {"\u2190"}
633
+ </span>
634
+ <span
635
+ data-testid={`file-ref-${file.path}`}
636
+ role="link"
637
+ tabIndex={0}
638
+ onClick={() => handleNavigate(file)}
639
+ onKeyDown={(e) => {
640
+ if (e.key === "Enter") handleNavigate(file);
641
+ }}
642
+ style={{
643
+ fontFamily: theme.mono,
644
+ fontSize: 11,
645
+ fontWeight: 500,
646
+ color: theme.accent,
647
+ cursor: "pointer",
648
+ textDecoration: "underline",
649
+ textDecorationColor: `${theme.accent}55`,
650
+ textDecorationStyle: "dotted",
651
+ }}
652
+ >
653
+ {file.path}
654
+ </span>
655
+ <EnvBadge env={file.environment} small />
656
+ </div>
657
+
658
+ <div
659
+ style={{
660
+ fontFamily: theme.sans,
661
+ fontSize: 12,
662
+ color: theme.textMuted,
663
+ }}
664
+ >
665
+ {message}
666
+ </div>
667
+ </div>
668
+ </div>
669
+ );
670
+ })}
671
+ </div>
672
+ </div>
673
+ );
674
+ })}
675
+
676
+ {/* Footer hint */}
677
+ {!loading && summary && summary.total_files > 0 && (
678
+ <div
679
+ style={{
680
+ marginTop: 8,
681
+ padding: "12px 16px",
682
+ background: theme.surface,
683
+ border: `1px solid ${theme.border}`,
684
+ borderRadius: 8,
685
+ display: "flex",
686
+ alignItems: "center",
687
+ gap: 12,
688
+ }}
689
+ >
690
+ <span style={{ fontSize: 14 }}>{"\uD83D\uDCA1"}</span>
691
+ <span
692
+ style={{
693
+ fontFamily: theme.sans,
694
+ fontSize: 12,
695
+ color: theme.textMuted,
696
+ }}
697
+ >
698
+ Edit{" "}
699
+ <code
700
+ style={{
701
+ fontFamily: theme.mono,
702
+ fontSize: 11,
703
+ color: theme.accent,
704
+ background: theme.accentDim,
705
+ padding: "1px 6px",
706
+ borderRadius: 3,
707
+ }}
708
+ >
709
+ .clef/policy.yaml
710
+ </code>{" "}
711
+ to change rotation limits. Run{" "}
712
+ <code
713
+ style={{
714
+ fontFamily: theme.mono,
715
+ fontSize: 11,
716
+ color: theme.accent,
717
+ background: theme.accentDim,
718
+ padding: "1px 6px",
719
+ borderRadius: 3,
720
+ }}
721
+ >
722
+ clef policy check
723
+ </code>{" "}
724
+ locally to reproduce this verdict.
725
+ </span>
726
+ </div>
727
+ )}
728
+ </div>
729
+ </div>
730
+ );
731
+ }