@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.
@@ -8,6 +8,7 @@ export type ViewName =
8
8
  | "diff"
9
9
  | "lint"
10
10
  | "scan"
11
+ | "policy"
11
12
  | "import"
12
13
  | "recipients"
13
14
  | "identities"
@@ -26,6 +27,7 @@ interface SidebarProps {
26
27
  gitStatus: GitStatusType | null;
27
28
  lintErrorCount: number;
28
29
  scanIssueCount: number;
30
+ policyOverdueCount: number;
29
31
  }
30
32
 
31
33
  export function Sidebar({
@@ -38,6 +40,7 @@ export function Sidebar({
38
40
  gitStatus,
39
41
  lintErrorCount,
40
42
  scanIssueCount,
43
+ policyOverdueCount,
41
44
  }: SidebarProps) {
42
45
  const uncommittedCount = gitStatus
43
46
  ? gitStatus.staged.length + gitStatus.unstaged.length + gitStatus.untracked.length
@@ -49,7 +52,11 @@ export function Sidebar({
49
52
  <div
50
53
  style={{
51
54
  width: 220,
52
- minHeight: "100vh",
55
+ // Fixed viewport height (not minHeight) so the flex column can
56
+ // actually clip overflow. Paired with overflowY: auto on the nav
57
+ // block below, this lets the middle section scroll when the list
58
+ // grows or the user zooms in, while the logo and footer stay pinned.
59
+ height: "100vh",
53
60
  background: theme.surface,
54
61
  borderRight: `1px solid ${theme.border}`,
55
62
  display: "flex",
@@ -109,7 +116,18 @@ export function Sidebar({
109
116
  </div>
110
117
 
111
118
  {/* Nav */}
112
- <div style={{ padding: "12px 10px", flex: 1 }}>
119
+ <div
120
+ style={{
121
+ padding: "12px 10px",
122
+ flex: 1,
123
+ // minHeight: 0 is the standard flex quirk — without it, a flex
124
+ // child's scrollable content never shrinks below its intrinsic
125
+ // size, so overflowY: auto would never actually clip.
126
+ minHeight: 0,
127
+ overflowY: "auto",
128
+ overflowX: "hidden",
129
+ }}
130
+ >
113
131
  <NavItem
114
132
  icon={"\u229E"}
115
133
  label="Matrix"
@@ -138,6 +156,14 @@ export function Sidebar({
138
156
  badge={scanIssueCount > 0 ? String(scanIssueCount) : undefined}
139
157
  badgeColor={theme.yellow}
140
158
  />
159
+ <NavItem
160
+ icon={"\u2696"}
161
+ label="Policy"
162
+ active={activeView === "policy"}
163
+ onClick={() => setView("policy")}
164
+ badge={policyOverdueCount > 0 ? String(policyOverdueCount) : undefined}
165
+ badgeColor={theme.red}
166
+ />
141
167
  <NavItem
142
168
  icon={"\u2B06"}
143
169
  label="Import"
@@ -691,4 +691,104 @@ describe("NamespaceEditor", () => {
691
691
  // Key should still be there
692
692
  expect(screen.getByText("DB_HOST")).toBeInTheDocument();
693
693
  });
694
+
695
+ it("surfaces the server error and resets UI to on-disk state on Save failure", async () => {
696
+ // Route by method: all GETs (decrypt + lint) succeed; the PUT that
697
+ // handleSave issues rejects with a 500 (dirty-tree preflight failure).
698
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
699
+ const fetchMock = jest.fn().mockImplementation((_url: string, init?: any) => {
700
+ if (init?.method === "PUT") {
701
+ return Promise.resolve({
702
+ ok: false,
703
+ status: 500,
704
+ json: () =>
705
+ Promise.resolve({
706
+ error: "Working tree has uncommitted changes. Refusing to mutate.",
707
+ code: "SET_ERROR",
708
+ }),
709
+ } as Response);
710
+ }
711
+ return Promise.resolve({
712
+ ok: true,
713
+ json: () => Promise.resolve(mockDecrypted),
714
+ } as Response);
715
+ });
716
+ global.fetch = fetchMock;
717
+
718
+ await act(async () => {
719
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
720
+ });
721
+
722
+ // Reveal + edit DB_HOST to produce a dirty row.
723
+ await act(async () => {
724
+ fireEvent.click(screen.getByTestId("eye-DB_HOST"));
725
+ });
726
+ await act(async () => {
727
+ fireEvent.change(screen.getByTestId("value-input-DB_HOST"), {
728
+ target: { value: "rotated" },
729
+ });
730
+ });
731
+
732
+ // Click the Save button (rendered once a row is dirty).
733
+ await act(async () => {
734
+ fireEvent.click(screen.getByText("Save"));
735
+ });
736
+
737
+ // The server's error message reaches the user — no silent success.
738
+ expect(screen.getByText(/Working tree has uncommitted changes/)).toBeInTheDocument();
739
+ // UI resets to on-disk state: dirty indicator gone, value re-masked
740
+ // (value-input replaced by bullet-mask span), Save button hidden.
741
+ expect(screen.queryByTestId("dirty-dot")).not.toBeInTheDocument();
742
+ expect(screen.queryByTestId("value-input-DB_HOST")).not.toBeInTheDocument();
743
+ expect(screen.queryByText("Save")).not.toBeInTheDocument();
744
+ });
745
+
746
+ it("bails on first failure in a batch save without issuing further PUTs", async () => {
747
+ // All GETs succeed; every PUT 500s. We expect handleSave to abort
748
+ // after the first PUT and never issue the second.
749
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
750
+ const fetchMock = jest.fn().mockImplementation((_url: string, init?: any) => {
751
+ if (init?.method === "PUT") {
752
+ return Promise.resolve({
753
+ ok: false,
754
+ status: 500,
755
+ json: () => Promise.resolve({ error: "boom" }),
756
+ } as Response);
757
+ }
758
+ return Promise.resolve({
759
+ ok: true,
760
+ json: () => Promise.resolve(mockDecrypted),
761
+ } as Response);
762
+ });
763
+ global.fetch = fetchMock;
764
+
765
+ await act(async () => {
766
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
767
+ });
768
+
769
+ // Dirty both rows.
770
+ for (const key of ["DB_HOST", "DB_PORT"]) {
771
+ await act(async () => {
772
+ fireEvent.click(screen.getByTestId(`eye-${key}`));
773
+ });
774
+ await act(async () => {
775
+ fireEvent.change(screen.getByTestId(`value-input-${key}`), {
776
+ target: { value: `${key}-new` },
777
+ });
778
+ });
779
+ }
780
+
781
+ await act(async () => {
782
+ fireEvent.click(screen.getByText("Save"));
783
+ });
784
+
785
+ // Exactly one PUT was attempted — the second row was skipped after the
786
+ // first failure. Initial GET + one PUT = 2 fetch calls total.
787
+ const putCalls = fetchMock.mock.calls.filter(
788
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
789
+ (c: any[]) => c[1]?.method === "PUT",
790
+ );
791
+ expect(putCalls).toHaveLength(1);
792
+ expect(screen.getByText("boom")).toBeInTheDocument();
793
+ });
694
794
  });
@@ -130,20 +130,38 @@ export function NamespaceEditor({ ns, initialEnv, manifest }: NamespaceEditorPro
130
130
  const dirtyRows = rows.filter((r) => r.edited);
131
131
  if (dirtyRows.length === 0) return;
132
132
  setSaving(true);
133
+ setError(null);
133
134
  try {
134
135
  // Each PUT auto-commits via the transaction manager, matching CLI
135
136
  // behavior where each `clef set` is its own commit. Serialize so
136
137
  // each transaction completes before the next starts.
138
+ let failure: string | null = null;
137
139
  for (const row of dirtyRows) {
138
140
  const payload: Record<string, unknown> = { value: row.value };
139
141
  if (confirmed) payload.confirmed = true;
140
- await apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
142
+ const res = await apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
141
143
  method: "PUT",
142
144
  headers: { "Content-Type": "application/json" },
143
145
  body: JSON.stringify(payload),
144
146
  });
147
+ if (!res.ok) {
148
+ // Bail on first failure — for dirty-tree / recipient errors the
149
+ // remaining PUTs will fail the same way, and piling errors on top
150
+ // of each other just obscures the root cause.
151
+ const data = await res.json().catch(() => ({}));
152
+ failure = data.error || `Failed to save ${row.key}`;
153
+ break;
154
+ }
145
155
  }
156
+ // Always reload from disk — on success to pick up server-side state
157
+ // (lastModified, metadata), on failure to snap the UI back to the
158
+ // current on-disk values and re-mask the inputs rather than leaving
159
+ // unsaved plaintext edits visible in an illusory "pending" state.
160
+ await loadData();
161
+ if (failure) setError(failure);
162
+ } catch {
146
163
  await loadData();
164
+ setError("Failed to save changes");
147
165
  } finally {
148
166
  setSaving(false);
149
167
  }
@@ -401,9 +419,12 @@ export function NamespaceEditor({ ns, initialEnv, manifest }: NamespaceEditorPro
401
419
  </div>
402
420
  )}
403
421
 
404
- {!loading && !error && (
422
+ {!loading && (
405
423
  <>
406
- {/* Keys table */}
424
+ {/* Keys table. Rendered even when `error` is set so a failed
425
+ save still shows the dirty rows + Save button for retry.
426
+ On load failure `rows` is empty, so the table renders
427
+ gracefully with no row body. */}
407
428
  <div
408
429
  style={{
409
430
  background: theme.surface,
@@ -0,0 +1,278 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { PolicyView } from "./PolicyView";
5
+ import type { FileRotationStatus, KeyRotationStatus } from "@clef-sh/core";
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ declare let global: any;
9
+
10
+ function okKey(name: string): KeyRotationStatus {
11
+ return {
12
+ key: name,
13
+ last_rotated_at: "2026-01-01T00:00:00Z",
14
+ last_rotated_known: true,
15
+ rotated_by: "alice <alice@example.com>",
16
+ rotation_count: 1,
17
+ rotation_due: "2026-04-01T00:00:00Z",
18
+ rotation_overdue: false,
19
+ days_overdue: 0,
20
+ compliant: true,
21
+ };
22
+ }
23
+
24
+ function overdueKey(name: string, daysOverdue: number): KeyRotationStatus {
25
+ return {
26
+ key: name,
27
+ last_rotated_at: "2025-01-01T00:00:00Z",
28
+ last_rotated_known: true,
29
+ rotated_by: "alice <alice@example.com>",
30
+ rotation_count: 2,
31
+ rotation_due: "2025-04-01T00:00:00Z",
32
+ rotation_overdue: true,
33
+ days_overdue: daysOverdue,
34
+ compliant: false,
35
+ };
36
+ }
37
+
38
+ function unknownKey(name: string): KeyRotationStatus {
39
+ return {
40
+ key: name,
41
+ last_rotated_at: null,
42
+ last_rotated_known: false,
43
+ rotated_by: null,
44
+ rotation_count: 0,
45
+ rotation_due: null,
46
+ rotation_overdue: false,
47
+ days_overdue: 0,
48
+ compliant: false,
49
+ };
50
+ }
51
+
52
+ function makeFile(overrides: Partial<FileRotationStatus> = {}): FileRotationStatus {
53
+ const base: FileRotationStatus = {
54
+ path: "database/dev.enc.yaml",
55
+ environment: "dev",
56
+ backend: "age",
57
+ recipients: ["age1abc"],
58
+ last_modified: "2026-01-01T00:00:00Z",
59
+ last_modified_known: true,
60
+ keys: [okKey("DB_URL")],
61
+ compliant: true,
62
+ };
63
+ const merged = { ...base, ...overrides };
64
+ if (!("compliant" in overrides)) {
65
+ merged.compliant = merged.keys.every((k) => k.compliant);
66
+ }
67
+ return merged;
68
+ }
69
+
70
+ const mixedResponse = {
71
+ files: [
72
+ makeFile({
73
+ path: "database/production.enc.yaml",
74
+ environment: "production",
75
+ keys: [overdueKey("DB_PROD_URL", 380)],
76
+ }),
77
+ makeFile({
78
+ path: "payments/staging.enc.yaml",
79
+ environment: "staging",
80
+ keys: [unknownKey("STRIPE_KEY")],
81
+ }),
82
+ makeFile({ path: "auth/dev.enc.yaml", environment: "dev", keys: [okKey("AUTH0_SECRET")] }),
83
+ ],
84
+ summary: { total_files: 3, compliant: 1, rotation_overdue: 2, unknown_metadata: 1 },
85
+ policy: { version: 1, rotation: { max_age_days: 90 } },
86
+ source: "default" as const,
87
+ };
88
+
89
+ const policyShowResponse = {
90
+ policy: { version: 1, rotation: { max_age_days: 90 } },
91
+ source: "default" as const,
92
+ path: ".clef/policy.yaml",
93
+ rawYaml: "version: 1\nrotation:\n max_age_days: 90\n",
94
+ };
95
+
96
+ const allCompliantResponse = {
97
+ files: [makeFile()],
98
+ summary: { total_files: 1, compliant: 1, rotation_overdue: 0, unknown_metadata: 0 },
99
+ policy: { version: 1, rotation: { max_age_days: 90 } },
100
+ source: "file" as const,
101
+ };
102
+
103
+ function mockFetchSequence(checkBody: object, policyBody: object): jest.Mock {
104
+ const fn = jest.fn().mockImplementation(async (url: string) => {
105
+ if (url.endsWith("/api/policy/check")) {
106
+ return { ok: true, json: () => Promise.resolve(checkBody) } as Response;
107
+ }
108
+ if (url.endsWith("/api/policy")) {
109
+ return { ok: true, json: () => Promise.resolve(policyBody) } as Response;
110
+ }
111
+ return { ok: false, json: () => Promise.resolve({}) } as Response;
112
+ });
113
+ global.fetch = fn;
114
+ return fn;
115
+ }
116
+
117
+ beforeEach(() => {
118
+ jest.clearAllMocks();
119
+ jest.restoreAllMocks();
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ delete (global as any).fetch;
122
+ });
123
+
124
+ describe("PolicyView", () => {
125
+ it("renders rotation rows grouped by status with summary chips", async () => {
126
+ mockFetchSequence(mixedResponse, policyShowResponse);
127
+
128
+ await act(async () => {
129
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
130
+ });
131
+
132
+ expect(screen.getByText("database/production.enc.yaml")).toBeInTheDocument();
133
+ expect(screen.getByText("payments/staging.enc.yaml")).toBeInTheDocument();
134
+ expect(screen.getByText("auth/dev.enc.yaml")).toBeInTheDocument();
135
+
136
+ expect(screen.getByTestId("filter-overdue")).toHaveTextContent("1");
137
+ expect(screen.getByTestId("filter-unknown")).toHaveTextContent("1");
138
+ expect(screen.getByTestId("filter-ok")).toHaveTextContent("1");
139
+ });
140
+
141
+ it("filters rows by status when a chip is clicked", async () => {
142
+ mockFetchSequence(mixedResponse, policyShowResponse);
143
+
144
+ await act(async () => {
145
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
146
+ });
147
+
148
+ await act(async () => {
149
+ fireEvent.click(screen.getByTestId("filter-overdue"));
150
+ });
151
+
152
+ expect(screen.getByText("database/production.enc.yaml")).toBeInTheDocument();
153
+ expect(screen.queryByText("auth/dev.enc.yaml")).not.toBeInTheDocument();
154
+ expect(screen.queryByText("payments/staging.enc.yaml")).not.toBeInTheDocument();
155
+ });
156
+
157
+ it("shows the all-compliant state when nothing is overdue or unknown", async () => {
158
+ mockFetchSequence(allCompliantResponse, policyShowResponse);
159
+
160
+ await act(async () => {
161
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
162
+ });
163
+
164
+ expect(screen.getByTestId("all-compliant")).toBeInTheDocument();
165
+ expect(screen.getByText("All compliant")).toBeInTheDocument();
166
+ // Per-key summary: 1 key across 1 file. Regex matches "1 key within ..." with any suffix.
167
+ expect(screen.getByText(/1 key within rotation window across 1 file/)).toBeInTheDocument();
168
+ });
169
+
170
+ it("shows the source badge — 'Built-in default' when source is 'default'", async () => {
171
+ mockFetchSequence(mixedResponse, policyShowResponse);
172
+
173
+ await act(async () => {
174
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
175
+ });
176
+
177
+ expect(screen.getByTestId("policy-source")).toHaveTextContent("Built-in default");
178
+ });
179
+
180
+ it("shows the source badge — '.clef/policy.yaml' when source is 'file'", async () => {
181
+ mockFetchSequence(allCompliantResponse, { ...policyShowResponse, source: "file" });
182
+
183
+ await act(async () => {
184
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
185
+ });
186
+
187
+ expect(screen.getByTestId("policy-source")).toHaveTextContent(".clef/policy.yaml");
188
+ });
189
+
190
+ it("toggles the YAML view via the View YAML button", async () => {
191
+ mockFetchSequence(mixedResponse, policyShowResponse);
192
+
193
+ await act(async () => {
194
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
195
+ });
196
+
197
+ expect(screen.queryByTestId("raw-yaml")).not.toBeInTheDocument();
198
+
199
+ await act(async () => {
200
+ fireEvent.click(screen.getByTestId("toggle-yaml"));
201
+ });
202
+
203
+ expect(screen.getByTestId("raw-yaml")).toBeInTheDocument();
204
+ expect(screen.getByTestId("raw-yaml")).toHaveTextContent("max_age_days: 90");
205
+ });
206
+
207
+ it("navigates to the editor when a file ref is clicked", async () => {
208
+ mockFetchSequence(mixedResponse, policyShowResponse);
209
+ const setView = jest.fn();
210
+ const setNs = jest.fn();
211
+
212
+ await act(async () => {
213
+ render(<PolicyView setView={setView} setNs={setNs} />);
214
+ });
215
+
216
+ fireEvent.click(screen.getByTestId("file-ref-database/production.enc.yaml"));
217
+
218
+ expect(setView).toHaveBeenCalledWith("editor");
219
+ expect(setNs).toHaveBeenCalledWith("database");
220
+ });
221
+
222
+ it("extracts the namespace from the second-to-last path segment (nested paths)", async () => {
223
+ // Bot repo layout: secrets/<namespace>/<env>.enc.yaml. The namespace is
224
+ // "github", not "secrets" — previously file.path.split("/")[0] was
225
+ // returning "secrets" and producing a wrong `clef set` hint.
226
+ const nested = {
227
+ files: [
228
+ makeFile({
229
+ path: "secrets/github/dev.enc.yaml",
230
+ environment: "dev",
231
+ keys: [unknownKey("GITHUB_CLIENT_SECRET")],
232
+ }),
233
+ ],
234
+ summary: { total_files: 1, compliant: 0, rotation_overdue: 1, unknown_metadata: 1 },
235
+ policy: { version: 1, rotation: { max_age_days: 90 } },
236
+ source: "default" as const,
237
+ };
238
+ mockFetchSequence(nested, policyShowResponse);
239
+ const setView = jest.fn();
240
+ const setNs = jest.fn();
241
+
242
+ await act(async () => {
243
+ render(<PolicyView setView={setView} setNs={setNs} />);
244
+ });
245
+
246
+ fireEvent.click(screen.getByTestId("file-ref-secrets/github/dev.enc.yaml"));
247
+
248
+ expect(setNs).toHaveBeenCalledWith("github");
249
+ // And the unknown-row hint should suggest the correct namespace too.
250
+ expect(
251
+ screen.getByText(/run clef set github\/dev GITHUB_CLIENT_SECRET to establish/),
252
+ ).toBeInTheDocument();
253
+ });
254
+
255
+ it("renders a per-environment override chip when policy has environments block", async () => {
256
+ const withEnvOverride = {
257
+ ...mixedResponse,
258
+ policy: {
259
+ version: 1,
260
+ rotation: {
261
+ max_age_days: 90,
262
+ environments: { production: { max_age_days: 30 } },
263
+ },
264
+ },
265
+ };
266
+ mockFetchSequence(withEnvOverride, {
267
+ ...policyShowResponse,
268
+ policy: withEnvOverride.policy,
269
+ });
270
+
271
+ await act(async () => {
272
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
273
+ });
274
+
275
+ expect(screen.getByText("PRODUCTION")).toBeInTheDocument();
276
+ expect(screen.getByText("30d")).toBeInTheDocument();
277
+ });
278
+ });