@clef-sh/ui 0.1.14 → 0.1.15-beta.97
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-rBYybJbt.js +26 -0
- package/dist/client/index.html +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/server/api.d.ts.map +1 -1
- package/dist/server/api.js +275 -87
- package/dist/server/api.js.map +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +8 -0
- package/src/client/components/Sidebar.tsx +15 -1
- package/src/client/screens/ManifestScreen.test.tsx +394 -0
- package/src/client/screens/ManifestScreen.tsx +977 -0
- package/src/client/screens/MatrixView.tsx +10 -1
- package/src/client/screens/NamespaceEditor.tsx +13 -3
- package/src/client/screens/ResetScreen.test.tsx +397 -0
- package/src/client/screens/ResetScreen.tsx +614 -0
- package/dist/client/assets/index-CVpAmirt.js +0 -26
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
import React, { useState, 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 type { ClefManifest, ClefNamespace, ClefEnvironment } from "@clef-sh/core";
|
|
7
|
+
|
|
8
|
+
interface ManifestScreenProps {
|
|
9
|
+
manifest: ClefManifest | null;
|
|
10
|
+
reloadManifest: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type ModalState =
|
|
14
|
+
| { kind: "none" }
|
|
15
|
+
| { kind: "addNamespace" }
|
|
16
|
+
| { kind: "editNamespace"; ns: ClefNamespace }
|
|
17
|
+
| { kind: "removeNamespace"; ns: ClefNamespace }
|
|
18
|
+
| { kind: "addEnvironment" }
|
|
19
|
+
| { kind: "editEnvironment"; env: ClefEnvironment }
|
|
20
|
+
| { kind: "removeEnvironment"; env: ClefEnvironment };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The Manifest screen is the home for namespace and environment configuration.
|
|
24
|
+
* It mirrors the StructureManager surface from packages/core/src/structure: add,
|
|
25
|
+
* edit, and remove for both axes, with the same validation and refusal rules.
|
|
26
|
+
*
|
|
27
|
+
* The matrix view shows the matrix DATA (cells, statuses, drift); this screen
|
|
28
|
+
* shows the matrix STRUCTURE (which envs/namespaces exist and their config).
|
|
29
|
+
*/
|
|
30
|
+
export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps) {
|
|
31
|
+
const [modal, setModal] = useState<ModalState>({ kind: "none" });
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
const closeModal = useCallback(() => {
|
|
35
|
+
setModal({ kind: "none" });
|
|
36
|
+
setError(null);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
if (!manifest) {
|
|
40
|
+
return (
|
|
41
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
|
42
|
+
<TopBar title="Manifest" subtitle="Loading..." />
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const namespaces = manifest.namespaces;
|
|
48
|
+
const environments = manifest.environments;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
52
|
+
<TopBar
|
|
53
|
+
title="Manifest"
|
|
54
|
+
subtitle={`${namespaces.length} namespaces \u00B7 ${environments.length} environments`}
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
<div style={{ flex: 1, overflow: "auto", padding: 28 }}>
|
|
58
|
+
{/* Namespaces section */}
|
|
59
|
+
<Section
|
|
60
|
+
title="Namespaces"
|
|
61
|
+
actionLabel="+ Namespace"
|
|
62
|
+
onAction={() => setModal({ kind: "addNamespace" })}
|
|
63
|
+
actionTestId="add-namespace-btn"
|
|
64
|
+
>
|
|
65
|
+
{namespaces.length === 0 ? (
|
|
66
|
+
<EmptyState message="No namespaces declared yet." />
|
|
67
|
+
) : (
|
|
68
|
+
<EntityList>
|
|
69
|
+
{namespaces.map((ns) => (
|
|
70
|
+
<EntityRow
|
|
71
|
+
key={ns.name}
|
|
72
|
+
testId={`namespace-row-${ns.name}`}
|
|
73
|
+
name={ns.name}
|
|
74
|
+
description={ns.description}
|
|
75
|
+
badges={ns.schema ? [{ label: `schema: ${ns.schema}`, color: theme.purple }] : []}
|
|
76
|
+
onEdit={() => setModal({ kind: "editNamespace", ns })}
|
|
77
|
+
onDelete={() => setModal({ kind: "removeNamespace", ns })}
|
|
78
|
+
/>
|
|
79
|
+
))}
|
|
80
|
+
</EntityList>
|
|
81
|
+
)}
|
|
82
|
+
</Section>
|
|
83
|
+
|
|
84
|
+
{/* Environments section */}
|
|
85
|
+
<div style={{ marginTop: 36 }}>
|
|
86
|
+
<Section
|
|
87
|
+
title="Environments"
|
|
88
|
+
actionLabel="+ Environment"
|
|
89
|
+
onAction={() => setModal({ kind: "addEnvironment" })}
|
|
90
|
+
actionTestId="add-environment-btn"
|
|
91
|
+
>
|
|
92
|
+
{environments.length === 0 ? (
|
|
93
|
+
<EmptyState message="No environments declared yet." />
|
|
94
|
+
) : (
|
|
95
|
+
<EntityList>
|
|
96
|
+
{environments.map((env) => (
|
|
97
|
+
<EntityRow
|
|
98
|
+
key={env.name}
|
|
99
|
+
testId={`environment-row-${env.name}`}
|
|
100
|
+
name={env.name}
|
|
101
|
+
description={env.description}
|
|
102
|
+
badges={env.protected ? [{ label: "protected", color: theme.red }] : []}
|
|
103
|
+
onEdit={() => setModal({ kind: "editEnvironment", env })}
|
|
104
|
+
onDelete={() => setModal({ kind: "removeEnvironment", env })}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
</EntityList>
|
|
108
|
+
)}
|
|
109
|
+
</Section>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Modals */}
|
|
114
|
+
{modal.kind === "addNamespace" && (
|
|
115
|
+
<AddNamespaceModal
|
|
116
|
+
onClose={closeModal}
|
|
117
|
+
onSubmit={async (data) => {
|
|
118
|
+
const res = await apiFetch("/api/namespaces", {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify(data),
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
const body = await res.json();
|
|
125
|
+
setError(body.error ?? "Failed to add namespace");
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
reloadManifest();
|
|
129
|
+
closeModal();
|
|
130
|
+
return true;
|
|
131
|
+
}}
|
|
132
|
+
existingNames={namespaces.map((n) => n.name)}
|
|
133
|
+
error={error}
|
|
134
|
+
setError={setError}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{modal.kind === "editNamespace" && (
|
|
139
|
+
<EditNamespaceModal
|
|
140
|
+
ns={modal.ns}
|
|
141
|
+
existingNames={namespaces.map((n) => n.name)}
|
|
142
|
+
onClose={closeModal}
|
|
143
|
+
onSubmit={async (data) => {
|
|
144
|
+
const res = await apiFetch(`/api/namespaces/${encodeURIComponent(modal.ns.name)}`, {
|
|
145
|
+
method: "PATCH",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify(data),
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const body = await res.json();
|
|
151
|
+
setError(body.error ?? "Failed to edit namespace");
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
reloadManifest();
|
|
155
|
+
closeModal();
|
|
156
|
+
return true;
|
|
157
|
+
}}
|
|
158
|
+
error={error}
|
|
159
|
+
setError={setError}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{modal.kind === "removeNamespace" && (
|
|
164
|
+
<ConfirmRemoveModal
|
|
165
|
+
title="Delete namespace"
|
|
166
|
+
subjectKind="namespace"
|
|
167
|
+
subjectName={modal.ns.name}
|
|
168
|
+
impactDescription={`This will delete every encrypted cell file under '${modal.ns.name}/' across all environments and remove '${modal.ns.name}' from any service identity that references it.`}
|
|
169
|
+
onClose={closeModal}
|
|
170
|
+
onConfirm={async () => {
|
|
171
|
+
const res = await apiFetch(`/api/namespaces/${encodeURIComponent(modal.ns.name)}`, {
|
|
172
|
+
method: "DELETE",
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
const body = await res.json();
|
|
176
|
+
setError(body.error ?? "Failed to remove namespace");
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
reloadManifest();
|
|
180
|
+
closeModal();
|
|
181
|
+
return true;
|
|
182
|
+
}}
|
|
183
|
+
error={error}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{modal.kind === "addEnvironment" && (
|
|
188
|
+
<AddEnvironmentModal
|
|
189
|
+
existingNames={environments.map((e) => e.name)}
|
|
190
|
+
onClose={closeModal}
|
|
191
|
+
onSubmit={async (data) => {
|
|
192
|
+
const res = await apiFetch("/api/environments", {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify(data),
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const body = await res.json();
|
|
199
|
+
setError(body.error ?? "Failed to add environment");
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
reloadManifest();
|
|
203
|
+
closeModal();
|
|
204
|
+
return true;
|
|
205
|
+
}}
|
|
206
|
+
error={error}
|
|
207
|
+
setError={setError}
|
|
208
|
+
/>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{modal.kind === "editEnvironment" && (
|
|
212
|
+
<EditEnvironmentModal
|
|
213
|
+
env={modal.env}
|
|
214
|
+
existingNames={environments.map((e) => e.name)}
|
|
215
|
+
onClose={closeModal}
|
|
216
|
+
onSubmit={async (data) => {
|
|
217
|
+
const res = await apiFetch(`/api/environments/${encodeURIComponent(modal.env.name)}`, {
|
|
218
|
+
method: "PATCH",
|
|
219
|
+
headers: { "Content-Type": "application/json" },
|
|
220
|
+
body: JSON.stringify(data),
|
|
221
|
+
});
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
const body = await res.json();
|
|
224
|
+
setError(body.error ?? "Failed to edit environment");
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
reloadManifest();
|
|
228
|
+
closeModal();
|
|
229
|
+
return true;
|
|
230
|
+
}}
|
|
231
|
+
error={error}
|
|
232
|
+
setError={setError}
|
|
233
|
+
/>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{modal.kind === "removeEnvironment" && (
|
|
237
|
+
<ConfirmRemoveModal
|
|
238
|
+
title="Delete environment"
|
|
239
|
+
subjectKind="environment"
|
|
240
|
+
subjectName={modal.env.name}
|
|
241
|
+
impactDescription={
|
|
242
|
+
modal.env.protected
|
|
243
|
+
? `'${modal.env.name}' is a protected environment and will be refused. Run "Edit" first and unprotect it before removing.`
|
|
244
|
+
: `This will delete every encrypted cell file for '${modal.env.name}' across all namespaces and remove the '${modal.env.name}' entry from every service identity.`
|
|
245
|
+
}
|
|
246
|
+
onClose={closeModal}
|
|
247
|
+
onConfirm={async () => {
|
|
248
|
+
const res = await apiFetch(`/api/environments/${encodeURIComponent(modal.env.name)}`, {
|
|
249
|
+
method: "DELETE",
|
|
250
|
+
});
|
|
251
|
+
if (!res.ok) {
|
|
252
|
+
const body = await res.json();
|
|
253
|
+
setError(body.error ?? "Failed to remove environment");
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
reloadManifest();
|
|
257
|
+
closeModal();
|
|
258
|
+
return true;
|
|
259
|
+
}}
|
|
260
|
+
error={error}
|
|
261
|
+
/>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Layout primitives ────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
function Section(props: {
|
|
270
|
+
title: string;
|
|
271
|
+
actionLabel: string;
|
|
272
|
+
onAction: () => void;
|
|
273
|
+
actionTestId: string;
|
|
274
|
+
children: React.ReactNode;
|
|
275
|
+
}) {
|
|
276
|
+
return (
|
|
277
|
+
<div>
|
|
278
|
+
<div
|
|
279
|
+
style={{
|
|
280
|
+
display: "flex",
|
|
281
|
+
alignItems: "center",
|
|
282
|
+
justifyContent: "space-between",
|
|
283
|
+
marginBottom: 14,
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<h2
|
|
287
|
+
style={{
|
|
288
|
+
fontFamily: theme.sans,
|
|
289
|
+
fontSize: 14,
|
|
290
|
+
fontWeight: 600,
|
|
291
|
+
color: theme.text,
|
|
292
|
+
margin: 0,
|
|
293
|
+
letterSpacing: "-0.01em",
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
{props.title}
|
|
297
|
+
</h2>
|
|
298
|
+
<Button variant="primary" onClick={props.onAction} data-testid={props.actionTestId}>
|
|
299
|
+
{props.actionLabel}
|
|
300
|
+
</Button>
|
|
301
|
+
</div>
|
|
302
|
+
{props.children}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function EntityList(props: { children: React.ReactNode }) {
|
|
308
|
+
return (
|
|
309
|
+
<div
|
|
310
|
+
style={{
|
|
311
|
+
border: `1px solid ${theme.border}`,
|
|
312
|
+
borderRadius: 8,
|
|
313
|
+
background: theme.surface,
|
|
314
|
+
overflow: "hidden",
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{props.children}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function EntityRow(props: {
|
|
323
|
+
testId: string;
|
|
324
|
+
name: string;
|
|
325
|
+
description: string;
|
|
326
|
+
badges: { label: string; color: string }[];
|
|
327
|
+
onEdit: () => void;
|
|
328
|
+
onDelete: () => void;
|
|
329
|
+
}) {
|
|
330
|
+
return (
|
|
331
|
+
<div
|
|
332
|
+
data-testid={props.testId}
|
|
333
|
+
style={{
|
|
334
|
+
display: "flex",
|
|
335
|
+
alignItems: "center",
|
|
336
|
+
padding: "12px 16px",
|
|
337
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
338
|
+
gap: 12,
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
342
|
+
<div
|
|
343
|
+
style={{
|
|
344
|
+
fontFamily: theme.mono,
|
|
345
|
+
fontSize: 13,
|
|
346
|
+
fontWeight: 600,
|
|
347
|
+
color: theme.text,
|
|
348
|
+
display: "flex",
|
|
349
|
+
alignItems: "center",
|
|
350
|
+
gap: 8,
|
|
351
|
+
}}
|
|
352
|
+
>
|
|
353
|
+
{props.name}
|
|
354
|
+
{props.badges.map((b) => (
|
|
355
|
+
<span
|
|
356
|
+
key={b.label}
|
|
357
|
+
style={{
|
|
358
|
+
fontFamily: theme.sans,
|
|
359
|
+
fontSize: 10,
|
|
360
|
+
fontWeight: 500,
|
|
361
|
+
color: b.color,
|
|
362
|
+
background: `${b.color}14`,
|
|
363
|
+
border: `1px solid ${b.color}33`,
|
|
364
|
+
borderRadius: 10,
|
|
365
|
+
padding: "1px 8px",
|
|
366
|
+
}}
|
|
367
|
+
>
|
|
368
|
+
{b.label}
|
|
369
|
+
</span>
|
|
370
|
+
))}
|
|
371
|
+
</div>
|
|
372
|
+
{props.description && (
|
|
373
|
+
<div
|
|
374
|
+
style={{
|
|
375
|
+
fontFamily: theme.sans,
|
|
376
|
+
fontSize: 12,
|
|
377
|
+
color: theme.textMuted,
|
|
378
|
+
marginTop: 2,
|
|
379
|
+
}}
|
|
380
|
+
>
|
|
381
|
+
{props.description}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
<Button onClick={props.onEdit} data-testid={`${props.testId}-edit`}>
|
|
386
|
+
Edit
|
|
387
|
+
</Button>
|
|
388
|
+
<Button onClick={props.onDelete} data-testid={`${props.testId}-delete`}>
|
|
389
|
+
Delete
|
|
390
|
+
</Button>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function EmptyState(props: { message: string }) {
|
|
396
|
+
return (
|
|
397
|
+
<div
|
|
398
|
+
style={{
|
|
399
|
+
padding: 24,
|
|
400
|
+
border: `1px dashed ${theme.border}`,
|
|
401
|
+
borderRadius: 8,
|
|
402
|
+
textAlign: "center",
|
|
403
|
+
fontFamily: theme.sans,
|
|
404
|
+
fontSize: 12,
|
|
405
|
+
color: theme.textMuted,
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
{props.message}
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Modal primitives ─────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
function ModalShell(props: { title: string; onClose: () => void; children: React.ReactNode }) {
|
|
416
|
+
// Stop propagation on inner click so clicking the dialog body doesn't dismiss
|
|
417
|
+
return (
|
|
418
|
+
<div
|
|
419
|
+
data-testid="manifest-modal"
|
|
420
|
+
onClick={props.onClose}
|
|
421
|
+
style={{
|
|
422
|
+
position: "fixed",
|
|
423
|
+
inset: 0,
|
|
424
|
+
background: "rgba(0,0,0,0.55)",
|
|
425
|
+
display: "flex",
|
|
426
|
+
alignItems: "center",
|
|
427
|
+
justifyContent: "center",
|
|
428
|
+
zIndex: 100,
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
<div
|
|
432
|
+
onClick={(e) => e.stopPropagation()}
|
|
433
|
+
style={{
|
|
434
|
+
background: theme.surface,
|
|
435
|
+
border: `1px solid ${theme.border}`,
|
|
436
|
+
borderRadius: 10,
|
|
437
|
+
padding: 24,
|
|
438
|
+
width: 480,
|
|
439
|
+
maxWidth: "90vw",
|
|
440
|
+
}}
|
|
441
|
+
>
|
|
442
|
+
<h3
|
|
443
|
+
style={{
|
|
444
|
+
fontFamily: theme.sans,
|
|
445
|
+
fontSize: 16,
|
|
446
|
+
fontWeight: 600,
|
|
447
|
+
color: theme.text,
|
|
448
|
+
margin: "0 0 16px",
|
|
449
|
+
}}
|
|
450
|
+
>
|
|
451
|
+
{props.title}
|
|
452
|
+
</h3>
|
|
453
|
+
{props.children}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function FormField(props: { label: string; children: React.ReactNode; hint?: string }) {
|
|
460
|
+
return (
|
|
461
|
+
<div style={{ marginBottom: 14 }}>
|
|
462
|
+
<label
|
|
463
|
+
style={{
|
|
464
|
+
display: "block",
|
|
465
|
+
fontFamily: theme.sans,
|
|
466
|
+
fontSize: 11,
|
|
467
|
+
fontWeight: 600,
|
|
468
|
+
color: theme.textMuted,
|
|
469
|
+
marginBottom: 4,
|
|
470
|
+
textTransform: "uppercase",
|
|
471
|
+
letterSpacing: "0.05em",
|
|
472
|
+
}}
|
|
473
|
+
>
|
|
474
|
+
{props.label}
|
|
475
|
+
</label>
|
|
476
|
+
{props.children}
|
|
477
|
+
{props.hint && (
|
|
478
|
+
<div
|
|
479
|
+
style={{
|
|
480
|
+
fontFamily: theme.sans,
|
|
481
|
+
fontSize: 11,
|
|
482
|
+
color: theme.textMuted,
|
|
483
|
+
marginTop: 4,
|
|
484
|
+
}}
|
|
485
|
+
>
|
|
486
|
+
{props.hint}
|
|
487
|
+
</div>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function TextInput(props: {
|
|
494
|
+
value: string;
|
|
495
|
+
onChange: (v: string) => void;
|
|
496
|
+
placeholder?: string;
|
|
497
|
+
testId?: string;
|
|
498
|
+
autoFocus?: boolean;
|
|
499
|
+
}) {
|
|
500
|
+
return (
|
|
501
|
+
<input
|
|
502
|
+
type="text"
|
|
503
|
+
value={props.value}
|
|
504
|
+
onChange={(e) => props.onChange(e.target.value)}
|
|
505
|
+
placeholder={props.placeholder}
|
|
506
|
+
data-testid={props.testId}
|
|
507
|
+
autoFocus={props.autoFocus}
|
|
508
|
+
style={{
|
|
509
|
+
width: "100%",
|
|
510
|
+
padding: "8px 12px",
|
|
511
|
+
background: theme.bg,
|
|
512
|
+
border: `1px solid ${theme.border}`,
|
|
513
|
+
borderRadius: 6,
|
|
514
|
+
color: theme.text,
|
|
515
|
+
fontFamily: theme.mono,
|
|
516
|
+
fontSize: 13,
|
|
517
|
+
boxSizing: "border-box",
|
|
518
|
+
}}
|
|
519
|
+
/>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function ErrorBanner(props: { message: string }) {
|
|
524
|
+
return (
|
|
525
|
+
<div
|
|
526
|
+
data-testid="manifest-modal-error"
|
|
527
|
+
style={{
|
|
528
|
+
padding: "8px 12px",
|
|
529
|
+
background: `${theme.red}14`,
|
|
530
|
+
border: `1px solid ${theme.red}33`,
|
|
531
|
+
borderRadius: 6,
|
|
532
|
+
color: theme.red,
|
|
533
|
+
fontFamily: theme.sans,
|
|
534
|
+
fontSize: 12,
|
|
535
|
+
marginBottom: 12,
|
|
536
|
+
}}
|
|
537
|
+
>
|
|
538
|
+
{props.message}
|
|
539
|
+
</div>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function ModalActions(props: { children: React.ReactNode }) {
|
|
544
|
+
return (
|
|
545
|
+
<div
|
|
546
|
+
style={{
|
|
547
|
+
display: "flex",
|
|
548
|
+
justifyContent: "flex-end",
|
|
549
|
+
gap: 8,
|
|
550
|
+
marginTop: 8,
|
|
551
|
+
}}
|
|
552
|
+
>
|
|
553
|
+
{props.children}
|
|
554
|
+
</div>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Validate identifiers locally for instant feedback. Mirrors the regex used
|
|
559
|
+
// server-side in StructureManager.assertValidIdentifier.
|
|
560
|
+
function isValidIdentifier(name: string): boolean {
|
|
561
|
+
return /^[A-Za-z0-9._-]+$/.test(name);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── Add Namespace ────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
function AddNamespaceModal(props: {
|
|
567
|
+
existingNames: string[];
|
|
568
|
+
onClose: () => void;
|
|
569
|
+
onSubmit: (data: { name: string; description?: string; schema?: string }) => Promise<boolean>;
|
|
570
|
+
error: string | null;
|
|
571
|
+
setError: (e: string | null) => void;
|
|
572
|
+
}) {
|
|
573
|
+
const [name, setName] = useState("");
|
|
574
|
+
const [description, setDescription] = useState("");
|
|
575
|
+
const [schema, setSchema] = useState("");
|
|
576
|
+
const [busy, setBusy] = useState(false);
|
|
577
|
+
|
|
578
|
+
const trimmed = name.trim();
|
|
579
|
+
const collides = props.existingNames.includes(trimmed);
|
|
580
|
+
const valid = trimmed.length > 0 && isValidIdentifier(trimmed) && !collides;
|
|
581
|
+
const localError = !trimmed
|
|
582
|
+
? null
|
|
583
|
+
: !isValidIdentifier(trimmed)
|
|
584
|
+
? "Use letters, numbers, '.', '_', or '-' only."
|
|
585
|
+
: collides
|
|
586
|
+
? `A namespace named '${trimmed}' already exists.`
|
|
587
|
+
: null;
|
|
588
|
+
|
|
589
|
+
return (
|
|
590
|
+
<ModalShell title="Add namespace" onClose={props.onClose}>
|
|
591
|
+
{props.error && <ErrorBanner message={props.error} />}
|
|
592
|
+
<FormField label="Name" hint={localError ?? undefined}>
|
|
593
|
+
<TextInput
|
|
594
|
+
value={name}
|
|
595
|
+
onChange={(v) => {
|
|
596
|
+
setName(v);
|
|
597
|
+
props.setError(null);
|
|
598
|
+
}}
|
|
599
|
+
placeholder="payments"
|
|
600
|
+
testId="namespace-name-input"
|
|
601
|
+
autoFocus
|
|
602
|
+
/>
|
|
603
|
+
</FormField>
|
|
604
|
+
<FormField label="Description">
|
|
605
|
+
<TextInput
|
|
606
|
+
value={description}
|
|
607
|
+
onChange={setDescription}
|
|
608
|
+
placeholder="Payment processing secrets"
|
|
609
|
+
testId="namespace-description-input"
|
|
610
|
+
/>
|
|
611
|
+
</FormField>
|
|
612
|
+
<FormField label="Schema (optional)" hint="Path to a YAML schema file in the repo.">
|
|
613
|
+
<TextInput
|
|
614
|
+
value={schema}
|
|
615
|
+
onChange={setSchema}
|
|
616
|
+
placeholder="schemas/payments.yaml"
|
|
617
|
+
testId="namespace-schema-input"
|
|
618
|
+
/>
|
|
619
|
+
</FormField>
|
|
620
|
+
<ModalActions>
|
|
621
|
+
<Button onClick={props.onClose} data-testid="namespace-add-cancel">
|
|
622
|
+
Cancel
|
|
623
|
+
</Button>
|
|
624
|
+
<Button
|
|
625
|
+
variant="primary"
|
|
626
|
+
disabled={!valid || busy}
|
|
627
|
+
data-testid="namespace-add-submit"
|
|
628
|
+
onClick={async () => {
|
|
629
|
+
setBusy(true);
|
|
630
|
+
await props.onSubmit({
|
|
631
|
+
name: trimmed,
|
|
632
|
+
description: description.trim() || undefined,
|
|
633
|
+
schema: schema.trim() || undefined,
|
|
634
|
+
});
|
|
635
|
+
setBusy(false);
|
|
636
|
+
}}
|
|
637
|
+
>
|
|
638
|
+
{busy ? "Adding..." : "Add namespace"}
|
|
639
|
+
</Button>
|
|
640
|
+
</ModalActions>
|
|
641
|
+
</ModalShell>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Edit Namespace ───────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
function EditNamespaceModal(props: {
|
|
648
|
+
ns: ClefNamespace;
|
|
649
|
+
existingNames: string[];
|
|
650
|
+
onClose: () => void;
|
|
651
|
+
onSubmit: (data: { rename?: string; description?: string; schema?: string }) => Promise<boolean>;
|
|
652
|
+
error: string | null;
|
|
653
|
+
setError: (e: string | null) => void;
|
|
654
|
+
}) {
|
|
655
|
+
const [rename, setRename] = useState(props.ns.name);
|
|
656
|
+
const [description, setDescription] = useState(props.ns.description ?? "");
|
|
657
|
+
const [schema, setSchema] = useState(props.ns.schema ?? "");
|
|
658
|
+
const [busy, setBusy] = useState(false);
|
|
659
|
+
|
|
660
|
+
const trimmedRename = rename.trim();
|
|
661
|
+
const isRename = trimmedRename !== props.ns.name;
|
|
662
|
+
const collides = isRename && props.existingNames.includes(trimmedRename);
|
|
663
|
+
const renameValid = !isRename || (isValidIdentifier(trimmedRename) && !collides);
|
|
664
|
+
const localError = !trimmedRename
|
|
665
|
+
? "Name cannot be empty."
|
|
666
|
+
: !renameValid
|
|
667
|
+
? collides
|
|
668
|
+
? `A namespace named '${trimmedRename}' already exists.`
|
|
669
|
+
: "Use letters, numbers, '.', '_', or '-' only."
|
|
670
|
+
: null;
|
|
671
|
+
|
|
672
|
+
// Detect any change vs the original entity
|
|
673
|
+
const dirty =
|
|
674
|
+
isRename || description !== (props.ns.description ?? "") || schema !== (props.ns.schema ?? "");
|
|
675
|
+
|
|
676
|
+
return (
|
|
677
|
+
<ModalShell title={`Edit namespace '${props.ns.name}'`} onClose={props.onClose}>
|
|
678
|
+
{props.error && <ErrorBanner message={props.error} />}
|
|
679
|
+
<FormField label="Name" hint={localError ?? undefined}>
|
|
680
|
+
<TextInput
|
|
681
|
+
value={rename}
|
|
682
|
+
onChange={(v) => {
|
|
683
|
+
setRename(v);
|
|
684
|
+
props.setError(null);
|
|
685
|
+
}}
|
|
686
|
+
testId="namespace-rename-input"
|
|
687
|
+
autoFocus
|
|
688
|
+
/>
|
|
689
|
+
</FormField>
|
|
690
|
+
<FormField label="Description">
|
|
691
|
+
<TextInput
|
|
692
|
+
value={description}
|
|
693
|
+
onChange={setDescription}
|
|
694
|
+
testId="namespace-description-input"
|
|
695
|
+
/>
|
|
696
|
+
</FormField>
|
|
697
|
+
<FormField label="Schema (optional)" hint="Empty to clear.">
|
|
698
|
+
<TextInput value={schema} onChange={setSchema} testId="namespace-schema-input" />
|
|
699
|
+
</FormField>
|
|
700
|
+
<ModalActions>
|
|
701
|
+
<Button onClick={props.onClose} data-testid="namespace-edit-cancel">
|
|
702
|
+
Cancel
|
|
703
|
+
</Button>
|
|
704
|
+
<Button
|
|
705
|
+
variant="primary"
|
|
706
|
+
disabled={!dirty || !!localError || busy}
|
|
707
|
+
data-testid="namespace-edit-submit"
|
|
708
|
+
onClick={async () => {
|
|
709
|
+
setBusy(true);
|
|
710
|
+
const data: { rename?: string; description?: string; schema?: string } = {};
|
|
711
|
+
if (isRename) data.rename = trimmedRename;
|
|
712
|
+
if (description !== (props.ns.description ?? "")) data.description = description;
|
|
713
|
+
if (schema !== (props.ns.schema ?? "")) data.schema = schema;
|
|
714
|
+
await props.onSubmit(data);
|
|
715
|
+
setBusy(false);
|
|
716
|
+
}}
|
|
717
|
+
>
|
|
718
|
+
{busy ? "Saving..." : "Save changes"}
|
|
719
|
+
</Button>
|
|
720
|
+
</ModalActions>
|
|
721
|
+
</ModalShell>
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ── Add Environment ──────────────────────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
function AddEnvironmentModal(props: {
|
|
728
|
+
existingNames: string[];
|
|
729
|
+
onClose: () => void;
|
|
730
|
+
onSubmit: (data: { name: string; description?: string; protected?: boolean }) => Promise<boolean>;
|
|
731
|
+
error: string | null;
|
|
732
|
+
setError: (e: string | null) => void;
|
|
733
|
+
}) {
|
|
734
|
+
const [name, setName] = useState("");
|
|
735
|
+
const [description, setDescription] = useState("");
|
|
736
|
+
const [isProtected, setIsProtected] = useState(false);
|
|
737
|
+
const [busy, setBusy] = useState(false);
|
|
738
|
+
|
|
739
|
+
const trimmed = name.trim();
|
|
740
|
+
const collides = props.existingNames.includes(trimmed);
|
|
741
|
+
const valid = trimmed.length > 0 && isValidIdentifier(trimmed) && !collides;
|
|
742
|
+
const localError = !trimmed
|
|
743
|
+
? null
|
|
744
|
+
: !isValidIdentifier(trimmed)
|
|
745
|
+
? "Use letters, numbers, '.', '_', or '-' only."
|
|
746
|
+
: collides
|
|
747
|
+
? `An environment named '${trimmed}' already exists.`
|
|
748
|
+
: null;
|
|
749
|
+
|
|
750
|
+
return (
|
|
751
|
+
<ModalShell title="Add environment" onClose={props.onClose}>
|
|
752
|
+
{props.error && <ErrorBanner message={props.error} />}
|
|
753
|
+
<FormField label="Name" hint={localError ?? undefined}>
|
|
754
|
+
<TextInput
|
|
755
|
+
value={name}
|
|
756
|
+
onChange={(v) => {
|
|
757
|
+
setName(v);
|
|
758
|
+
props.setError(null);
|
|
759
|
+
}}
|
|
760
|
+
placeholder="staging"
|
|
761
|
+
testId="environment-name-input"
|
|
762
|
+
autoFocus
|
|
763
|
+
/>
|
|
764
|
+
</FormField>
|
|
765
|
+
<FormField label="Description">
|
|
766
|
+
<TextInput
|
|
767
|
+
value={description}
|
|
768
|
+
onChange={setDescription}
|
|
769
|
+
placeholder="Pre-production"
|
|
770
|
+
testId="environment-description-input"
|
|
771
|
+
/>
|
|
772
|
+
</FormField>
|
|
773
|
+
<div style={{ marginBottom: 14 }}>
|
|
774
|
+
<label
|
|
775
|
+
style={{
|
|
776
|
+
display: "flex",
|
|
777
|
+
alignItems: "center",
|
|
778
|
+
gap: 8,
|
|
779
|
+
fontFamily: theme.sans,
|
|
780
|
+
fontSize: 12,
|
|
781
|
+
color: theme.text,
|
|
782
|
+
cursor: "pointer",
|
|
783
|
+
}}
|
|
784
|
+
>
|
|
785
|
+
<input
|
|
786
|
+
type="checkbox"
|
|
787
|
+
checked={isProtected}
|
|
788
|
+
onChange={(e) => setIsProtected(e.target.checked)}
|
|
789
|
+
data-testid="environment-protected-checkbox"
|
|
790
|
+
/>
|
|
791
|
+
Mark as protected
|
|
792
|
+
</label>
|
|
793
|
+
</div>
|
|
794
|
+
<ModalActions>
|
|
795
|
+
<Button onClick={props.onClose} data-testid="environment-add-cancel">
|
|
796
|
+
Cancel
|
|
797
|
+
</Button>
|
|
798
|
+
<Button
|
|
799
|
+
variant="primary"
|
|
800
|
+
disabled={!valid || busy}
|
|
801
|
+
data-testid="environment-add-submit"
|
|
802
|
+
onClick={async () => {
|
|
803
|
+
setBusy(true);
|
|
804
|
+
await props.onSubmit({
|
|
805
|
+
name: trimmed,
|
|
806
|
+
description: description.trim() || undefined,
|
|
807
|
+
protected: isProtected || undefined,
|
|
808
|
+
});
|
|
809
|
+
setBusy(false);
|
|
810
|
+
}}
|
|
811
|
+
>
|
|
812
|
+
{busy ? "Adding..." : "Add environment"}
|
|
813
|
+
</Button>
|
|
814
|
+
</ModalActions>
|
|
815
|
+
</ModalShell>
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ── Edit Environment ─────────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
function EditEnvironmentModal(props: {
|
|
822
|
+
env: ClefEnvironment;
|
|
823
|
+
existingNames: string[];
|
|
824
|
+
onClose: () => void;
|
|
825
|
+
onSubmit: (data: {
|
|
826
|
+
rename?: string;
|
|
827
|
+
description?: string;
|
|
828
|
+
protected?: boolean;
|
|
829
|
+
}) => Promise<boolean>;
|
|
830
|
+
error: string | null;
|
|
831
|
+
setError: (e: string | null) => void;
|
|
832
|
+
}) {
|
|
833
|
+
const [rename, setRename] = useState(props.env.name);
|
|
834
|
+
const [description, setDescription] = useState(props.env.description ?? "");
|
|
835
|
+
const [isProtected, setIsProtected] = useState(props.env.protected === true);
|
|
836
|
+
const [busy, setBusy] = useState(false);
|
|
837
|
+
|
|
838
|
+
const trimmedRename = rename.trim();
|
|
839
|
+
const isRename = trimmedRename !== props.env.name;
|
|
840
|
+
const collides = isRename && props.existingNames.includes(trimmedRename);
|
|
841
|
+
const renameValid = !isRename || (isValidIdentifier(trimmedRename) && !collides);
|
|
842
|
+
const localError = !trimmedRename
|
|
843
|
+
? "Name cannot be empty."
|
|
844
|
+
: !renameValid
|
|
845
|
+
? collides
|
|
846
|
+
? `An environment named '${trimmedRename}' already exists.`
|
|
847
|
+
: "Use letters, numbers, '.', '_', or '-' only."
|
|
848
|
+
: null;
|
|
849
|
+
|
|
850
|
+
const protectedChanged = isProtected !== (props.env.protected === true);
|
|
851
|
+
const dirty = isRename || description !== (props.env.description ?? "") || protectedChanged;
|
|
852
|
+
|
|
853
|
+
return (
|
|
854
|
+
<ModalShell title={`Edit environment '${props.env.name}'`} onClose={props.onClose}>
|
|
855
|
+
{props.error && <ErrorBanner message={props.error} />}
|
|
856
|
+
<FormField label="Name" hint={localError ?? undefined}>
|
|
857
|
+
<TextInput
|
|
858
|
+
value={rename}
|
|
859
|
+
onChange={(v) => {
|
|
860
|
+
setRename(v);
|
|
861
|
+
props.setError(null);
|
|
862
|
+
}}
|
|
863
|
+
testId="environment-rename-input"
|
|
864
|
+
autoFocus
|
|
865
|
+
/>
|
|
866
|
+
</FormField>
|
|
867
|
+
<FormField label="Description">
|
|
868
|
+
<TextInput
|
|
869
|
+
value={description}
|
|
870
|
+
onChange={setDescription}
|
|
871
|
+
testId="environment-description-input"
|
|
872
|
+
/>
|
|
873
|
+
</FormField>
|
|
874
|
+
<div style={{ marginBottom: 14 }}>
|
|
875
|
+
<label
|
|
876
|
+
style={{
|
|
877
|
+
display: "flex",
|
|
878
|
+
alignItems: "center",
|
|
879
|
+
gap: 8,
|
|
880
|
+
fontFamily: theme.sans,
|
|
881
|
+
fontSize: 12,
|
|
882
|
+
color: theme.text,
|
|
883
|
+
cursor: "pointer",
|
|
884
|
+
}}
|
|
885
|
+
>
|
|
886
|
+
<input
|
|
887
|
+
type="checkbox"
|
|
888
|
+
checked={isProtected}
|
|
889
|
+
onChange={(e) => setIsProtected(e.target.checked)}
|
|
890
|
+
data-testid="environment-protected-checkbox"
|
|
891
|
+
/>
|
|
892
|
+
Protected (write operations require confirmation)
|
|
893
|
+
</label>
|
|
894
|
+
</div>
|
|
895
|
+
<ModalActions>
|
|
896
|
+
<Button onClick={props.onClose} data-testid="environment-edit-cancel">
|
|
897
|
+
Cancel
|
|
898
|
+
</Button>
|
|
899
|
+
<Button
|
|
900
|
+
variant="primary"
|
|
901
|
+
disabled={!dirty || !!localError || busy}
|
|
902
|
+
data-testid="environment-edit-submit"
|
|
903
|
+
onClick={async () => {
|
|
904
|
+
setBusy(true);
|
|
905
|
+
const data: { rename?: string; description?: string; protected?: boolean } = {};
|
|
906
|
+
if (isRename) data.rename = trimmedRename;
|
|
907
|
+
if (description !== (props.env.description ?? "")) data.description = description;
|
|
908
|
+
if (protectedChanged) data.protected = isProtected;
|
|
909
|
+
await props.onSubmit(data);
|
|
910
|
+
setBusy(false);
|
|
911
|
+
}}
|
|
912
|
+
>
|
|
913
|
+
{busy ? "Saving..." : "Save changes"}
|
|
914
|
+
</Button>
|
|
915
|
+
</ModalActions>
|
|
916
|
+
</ModalShell>
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ── Confirm Remove (shared by namespace + env) ───────────────────────────
|
|
921
|
+
|
|
922
|
+
function ConfirmRemoveModal(props: {
|
|
923
|
+
title: string;
|
|
924
|
+
subjectKind: "namespace" | "environment";
|
|
925
|
+
subjectName: string;
|
|
926
|
+
impactDescription: string;
|
|
927
|
+
onClose: () => void;
|
|
928
|
+
onConfirm: () => Promise<boolean>;
|
|
929
|
+
error: string | null;
|
|
930
|
+
}) {
|
|
931
|
+
const [typedName, setTypedName] = useState("");
|
|
932
|
+
const [busy, setBusy] = useState(false);
|
|
933
|
+
const matches = typedName === props.subjectName;
|
|
934
|
+
|
|
935
|
+
return (
|
|
936
|
+
<ModalShell title={props.title} onClose={props.onClose}>
|
|
937
|
+
{props.error && <ErrorBanner message={props.error} />}
|
|
938
|
+
<p
|
|
939
|
+
style={{
|
|
940
|
+
fontFamily: theme.sans,
|
|
941
|
+
fontSize: 12,
|
|
942
|
+
color: theme.text,
|
|
943
|
+
margin: "0 0 12px",
|
|
944
|
+
lineHeight: 1.5,
|
|
945
|
+
}}
|
|
946
|
+
>
|
|
947
|
+
{props.impactDescription}
|
|
948
|
+
</p>
|
|
949
|
+
<FormField label={`Type the ${props.subjectKind} name to confirm`}>
|
|
950
|
+
<TextInput
|
|
951
|
+
value={typedName}
|
|
952
|
+
onChange={setTypedName}
|
|
953
|
+
placeholder={props.subjectName}
|
|
954
|
+
testId={`${props.subjectKind}-remove-confirm-input`}
|
|
955
|
+
autoFocus
|
|
956
|
+
/>
|
|
957
|
+
</FormField>
|
|
958
|
+
<ModalActions>
|
|
959
|
+
<Button onClick={props.onClose} data-testid={`${props.subjectKind}-remove-cancel`}>
|
|
960
|
+
Cancel
|
|
961
|
+
</Button>
|
|
962
|
+
<Button
|
|
963
|
+
variant="primary"
|
|
964
|
+
disabled={!matches || busy}
|
|
965
|
+
data-testid={`${props.subjectKind}-remove-submit`}
|
|
966
|
+
onClick={async () => {
|
|
967
|
+
setBusy(true);
|
|
968
|
+
await props.onConfirm();
|
|
969
|
+
setBusy(false);
|
|
970
|
+
}}
|
|
971
|
+
>
|
|
972
|
+
{busy ? "Deleting..." : `Delete ${props.subjectKind}`}
|
|
973
|
+
</Button>
|
|
974
|
+
</ModalActions>
|
|
975
|
+
</ModalShell>
|
|
976
|
+
);
|
|
977
|
+
}
|