@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,94 @@
1
+ /**
2
+ * Tests for client/api.ts — session token management and apiFetch wrapper.
3
+ *
4
+ * Each test gets a fresh module instance via jest.resetModules() so the
5
+ * module-level `sessionToken` variable always starts as null.
6
+ */
7
+
8
+ describe("client/api module", () => {
9
+ let mod: typeof import("./api");
10
+
11
+ beforeEach(() => {
12
+ // Reset module registry so sessionToken starts as null for every test
13
+ jest.resetModules();
14
+ // Reset URL to a clean baseline
15
+ window.history.replaceState({}, "", "/");
16
+ // Clear sessionStorage so token state is clean
17
+ sessionStorage.clear();
18
+ // Get a fresh module
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ mod = require("./api") as typeof import("./api");
21
+ // Stub global fetch (jsdom does not provide a Response global)
22
+ global.fetch = jest.fn().mockResolvedValue({
23
+ ok: true,
24
+ json: jest.fn().mockResolvedValue({}),
25
+ } as unknown as Response);
26
+ });
27
+
28
+ afterEach(() => {
29
+ jest.restoreAllMocks();
30
+ sessionStorage.clear();
31
+ });
32
+
33
+ describe("initToken()", () => {
34
+ it("should do nothing when the URL has no token parameter", () => {
35
+ // No ?token= in the URL — should not throw and should leave sessionToken null
36
+ mod.initToken();
37
+ mod.apiFetch("/api/test");
38
+ const [, callInit] = (global.fetch as jest.Mock).mock.calls[0];
39
+ const headers = callInit.headers as Headers;
40
+ expect(headers.get("Authorization")).toBeNull();
41
+ });
42
+
43
+ it("should extract the token from the URL query string", () => {
44
+ window.history.pushState({}, "", "/?token=abc123");
45
+ mod.initToken();
46
+ // After initToken the token should be forwarded in apiFetch
47
+ mod.apiFetch("/api/test");
48
+ const [, callInit] = (global.fetch as jest.Mock).mock.calls[0];
49
+ const headers = callInit.headers as Headers;
50
+ expect(headers.get("Authorization")).toBe("Bearer abc123");
51
+ });
52
+
53
+ it("should remove the token from the URL after extracting it", () => {
54
+ window.history.pushState({}, "", "/?token=abc123");
55
+ const replaceSpy = jest.spyOn(window.history, "replaceState");
56
+ mod.initToken();
57
+ expect(replaceSpy).toHaveBeenCalled();
58
+ expect(window.location.search).not.toContain("token=");
59
+ });
60
+
61
+ it("should restore the token from sessionStorage on refresh (no URL token)", () => {
62
+ // Simulate a previous visit that saved the token
63
+ sessionStorage.setItem("clef_ui_token", "stored-token");
64
+ // URL has no token (simulates a browser refresh)
65
+ mod.initToken();
66
+ mod.apiFetch("/api/test");
67
+ const [, callInit] = (global.fetch as jest.Mock).mock.calls[0];
68
+ const headers = callInit.headers as Headers;
69
+ expect(headers.get("Authorization")).toBe("Bearer stored-token");
70
+ });
71
+
72
+ it("should persist the token to sessionStorage when extracted from URL", () => {
73
+ window.history.pushState({}, "", "/?token=abc123");
74
+ mod.initToken();
75
+ expect(sessionStorage.getItem("clef_ui_token")).toBe("abc123");
76
+ });
77
+ });
78
+
79
+ describe("apiFetch()", () => {
80
+ it("should call fetch without an Authorization header when no token is set", () => {
81
+ mod.apiFetch("/api/manifest");
82
+ const [, callInit] = (global.fetch as jest.Mock).mock.calls[0];
83
+ const headers = callInit.headers as Headers;
84
+ expect(headers.get("Authorization")).toBeNull();
85
+ });
86
+
87
+ it("should forward extra init options to fetch", () => {
88
+ mod.apiFetch("/api/test", { method: "POST", body: JSON.stringify({ x: 1 }) });
89
+ const [url, callInit] = (global.fetch as jest.Mock).mock.calls[0];
90
+ expect(url).toBe("/api/test");
91
+ expect(callInit.method).toBe("POST");
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,30 @@
1
+ // Session token — extracted from URL query parameter on initial page load.
2
+ // Persisted in sessionStorage so it survives same-tab refreshes but is
3
+ // cleared when the tab closes (never written to localStorage).
4
+ const SESSION_KEY = "clef_ui_token";
5
+
6
+ let sessionToken: string | null = null;
7
+
8
+ export function initToken(): void {
9
+ const params = new URLSearchParams(window.location.search);
10
+ const token = params.get("token");
11
+ if (token) {
12
+ sessionToken = token;
13
+ sessionStorage.setItem(SESSION_KEY, token);
14
+ // Remove token from URL to avoid leaking it in browser history
15
+ const url = new URL(window.location.href);
16
+ url.searchParams.delete("token");
17
+ window.history.replaceState({}, "", url.pathname + url.hash);
18
+ } else {
19
+ // Restore from sessionStorage on refresh (token no longer in URL)
20
+ sessionToken = sessionStorage.getItem(SESSION_KEY);
21
+ }
22
+ }
23
+
24
+ export function apiFetch(path: string, init?: RequestInit): Promise<Response> {
25
+ const headers = new Headers(init?.headers);
26
+ if (sessionToken) {
27
+ headers.set("Authorization", `Bearer ${sessionToken}`);
28
+ }
29
+ return fetch(path, { ...init, headers });
30
+ }
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+ import { theme } from "../theme";
3
+
4
+ interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
5
+ children: React.ReactNode;
6
+ variant?: "primary" | "ghost" | "danger";
7
+ icon?: React.ReactNode;
8
+ type?: "button" | "submit";
9
+ }
10
+
11
+ const VARIANT_STYLES = {
12
+ primary: { bg: theme.accent, color: "#000", border: "none" },
13
+ ghost: { bg: "transparent", color: theme.text, border: `1px solid ${theme.borderLight}` },
14
+ danger: { bg: theme.redDim, color: theme.red, border: `1px solid ${theme.red}44` },
15
+ };
16
+
17
+ export function Button({
18
+ children,
19
+ variant = "ghost",
20
+ onClick,
21
+ icon,
22
+ type = "button",
23
+ style: _styleProp,
24
+ ...rest
25
+ }: ButtonProps) {
26
+ const s = VARIANT_STYLES[variant];
27
+ return (
28
+ <button
29
+ type={type}
30
+ onClick={onClick}
31
+ {...rest}
32
+ style={{
33
+ display: "flex",
34
+ alignItems: "center",
35
+ gap: 6,
36
+ padding: "5px 12px",
37
+ borderRadius: 6,
38
+ cursor: "pointer",
39
+ fontFamily: theme.sans,
40
+ fontSize: 12,
41
+ fontWeight: 600,
42
+ background: s.bg,
43
+ color: s.color,
44
+ border: s.border,
45
+ transition: "all 0.12s",
46
+ }}
47
+ >
48
+ {icon && <span style={{ display: "flex" }}>{icon}</span>}
49
+ {children}
50
+ </button>
51
+ );
52
+ }
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { CopyButton } from "./CopyButton";
5
+
6
+ describe("CopyButton", () => {
7
+ beforeEach(() => {
8
+ Object.defineProperty(navigator, "clipboard", {
9
+ value: { writeText: jest.fn().mockResolvedValue(undefined) },
10
+ writable: true,
11
+ configurable: true,
12
+ });
13
+ jest.useFakeTimers();
14
+ });
15
+
16
+ afterEach(() => {
17
+ jest.useRealTimers();
18
+ });
19
+
20
+ it("renders with 'copy' label by default", () => {
21
+ render(<CopyButton text="hello world" />);
22
+ expect(screen.getByTestId("copy-button")).toBeInTheDocument();
23
+ expect(screen.getByTestId("copy-button")).toHaveTextContent("copy");
24
+ });
25
+
26
+ it("shows 'copied!' after clicking and reverts after timeout", async () => {
27
+ render(<CopyButton text="hello world" />);
28
+ const button = screen.getByTestId("copy-button");
29
+
30
+ act(() => {
31
+ fireEvent.click(button);
32
+ });
33
+
34
+ expect(button).toHaveTextContent("copied!");
35
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
36
+
37
+ act(() => {
38
+ jest.advanceTimersByTime(1800);
39
+ });
40
+
41
+ expect(button).toHaveTextContent("copy");
42
+ });
43
+ });
@@ -0,0 +1,36 @@
1
+ import React, { useState, useCallback } from "react";
2
+ import { theme } from "../theme";
3
+
4
+ interface CopyButtonProps {
5
+ text: string;
6
+ }
7
+
8
+ export function CopyButton({ text }: CopyButtonProps) {
9
+ const [copied, setCopied] = useState(false);
10
+
11
+ const handleCopy = useCallback(() => {
12
+ navigator.clipboard?.writeText(text);
13
+ setCopied(true);
14
+ setTimeout(() => setCopied(false), 1800);
15
+ }, [text]);
16
+
17
+ return (
18
+ <button
19
+ data-testid="copy-button"
20
+ onClick={handleCopy}
21
+ style={{
22
+ background: copied ? theme.greenDim : "none",
23
+ border: `1px solid ${copied ? theme.green + "55" : theme.borderLight}`,
24
+ borderRadius: 4,
25
+ cursor: "pointer",
26
+ color: copied ? theme.green : theme.textDim,
27
+ fontFamily: theme.mono,
28
+ fontSize: 10,
29
+ padding: "2px 8px",
30
+ transition: "all 0.15s",
31
+ }}
32
+ >
33
+ {copied ? "copied!" : "copy"}
34
+ </button>
35
+ );
36
+ }
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import { theme, ENV_COLORS } from "../theme";
3
+
4
+ interface EnvBadgeProps {
5
+ env: string;
6
+ small?: boolean;
7
+ }
8
+
9
+ export function EnvBadge({ env, small }: EnvBadgeProps) {
10
+ const c = ENV_COLORS[env] ?? {
11
+ color: theme.textMuted,
12
+ bg: "transparent",
13
+ label: env.toUpperCase().slice(0, 3),
14
+ };
15
+ return (
16
+ <span
17
+ style={{
18
+ fontFamily: theme.mono,
19
+ fontSize: small ? "9px" : "10px",
20
+ fontWeight: 700,
21
+ color: c.color,
22
+ background: c.bg,
23
+ border: `1px solid ${c.color}33`,
24
+ borderRadius: "3px",
25
+ padding: small ? "1px 5px" : "2px 7px",
26
+ letterSpacing: "0.08em",
27
+ }}
28
+ >
29
+ {c.label}
30
+ </span>
31
+ );
32
+ }
@@ -0,0 +1,265 @@
1
+ import React from "react";
2
+ import { theme } from "../theme";
3
+ import { EnvBadge } from "./EnvBadge";
4
+ import { StatusDot } from "./StatusDot";
5
+ import type { MatrixStatus } from "@clef-sh/core";
6
+
7
+ export interface MatrixGridProps {
8
+ namespaces: Array<{ name: string }>;
9
+ environments: Array<{ name: string }>;
10
+ matrixStatuses: MatrixStatus[];
11
+ onNamespaceClick?: (ns: string) => void;
12
+ }
13
+
14
+ function getStatusType(status: MatrixStatus): string {
15
+ if (!status.cell.exists) return "missing_keys";
16
+ const hasError = status.issues.some((i) => i.type === "missing_keys" || i.type === "sops_error");
17
+ const hasWarning = status.issues.some((i) => i.type === "schema_warning");
18
+ if (hasError) return "missing_keys";
19
+ if (hasWarning) return "schema_warn";
20
+ return "ok";
21
+ }
22
+
23
+ function formatDate(d: Date | null): string {
24
+ if (!d) return "never";
25
+ const now = new Date();
26
+ const diffMs = now.getTime() - d.getTime();
27
+ const diffH = Math.floor(diffMs / (1000 * 60 * 60));
28
+ if (diffH < 1) return "just now";
29
+ if (diffH < 24) return `${diffH}h ago`;
30
+ const diffD = Math.floor(diffH / 24);
31
+ if (diffD < 7) return `${diffD}d ago`;
32
+ const diffW = Math.floor(diffD / 7);
33
+ return `${diffW}w ago`;
34
+ }
35
+
36
+ export function MatrixGrid({
37
+ namespaces,
38
+ environments,
39
+ matrixStatuses,
40
+ onNamespaceClick,
41
+ }: MatrixGridProps) {
42
+ return (
43
+ <div
44
+ data-testid="matrix-table"
45
+ style={{
46
+ background: theme.surface,
47
+ border: `1px solid ${theme.border}`,
48
+ borderRadius: 12,
49
+ overflow: "hidden",
50
+ }}
51
+ >
52
+ {/* Header row */}
53
+ <div
54
+ style={{
55
+ display: "grid",
56
+ gridTemplateColumns: `180px ${environments.map(() => "1fr").join(" ")}`,
57
+ borderBottom: `1px solid ${theme.border}`,
58
+ background: "#0D0F14",
59
+ }}
60
+ >
61
+ <div
62
+ style={{
63
+ padding: "12px 20px",
64
+ fontFamily: theme.sans,
65
+ fontSize: 11,
66
+ fontWeight: 600,
67
+ color: theme.textMuted,
68
+ textTransform: "uppercase",
69
+ letterSpacing: "0.08em",
70
+ }}
71
+ >
72
+ Namespace
73
+ </div>
74
+ {environments.map((env) => (
75
+ <div
76
+ key={env.name}
77
+ style={{
78
+ padding: "12px 20px",
79
+ display: "flex",
80
+ alignItems: "center",
81
+ gap: 8,
82
+ borderLeft: `1px solid ${theme.border}`,
83
+ }}
84
+ >
85
+ <EnvBadge env={env.name} />
86
+ <span
87
+ style={{
88
+ fontFamily: theme.sans,
89
+ fontSize: 12,
90
+ fontWeight: 500,
91
+ color: theme.text,
92
+ }}
93
+ >
94
+ {env.name}
95
+ </span>
96
+ </div>
97
+ ))}
98
+ </div>
99
+
100
+ {/* Namespace rows */}
101
+ {namespaces.map((ns, i) => (
102
+ <div
103
+ key={ns.name}
104
+ data-testid={`matrix-row-${ns.name}`}
105
+ role="button"
106
+ tabIndex={0}
107
+ onClick={() => onNamespaceClick?.(ns.name)}
108
+ onKeyDown={(e) => {
109
+ if (e.key === "Enter") onNamespaceClick?.(ns.name);
110
+ }}
111
+ style={{
112
+ display: "grid",
113
+ gridTemplateColumns: `180px ${environments.map(() => "1fr").join(" ")}`,
114
+ borderBottom: i < namespaces.length - 1 ? `1px solid ${theme.border}` : "none",
115
+ cursor: onNamespaceClick ? "pointer" : "default",
116
+ transition: "background 0.1s",
117
+ }}
118
+ onMouseEnter={(e) => {
119
+ (e.currentTarget as HTMLElement).style.background = theme.surfaceHover;
120
+ }}
121
+ onMouseLeave={(e) => {
122
+ (e.currentTarget as HTMLElement).style.background = "transparent";
123
+ }}
124
+ >
125
+ {/* Namespace label */}
126
+ <div
127
+ style={{
128
+ padding: "16px 20px",
129
+ display: "flex",
130
+ alignItems: "center",
131
+ gap: 10,
132
+ }}
133
+ >
134
+ <span
135
+ style={{
136
+ fontFamily: theme.mono,
137
+ fontSize: 11,
138
+ color: theme.textDim,
139
+ }}
140
+ >
141
+ //
142
+ </span>
143
+ <span
144
+ style={{
145
+ fontFamily: theme.mono,
146
+ fontSize: 13,
147
+ fontWeight: 600,
148
+ color: theme.text,
149
+ }}
150
+ >
151
+ {ns.name}
152
+ </span>
153
+ </div>
154
+
155
+ {/* Environment cells */}
156
+ {environments.map((env) => {
157
+ const cellStatus = matrixStatuses.find(
158
+ (s) => s.cell.namespace === ns.name && s.cell.environment === env.name,
159
+ );
160
+ const statusType = cellStatus ? getStatusType(cellStatus) : "ok";
161
+ const keyCount = cellStatus?.keyCount ?? 0;
162
+ const lastMod = cellStatus?.lastModified
163
+ ? formatDate(
164
+ cellStatus.lastModified instanceof Date
165
+ ? cellStatus.lastModified
166
+ : new Date(cellStatus.lastModified as unknown as string),
167
+ )
168
+ : "never";
169
+ const missingKeyCount = cellStatus
170
+ ? new Set(
171
+ cellStatus.issues
172
+ .filter((i) => i.type === "missing_keys" && i.key)
173
+ .map((i) => i.key),
174
+ ).size
175
+ : 0;
176
+ const warnKeyCount = cellStatus
177
+ ? cellStatus.issues.filter((i) => i.type === "schema_warning").length
178
+ : 0;
179
+ const cellPending = cellStatus?.pendingCount ?? 0;
180
+
181
+ return (
182
+ <div
183
+ key={env.name}
184
+ style={{
185
+ padding: "14px 20px",
186
+ borderLeft: `1px solid ${theme.border}`,
187
+ display: "flex",
188
+ flexDirection: "column",
189
+ gap: 5,
190
+ }}
191
+ >
192
+ <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
193
+ <StatusDot status={statusType} />
194
+ <span
195
+ style={{
196
+ fontFamily: theme.mono,
197
+ fontSize: 11,
198
+ color: theme.textMuted,
199
+ }}
200
+ >
201
+ {keyCount} keys
202
+ </span>
203
+ {missingKeyCount > 0 && (
204
+ <span
205
+ style={{
206
+ fontFamily: theme.mono,
207
+ fontSize: 10,
208
+ color: theme.red,
209
+ background: theme.redDim,
210
+ border: `1px solid ${theme.red}33`,
211
+ borderRadius: 3,
212
+ padding: "1px 5px",
213
+ }}
214
+ >
215
+ -{missingKeyCount} missing
216
+ </span>
217
+ )}
218
+ {warnKeyCount > 0 && (
219
+ <span
220
+ style={{
221
+ fontFamily: theme.mono,
222
+ fontSize: 10,
223
+ color: theme.yellow,
224
+ background: theme.yellowDim,
225
+ border: `1px solid ${theme.yellow}33`,
226
+ borderRadius: 3,
227
+ padding: "1px 5px",
228
+ }}
229
+ >
230
+ {warnKeyCount} warn
231
+ </span>
232
+ )}
233
+ {cellPending > 0 && (
234
+ <span
235
+ style={{
236
+ fontFamily: theme.mono,
237
+ fontSize: 10,
238
+ color: theme.accent,
239
+ background: `${theme.accent}18`,
240
+ border: `1px solid ${theme.accent}33`,
241
+ borderRadius: 3,
242
+ padding: "1px 5px",
243
+ }}
244
+ >
245
+ {cellPending} pending
246
+ </span>
247
+ )}
248
+ </div>
249
+ <div
250
+ style={{
251
+ fontFamily: theme.mono,
252
+ fontSize: 10,
253
+ color: theme.textDim,
254
+ }}
255
+ >
256
+ {lastMod}
257
+ </div>
258
+ </div>
259
+ );
260
+ })}
261
+ </div>
262
+ ))}
263
+ </div>
264
+ );
265
+ }