@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.
@@ -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
+ }