@clef-sh/ui 0.1.20 → 0.1.21
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.
- package/dist/client/assets/index-DPWHjBbB.js +34 -0
- package/dist/client/assets/index-qsLTYpc9.css +2 -0
- package/dist/client/clef.svg +2 -0
- package/dist/client/index.html +3 -31
- package/dist/client-lib/components/Button.d.ts +1 -1
- package/dist/client-lib/components/Button.d.ts.map +1 -1
- package/dist/client-lib/components/CopyButton.d.ts.map +1 -1
- package/dist/client-lib/components/EnvBadge.d.ts.map +1 -1
- package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -1
- package/dist/client-lib/components/Sidebar.d.ts +1 -1
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
- package/dist/client-lib/components/StatusDot.d.ts.map +1 -1
- package/dist/client-lib/components/SyncPanel.d.ts.map +1 -1
- package/dist/client-lib/components/TopBar.d.ts +6 -0
- package/dist/client-lib/components/TopBar.d.ts.map +1 -1
- package/dist/client-lib/primitives/Badge.d.ts +11 -0
- package/dist/client-lib/primitives/Badge.d.ts.map +1 -0
- package/dist/client-lib/primitives/Card.d.ts +28 -0
- package/dist/client-lib/primitives/Card.d.ts.map +1 -0
- package/dist/client-lib/primitives/Dialog.d.ts +30 -0
- package/dist/client-lib/primitives/Dialog.d.ts.map +1 -0
- package/dist/client-lib/primitives/EmptyState.d.ts +10 -0
- package/dist/client-lib/primitives/EmptyState.d.ts.map +1 -0
- package/dist/client-lib/primitives/Field.d.ts +36 -0
- package/dist/client-lib/primitives/Field.d.ts.map +1 -0
- package/dist/client-lib/primitives/Input.d.ts +6 -0
- package/dist/client-lib/primitives/Input.d.ts.map +1 -0
- package/dist/client-lib/primitives/Stat.d.ts +11 -0
- package/dist/client-lib/primitives/Stat.d.ts.map +1 -0
- package/dist/client-lib/primitives/Table.d.ts +37 -0
- package/dist/client-lib/primitives/Table.d.ts.map +1 -0
- package/dist/client-lib/primitives/Tabs.d.ts +29 -0
- package/dist/client-lib/primitives/Tabs.d.ts.map +1 -0
- package/dist/client-lib/primitives/Toast.d.ts +16 -0
- package/dist/client-lib/primitives/Toast.d.ts.map +1 -0
- package/dist/client-lib/primitives/Toolbar.d.ts +29 -0
- package/dist/client-lib/primitives/Toolbar.d.ts.map +1 -0
- package/dist/client-lib/primitives/index.d.ts +23 -0
- package/dist/client-lib/primitives/index.d.ts.map +1 -0
- package/dist/client-lib/theme.d.ts +18 -41
- package/dist/client-lib/theme.d.ts.map +1 -1
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +215 -0
- package/dist/server/api.js.map +1 -1
- package/dist/server/envelope.d.ts +15 -0
- package/dist/server/envelope.d.ts.map +1 -0
- package/dist/server/envelope.js +310 -0
- package/dist/server/envelope.js.map +1 -0
- package/package.json +7 -2
- package/src/client/App.tsx +16 -41
- package/src/client/components/Button.tsx +13 -22
- package/src/client/components/CopyButton.tsx +5 -12
- package/src/client/components/EnvBadge.tsx +30 -15
- package/src/client/components/MatrixGrid.tsx +108 -252
- package/src/client/components/Sidebar.tsx +123 -199
- package/src/client/components/StatusDot.tsx +10 -15
- package/src/client/components/SyncPanel.tsx +14 -62
- package/src/client/components/TopBar.tsx +11 -36
- package/src/client/index.html +1 -30
- package/src/client/main.tsx +1 -0
- package/src/client/primitives/Badge.test.tsx +47 -0
- package/src/client/primitives/Badge.tsx +64 -0
- package/src/client/primitives/Card.test.tsx +50 -0
- package/src/client/primitives/Card.tsx +85 -0
- package/src/client/primitives/Dialog.test.tsx +55 -0
- package/src/client/primitives/Dialog.tsx +96 -0
- package/src/client/primitives/EmptyState.test.tsx +25 -0
- package/src/client/primitives/EmptyState.tsx +38 -0
- package/src/client/primitives/Field.test.tsx +46 -0
- package/src/client/primitives/Field.tsx +95 -0
- package/src/client/primitives/Input.tsx +26 -0
- package/src/client/primitives/Stat.test.tsx +32 -0
- package/src/client/primitives/Stat.tsx +52 -0
- package/src/client/primitives/Table.test.tsx +58 -0
- package/src/client/primitives/Table.tsx +113 -0
- package/src/client/primitives/Tabs.test.tsx +44 -0
- package/src/client/primitives/Tabs.tsx +100 -0
- package/src/client/primitives/Toast.test.tsx +77 -0
- package/src/client/primitives/Toast.tsx +89 -0
- package/src/client/primitives/Toolbar.test.tsx +50 -0
- package/src/client/primitives/Toolbar.tsx +86 -0
- package/src/client/primitives/index.ts +43 -0
- package/src/client/public/clef.svg +2 -0
- package/src/client/screens/BackendScreen.tsx +104 -363
- package/src/client/screens/DiffView.tsx +187 -378
- package/src/client/screens/EnvelopeScreen.test.tsx +542 -0
- package/src/client/screens/EnvelopeScreen.tsx +948 -0
- package/src/client/screens/GitLogView.tsx +48 -106
- package/src/client/screens/ImportScreen.tsx +105 -308
- package/src/client/screens/LintView.tsx +184 -379
- package/src/client/screens/ManifestScreen.tsx +283 -445
- package/src/client/screens/MatrixView.tsx +75 -91
- package/src/client/screens/NamespaceEditor.tsx +234 -609
- package/src/client/screens/PolicyView.tsx +183 -453
- package/src/client/screens/RecipientsScreen.tsx +71 -350
- package/src/client/screens/ResetScreen.tsx +67 -237
- package/src/client/screens/ScanScreen.tsx +85 -249
- package/src/client/screens/SchemaEditor.test.tsx +237 -0
- package/src/client/screens/SchemaEditor.tsx +435 -0
- package/src/client/screens/ServiceIdentitiesScreen.tsx +251 -788
- package/src/client/styles.css +77 -0
- package/src/client/theme.ts +27 -48
- package/dist/client/assets/index-Db6WgHgY.js +0 -38
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { SchemaEditor } from "./SchemaEditor";
|
|
5
|
+
import type { ClefManifest } from "@clef-sh/core";
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
declare let global: any;
|
|
9
|
+
|
|
10
|
+
const manifest: ClefManifest = {
|
|
11
|
+
version: 1,
|
|
12
|
+
environments: [
|
|
13
|
+
{ name: "dev", description: "Dev" },
|
|
14
|
+
{ name: "prod", description: "Prod", protected: true },
|
|
15
|
+
],
|
|
16
|
+
namespaces: [{ name: "auth", description: "Auth" }],
|
|
17
|
+
sops: { default_backend: "age" },
|
|
18
|
+
file_pattern: "{namespace}/{environment}.enc.yaml",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
interface FetchSpec {
|
|
22
|
+
schema?: { ok?: boolean; status?: number; body: unknown };
|
|
23
|
+
schemaPut?: { ok?: boolean; status?: number; body: unknown };
|
|
24
|
+
values?: { ok?: boolean; body: unknown };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mockRoutes(spec: FetchSpec): jest.Mock {
|
|
28
|
+
const fetchMock = jest.fn(async (url: string, init?: RequestInit) => {
|
|
29
|
+
const method = init?.method ?? "GET";
|
|
30
|
+
if (url.match(/\/api\/namespaces\/[^/]+\/schema$/) && method === "GET") {
|
|
31
|
+
const s = spec.schema ?? {
|
|
32
|
+
ok: true,
|
|
33
|
+
body: { attached: false, path: null, schema: { keys: {} } },
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
ok: s.ok !== false,
|
|
37
|
+
status: s.status ?? 200,
|
|
38
|
+
json: () => Promise.resolve(s.body),
|
|
39
|
+
} as Response;
|
|
40
|
+
}
|
|
41
|
+
if (url.match(/\/api\/namespaces\/[^/]+\/schema$/) && method === "PUT") {
|
|
42
|
+
const s = spec.schemaPut ?? {
|
|
43
|
+
ok: true,
|
|
44
|
+
body: { attached: true, path: "schemas/auth.yaml", schema: { keys: {} } },
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
ok: s.ok !== false,
|
|
48
|
+
status: s.status ?? 200,
|
|
49
|
+
json: () => Promise.resolve(s.body),
|
|
50
|
+
} as Response;
|
|
51
|
+
}
|
|
52
|
+
if (url.match(/\/api\/namespace\/[^/]+\/[^/]+$/) && method === "GET") {
|
|
53
|
+
const s = spec.values ?? { ok: true, body: { values: {} } };
|
|
54
|
+
return {
|
|
55
|
+
ok: s.ok !== false,
|
|
56
|
+
status: 200,
|
|
57
|
+
json: () => Promise.resolve(s.body),
|
|
58
|
+
} as Response;
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Unmocked fetch: ${method} ${url}`);
|
|
61
|
+
});
|
|
62
|
+
global.fetch = fetchMock;
|
|
63
|
+
return fetchMock;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
jest.clearAllMocks();
|
|
68
|
+
jest.restoreAllMocks();
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
delete (global as any).fetch;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("SchemaEditor", () => {
|
|
74
|
+
it("loads an attached schema and renders one row per key", async () => {
|
|
75
|
+
mockRoutes({
|
|
76
|
+
schema: {
|
|
77
|
+
ok: true,
|
|
78
|
+
body: {
|
|
79
|
+
attached: true,
|
|
80
|
+
path: "schemas/auth.yaml",
|
|
81
|
+
schema: {
|
|
82
|
+
keys: {
|
|
83
|
+
API_KEY: { type: "string", required: true, pattern: "^sk_" },
|
|
84
|
+
FLAG: { type: "boolean", required: false },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await act(async () => {
|
|
92
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(screen.getByDisplayValue("API_KEY")).toBeInTheDocument();
|
|
96
|
+
expect(screen.getByDisplayValue("FLAG")).toBeInTheDocument();
|
|
97
|
+
expect(screen.getByDisplayValue("^sk_")).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("shows the empty-state hint when the namespace has no schema yet", async () => {
|
|
101
|
+
mockRoutes({});
|
|
102
|
+
await act(async () => {
|
|
103
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
104
|
+
});
|
|
105
|
+
expect(screen.getByText(/No keys declared yet/i)).toBeInTheDocument();
|
|
106
|
+
// TopBar subtitle promises auto-create on save
|
|
107
|
+
expect(screen.getByText(/saving will create one at schemas\/auth\.yaml/i)).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("adds a new key row when '+ Add key' is clicked", async () => {
|
|
111
|
+
mockRoutes({});
|
|
112
|
+
await act(async () => {
|
|
113
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
114
|
+
});
|
|
115
|
+
fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
|
|
116
|
+
expect(screen.getByPlaceholderText("KEY_NAME")).toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("validates regex patterns inline against the preview env's sample value", async () => {
|
|
120
|
+
mockRoutes({
|
|
121
|
+
schema: {
|
|
122
|
+
ok: true,
|
|
123
|
+
body: {
|
|
124
|
+
attached: true,
|
|
125
|
+
path: "schemas/auth.yaml",
|
|
126
|
+
schema: { keys: { API_KEY: { type: "string", required: true, pattern: "^sk_" } } },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
values: { ok: true, body: { values: { API_KEY: "sk_test_abc" } } },
|
|
130
|
+
});
|
|
131
|
+
await act(async () => {
|
|
132
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
133
|
+
});
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(screen.getByText(/matches sample value/)).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("shows a miss when the pattern doesn't match the sample, and never echoes the value", async () => {
|
|
140
|
+
mockRoutes({
|
|
141
|
+
schema: {
|
|
142
|
+
ok: true,
|
|
143
|
+
body: {
|
|
144
|
+
attached: true,
|
|
145
|
+
path: "schemas/auth.yaml",
|
|
146
|
+
schema: { keys: { API_KEY: { type: "string", required: true, pattern: "^pk_" } } },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
values: { ok: true, body: { values: { API_KEY: "sk_test_abc" } } },
|
|
150
|
+
});
|
|
151
|
+
await act(async () => {
|
|
152
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
153
|
+
});
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(screen.getByText(/did not match/)).toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
// The decrypted sample value must not appear anywhere in the rendered
|
|
158
|
+
// result — only the binary match/miss outcome is leaked.
|
|
159
|
+
expect(screen.queryByText(/sk_test_abc/)).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("flags an invalid regex as a row error and disables Save", async () => {
|
|
163
|
+
mockRoutes({});
|
|
164
|
+
await act(async () => {
|
|
165
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
166
|
+
});
|
|
167
|
+
fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
|
|
168
|
+
fireEvent.change(screen.getByPlaceholderText("KEY_NAME"), { target: { value: "K" } });
|
|
169
|
+
fireEvent.change(screen.getByPlaceholderText(/pattern: \^regex/), {
|
|
170
|
+
target: { value: "([" },
|
|
171
|
+
});
|
|
172
|
+
expect(screen.getByText(/not a valid regex/i)).toBeInTheDocument();
|
|
173
|
+
expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("flags duplicate key names", async () => {
|
|
177
|
+
mockRoutes({});
|
|
178
|
+
await act(async () => {
|
|
179
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
180
|
+
});
|
|
181
|
+
fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
|
|
182
|
+
fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
|
|
183
|
+
const inputs = screen.getAllByPlaceholderText("KEY_NAME");
|
|
184
|
+
fireEvent.change(inputs[0], { target: { value: "DUPE" } });
|
|
185
|
+
fireEvent.change(inputs[1], { target: { value: "DUPE" } });
|
|
186
|
+
expect(screen.getByText(/Duplicate key name/i)).toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("PUTs the schema on Save and shows a confirmation toast with the saved path", async () => {
|
|
190
|
+
const fetchMock = mockRoutes({
|
|
191
|
+
schemaPut: {
|
|
192
|
+
ok: true,
|
|
193
|
+
body: {
|
|
194
|
+
attached: true,
|
|
195
|
+
path: "schemas/auth.yaml",
|
|
196
|
+
schema: { keys: { API_KEY: { type: "string", required: true } } },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
await act(async () => {
|
|
201
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
202
|
+
});
|
|
203
|
+
fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
|
|
204
|
+
fireEvent.change(screen.getByPlaceholderText("KEY_NAME"), { target: { value: "API_KEY" } });
|
|
205
|
+
|
|
206
|
+
await act(async () => {
|
|
207
|
+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const putCall = fetchMock.mock.calls.find(
|
|
211
|
+
(c) => c[0].match(/\/api\/namespaces\/auth\/schema$/) && c[1]?.method === "PUT",
|
|
212
|
+
);
|
|
213
|
+
expect(putCall).toBeDefined();
|
|
214
|
+
const body = JSON.parse(putCall![1].body as string);
|
|
215
|
+
expect(body.schema.keys.API_KEY).toMatchObject({ type: "string", required: true });
|
|
216
|
+
await waitFor(() => {
|
|
217
|
+
expect(screen.getByText(/Saved at .* · schemas\/auth\.yaml/)).toBeInTheDocument();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("surfaces a server-side error from the PUT", async () => {
|
|
222
|
+
mockRoutes({
|
|
223
|
+
schemaPut: { ok: false, status: 400, body: { error: "Boom", code: "INVALID_SCHEMA" } },
|
|
224
|
+
});
|
|
225
|
+
await act(async () => {
|
|
226
|
+
render(<SchemaEditor ns="auth" manifest={manifest} />);
|
|
227
|
+
});
|
|
228
|
+
fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
|
|
229
|
+
fireEvent.change(screen.getByPlaceholderText("KEY_NAME"), { target: { value: "K" } });
|
|
230
|
+
|
|
231
|
+
await act(async () => {
|
|
232
|
+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(screen.getByText("Boom")).toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { apiFetch } from "../api";
|
|
3
|
+
import { Button } from "../components/Button";
|
|
4
|
+
import { Toolbar, EmptyState } from "../primitives";
|
|
5
|
+
import type { ClefManifest, NamespaceSchema, SchemaKey } from "@clef-sh/core";
|
|
6
|
+
|
|
7
|
+
interface SchemaEditorProps {
|
|
8
|
+
ns: string;
|
|
9
|
+
manifest: ClefManifest | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface KeyRow {
|
|
13
|
+
/** Original name when the row came from disk; null for newly added rows. */
|
|
14
|
+
originalName: string | null;
|
|
15
|
+
/** Editable name field. */
|
|
16
|
+
name: string;
|
|
17
|
+
type: "string" | "integer" | "boolean";
|
|
18
|
+
required: boolean;
|
|
19
|
+
pattern: string;
|
|
20
|
+
description: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SchemaResponse {
|
|
24
|
+
namespace: string;
|
|
25
|
+
attached: boolean;
|
|
26
|
+
path: string | null;
|
|
27
|
+
schema: NamespaceSchema;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TYPES: KeyRow["type"][] = ["string", "integer", "boolean"];
|
|
31
|
+
|
|
32
|
+
const INPUT_BASE =
|
|
33
|
+
"rounded border border-edge bg-ink-950 px-2 py-1 font-sans text-[12px] text-bone outline-none focus-visible:border-gold-500 placeholder:text-ash-dim disabled:text-ash";
|
|
34
|
+
const INPUT_MONO =
|
|
35
|
+
"rounded border border-edge bg-ink-950 px-2 py-1 font-mono text-[12px] text-bone outline-none focus-visible:border-gold-500 placeholder:text-ash-dim disabled:text-ash";
|
|
36
|
+
|
|
37
|
+
export function SchemaEditor({ ns, manifest }: SchemaEditorProps) {
|
|
38
|
+
const [rows, setRows] = useState<KeyRow[]>([]);
|
|
39
|
+
const [attached, setAttached] = useState(false);
|
|
40
|
+
const [pathOnDisk, setPathOnDisk] = useState<string | null>(null);
|
|
41
|
+
const [previewEnv, setPreviewEnv] = useState<string>("");
|
|
42
|
+
const [sampleValues, setSampleValues] = useState<Record<string, string>>({});
|
|
43
|
+
const [loading, setLoading] = useState(false);
|
|
44
|
+
const [saving, setSaving] = useState(false);
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
const [savedAt, setSavedAt] = useState<string | null>(null);
|
|
47
|
+
const [savedPath, setSavedPath] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
const environments = manifest?.environments ?? [];
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!previewEnv && environments.length > 0) {
|
|
53
|
+
const firstUnprotected = environments.find((e) => !e.protected) ?? environments[0];
|
|
54
|
+
setPreviewEnv(firstUnprotected.name);
|
|
55
|
+
}
|
|
56
|
+
}, [environments, previewEnv]);
|
|
57
|
+
|
|
58
|
+
const loadSchema = useCallback(async () => {
|
|
59
|
+
if (!ns) return;
|
|
60
|
+
setLoading(true);
|
|
61
|
+
setError(null);
|
|
62
|
+
try {
|
|
63
|
+
const res = await apiFetch(`/api/namespaces/${ns}/schema`);
|
|
64
|
+
const body = await res.json();
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
setError(body.error || "Failed to load schema");
|
|
67
|
+
setRows([]);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const data = body as SchemaResponse;
|
|
71
|
+
setAttached(data.attached);
|
|
72
|
+
setPathOnDisk(data.path);
|
|
73
|
+
setRows(schemaToRows(data.schema));
|
|
74
|
+
} catch {
|
|
75
|
+
setError("Failed to load schema");
|
|
76
|
+
} finally {
|
|
77
|
+
setLoading(false);
|
|
78
|
+
}
|
|
79
|
+
}, [ns]);
|
|
80
|
+
|
|
81
|
+
const loadSampleValues = useCallback(async () => {
|
|
82
|
+
if (!ns || !previewEnv) return;
|
|
83
|
+
try {
|
|
84
|
+
const res = await apiFetch(`/api/namespace/${ns}/${previewEnv}`);
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
setSampleValues({});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const data = (await res.json()) as { values: Record<string, string> };
|
|
90
|
+
setSampleValues(data.values ?? {});
|
|
91
|
+
} catch {
|
|
92
|
+
setSampleValues({});
|
|
93
|
+
}
|
|
94
|
+
}, [ns, previewEnv]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
loadSchema();
|
|
98
|
+
}, [loadSchema]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
loadSampleValues();
|
|
102
|
+
}, [loadSampleValues]);
|
|
103
|
+
|
|
104
|
+
const handleSave = async () => {
|
|
105
|
+
setSaving(true);
|
|
106
|
+
setError(null);
|
|
107
|
+
setSavedAt(null);
|
|
108
|
+
try {
|
|
109
|
+
const schema = rowsToSchema(rows);
|
|
110
|
+
const res = await apiFetch(`/api/namespaces/${ns}/schema`, {
|
|
111
|
+
method: "PUT",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({ schema }),
|
|
114
|
+
});
|
|
115
|
+
const body = await res.json();
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
setError(body.error || "Failed to save schema");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const data = body as SchemaResponse;
|
|
121
|
+
setAttached(data.attached);
|
|
122
|
+
setPathOnDisk(data.path);
|
|
123
|
+
setSavedPath(data.path);
|
|
124
|
+
setSavedAt(new Date().toLocaleTimeString());
|
|
125
|
+
// Refresh from server so any normalisation (e.g. trimmed empty fields)
|
|
126
|
+
// shows up in the editor and "edited" markers reset.
|
|
127
|
+
setRows(schemaToRows(data.schema));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
setError(err instanceof Error ? err.message : "Failed to save schema");
|
|
130
|
+
} finally {
|
|
131
|
+
setSaving(false);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const handleAddRow = () => {
|
|
136
|
+
setRows((prev) => [
|
|
137
|
+
...prev,
|
|
138
|
+
{
|
|
139
|
+
originalName: null,
|
|
140
|
+
name: "",
|
|
141
|
+
type: "string",
|
|
142
|
+
required: true,
|
|
143
|
+
pattern: "",
|
|
144
|
+
description: "",
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleRemoveRow = (idx: number) => {
|
|
150
|
+
setRows((prev) => prev.filter((_, i) => i !== idx));
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const updateRow = (idx: number, patch: Partial<KeyRow>) => {
|
|
154
|
+
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const validation = useMemo(() => validateRows(rows), [rows]);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div className="flex h-full flex-col">
|
|
161
|
+
<Toolbar>
|
|
162
|
+
<div>
|
|
163
|
+
<Toolbar.Title>{`Schema · ${ns || "(no namespace selected)"}`}</Toolbar.Title>
|
|
164
|
+
<Toolbar.Subtitle>
|
|
165
|
+
{attached && pathOnDisk
|
|
166
|
+
? pathOnDisk
|
|
167
|
+
: ns
|
|
168
|
+
? `no schema attached yet — saving will create one at schemas/${ns}.yaml`
|
|
169
|
+
: ""}
|
|
170
|
+
</Toolbar.Subtitle>
|
|
171
|
+
</div>
|
|
172
|
+
<Toolbar.Actions>
|
|
173
|
+
<Button onClick={handleAddRow} disabled={!ns || loading}>
|
|
174
|
+
+ Add key
|
|
175
|
+
</Button>
|
|
176
|
+
<Button variant="primary" onClick={handleSave} disabled={!ns || saving || !validation.ok}>
|
|
177
|
+
{saving ? "Saving…" : "Save"}
|
|
178
|
+
</Button>
|
|
179
|
+
</Toolbar.Actions>
|
|
180
|
+
</Toolbar>
|
|
181
|
+
|
|
182
|
+
<div className="flex-1 overflow-auto px-6 py-4 font-sans text-bone">
|
|
183
|
+
{error && (
|
|
184
|
+
<div className="mb-3 rounded-md border border-stop-500/30 bg-stop-500/10 px-3 py-2 text-[12px] text-stop-500">
|
|
185
|
+
{error}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{savedAt && savedPath && !error && (
|
|
190
|
+
<div className="mb-3 rounded-md border border-edge bg-ink-850 px-3 py-2 text-[12px] text-ash">
|
|
191
|
+
Saved at {savedAt} · {savedPath}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{loading && <div className="text-[12px] text-ash">Loading…</div>}
|
|
196
|
+
|
|
197
|
+
{!loading && rows.length === 0 && (
|
|
198
|
+
<EmptyState
|
|
199
|
+
title="No keys declared yet"
|
|
200
|
+
body="Click + Add key to start building the schema."
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{!loading && rows.length > 0 && (
|
|
205
|
+
<div className="flex flex-col gap-2">
|
|
206
|
+
<PreviewEnvPicker
|
|
207
|
+
environments={environments.map((e) => e.name)}
|
|
208
|
+
value={previewEnv}
|
|
209
|
+
onChange={setPreviewEnv}
|
|
210
|
+
/>
|
|
211
|
+
{rows.map((row, idx) => (
|
|
212
|
+
<SchemaRow
|
|
213
|
+
key={`${row.originalName ?? "new"}-${idx}`}
|
|
214
|
+
row={row}
|
|
215
|
+
error={validation.rowErrors[idx]}
|
|
216
|
+
sampleValue={row.name ? sampleValues[row.name] : undefined}
|
|
217
|
+
onChange={(patch) => updateRow(idx, patch)}
|
|
218
|
+
onRemove={() => handleRemoveRow(idx)}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function PreviewEnvPicker(props: {
|
|
229
|
+
environments: string[];
|
|
230
|
+
value: string;
|
|
231
|
+
onChange: (env: string) => void;
|
|
232
|
+
}) {
|
|
233
|
+
if (props.environments.length === 0) return null;
|
|
234
|
+
return (
|
|
235
|
+
<div className="mb-1 flex items-center gap-2 text-[11px] text-ash">
|
|
236
|
+
<span>Pattern preview against:</span>
|
|
237
|
+
<select
|
|
238
|
+
value={props.value}
|
|
239
|
+
onChange={(e) => props.onChange(e.target.value)}
|
|
240
|
+
className="rounded border border-edge bg-ink-850 px-1.5 py-0.5 font-mono text-[11px] text-bone"
|
|
241
|
+
>
|
|
242
|
+
{props.environments.map((e) => (
|
|
243
|
+
<option key={e} value={e}>
|
|
244
|
+
{e}
|
|
245
|
+
</option>
|
|
246
|
+
))}
|
|
247
|
+
</select>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function SchemaRow(props: {
|
|
253
|
+
row: KeyRow;
|
|
254
|
+
error?: string;
|
|
255
|
+
sampleValue: string | undefined;
|
|
256
|
+
onChange: (patch: Partial<KeyRow>) => void;
|
|
257
|
+
onRemove: () => void;
|
|
258
|
+
}) {
|
|
259
|
+
const { row, error, sampleValue, onChange, onRemove } = props;
|
|
260
|
+
const patternMatchState = patternMatch(row.pattern, sampleValue);
|
|
261
|
+
|
|
262
|
+
// Brief "checking" state on every pattern/sample change so the user sees
|
|
263
|
+
// the test get re-run rather than the result just silently changing.
|
|
264
|
+
const [checking, setChecking] = useState(false);
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!row.pattern || row.type !== "string") return;
|
|
267
|
+
setChecking(true);
|
|
268
|
+
const t = setTimeout(() => setChecking(false), 180);
|
|
269
|
+
return () => clearTimeout(t);
|
|
270
|
+
}, [row.pattern, sampleValue, row.type]);
|
|
271
|
+
|
|
272
|
+
const previewToneClass = checking
|
|
273
|
+
? "text-ash"
|
|
274
|
+
: patternMatchState === "match"
|
|
275
|
+
? "text-go-500"
|
|
276
|
+
: patternMatchState === "miss"
|
|
277
|
+
? "text-stop-500"
|
|
278
|
+
: "text-ash";
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div
|
|
282
|
+
className={`grid grid-cols-[minmax(140px,1fr)_110px_auto_1fr_auto] items-center gap-x-2 gap-y-2 rounded-md border bg-ink-850 p-3 ${
|
|
283
|
+
error ? "border-stop-500" : "border-edge"
|
|
284
|
+
}`}
|
|
285
|
+
>
|
|
286
|
+
<input
|
|
287
|
+
placeholder="KEY_NAME"
|
|
288
|
+
value={row.name}
|
|
289
|
+
onChange={(e) => onChange({ name: e.target.value })}
|
|
290
|
+
className={INPUT_MONO}
|
|
291
|
+
/>
|
|
292
|
+
<select
|
|
293
|
+
value={row.type}
|
|
294
|
+
onChange={(e) => onChange({ type: e.target.value as KeyRow["type"] })}
|
|
295
|
+
className={INPUT_BASE}
|
|
296
|
+
>
|
|
297
|
+
{TYPES.map((t) => (
|
|
298
|
+
<option key={t} value={t}>
|
|
299
|
+
{t}
|
|
300
|
+
</option>
|
|
301
|
+
))}
|
|
302
|
+
</select>
|
|
303
|
+
<label className="flex items-center gap-1.5 text-[11px] text-bone">
|
|
304
|
+
<input
|
|
305
|
+
type="checkbox"
|
|
306
|
+
checked={row.required}
|
|
307
|
+
onChange={(e) => onChange({ required: e.target.checked })}
|
|
308
|
+
className="accent-gold-500"
|
|
309
|
+
/>
|
|
310
|
+
required
|
|
311
|
+
</label>
|
|
312
|
+
<input
|
|
313
|
+
placeholder={row.type === "string" ? "pattern: ^regex$ (optional)" : "— (strings only)"}
|
|
314
|
+
value={row.pattern}
|
|
315
|
+
disabled={row.type !== "string"}
|
|
316
|
+
onChange={(e) => onChange({ pattern: e.target.value })}
|
|
317
|
+
className={`${INPUT_MONO} ${row.type !== "string" ? "opacity-60" : ""}`}
|
|
318
|
+
/>
|
|
319
|
+
<Button variant="danger" onClick={onRemove}>
|
|
320
|
+
Remove
|
|
321
|
+
</Button>
|
|
322
|
+
|
|
323
|
+
<input
|
|
324
|
+
placeholder="description (optional)"
|
|
325
|
+
value={row.description}
|
|
326
|
+
onChange={(e) => onChange({ description: e.target.value })}
|
|
327
|
+
className={`${INPUT_BASE} col-span-5`}
|
|
328
|
+
/>
|
|
329
|
+
|
|
330
|
+
{error && <div className="col-span-5 text-[11px] text-stop-500">{error}</div>}
|
|
331
|
+
|
|
332
|
+
{row.type === "string" && row.pattern && (
|
|
333
|
+
<div
|
|
334
|
+
className={`col-span-5 flex items-center gap-2 font-mono text-[11px] ${previewToneClass}`}
|
|
335
|
+
>
|
|
336
|
+
{checking ? (
|
|
337
|
+
<>
|
|
338
|
+
<Spinner />
|
|
339
|
+
<span>testing…</span>
|
|
340
|
+
</>
|
|
341
|
+
) : sampleValue === undefined ? (
|
|
342
|
+
<span>No sample value in the selected env.</span>
|
|
343
|
+
) : patternMatchState === "invalid" ? (
|
|
344
|
+
<span>Invalid regex.</span>
|
|
345
|
+
) : patternMatchState === "match" ? (
|
|
346
|
+
<span data-testid="pattern-result">✔ matches sample value</span>
|
|
347
|
+
) : (
|
|
348
|
+
<span data-testid="pattern-result">✖ did not match</span>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function Spinner() {
|
|
357
|
+
// Keyframe `clef-schema-spin` is provided globally from styles.css so
|
|
358
|
+
// we don't inject a <style> tag at module load anymore.
|
|
359
|
+
return (
|
|
360
|
+
<span
|
|
361
|
+
aria-hidden="true"
|
|
362
|
+
className="inline-block h-2.5 w-2.5 rounded-full border-[1.5px] border-edge border-t-bone animate-[clef-schema-spin_0.6s_linear_infinite]"
|
|
363
|
+
/>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function patternMatch(
|
|
368
|
+
pattern: string,
|
|
369
|
+
sample: string | undefined,
|
|
370
|
+
): "match" | "miss" | "invalid" | "no-sample" {
|
|
371
|
+
if (!pattern) return "no-sample";
|
|
372
|
+
if (sample === undefined) return "no-sample";
|
|
373
|
+
let re: RegExp;
|
|
374
|
+
try {
|
|
375
|
+
re = new RegExp(pattern);
|
|
376
|
+
} catch {
|
|
377
|
+
return "invalid";
|
|
378
|
+
}
|
|
379
|
+
return re.test(sample) ? "match" : "miss";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function schemaToRows(schema: NamespaceSchema): KeyRow[] {
|
|
383
|
+
return Object.entries(schema.keys).map(([name, def]) => ({
|
|
384
|
+
originalName: name,
|
|
385
|
+
name,
|
|
386
|
+
type: def.type,
|
|
387
|
+
required: def.required,
|
|
388
|
+
pattern: def.pattern ?? "",
|
|
389
|
+
description: def.description ?? "",
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function rowsToSchema(rows: KeyRow[]): NamespaceSchema {
|
|
394
|
+
const keys: Record<string, SchemaKey> = {};
|
|
395
|
+
for (const row of rows) {
|
|
396
|
+
if (!row.name) continue; // empty-name rows are filtered (validation flags them separately)
|
|
397
|
+
keys[row.name] = {
|
|
398
|
+
type: row.type,
|
|
399
|
+
required: row.required,
|
|
400
|
+
...(row.pattern && row.type === "string" ? { pattern: row.pattern } : {}),
|
|
401
|
+
...(row.description ? { description: row.description } : {}),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return { keys };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function validateRows(rows: KeyRow[]): { ok: boolean; rowErrors: Record<number, string> } {
|
|
408
|
+
const rowErrors: Record<number, string> = {};
|
|
409
|
+
const seenNames = new Map<string, number>();
|
|
410
|
+
rows.forEach((row, idx) => {
|
|
411
|
+
if (!row.name.trim()) {
|
|
412
|
+
rowErrors[idx] = "Key name is required.";
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(row.name)) {
|
|
416
|
+
rowErrors[idx] =
|
|
417
|
+
"Key name must start with a letter or underscore and contain only letters, digits, and underscores.";
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const seen = seenNames.get(row.name);
|
|
421
|
+
if (seen !== undefined) {
|
|
422
|
+
rowErrors[idx] = `Duplicate key name (also row ${seen + 1}).`;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
seenNames.set(row.name, idx);
|
|
426
|
+
if (row.type === "string" && row.pattern) {
|
|
427
|
+
try {
|
|
428
|
+
new RegExp(row.pattern);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
rowErrors[idx] = `Pattern is not a valid regex: ${(err as Error).message}.`;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
return { ok: Object.keys(rowErrors).length === 0, rowErrors };
|
|
435
|
+
}
|