@clef-sh/ui 0.1.13-beta.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/dist/client/assets/index-CVpAmirt.js +26 -0
- package/dist/client/favicon-96x96.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.svg +16 -0
- package/dist/client/index.html +50 -0
- package/dist/client-lib/api.d.ts +3 -0
- package/dist/client-lib/api.d.ts.map +1 -0
- package/dist/client-lib/components/Button.d.ts +10 -0
- package/dist/client-lib/components/Button.d.ts.map +1 -0
- package/dist/client-lib/components/CopyButton.d.ts +6 -0
- package/dist/client-lib/components/CopyButton.d.ts.map +1 -0
- package/dist/client-lib/components/EnvBadge.d.ts +7 -0
- package/dist/client-lib/components/EnvBadge.d.ts.map +1 -0
- package/dist/client-lib/components/MatrixGrid.d.ts +13 -0
- package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -0
- package/dist/client-lib/components/Sidebar.d.ts +16 -0
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -0
- package/dist/client-lib/components/StatusDot.d.ts +6 -0
- package/dist/client-lib/components/StatusDot.d.ts.map +1 -0
- package/dist/client-lib/components/TopBar.d.ts +9 -0
- package/dist/client-lib/components/TopBar.d.ts.map +1 -0
- package/dist/client-lib/index.d.ts +12 -0
- package/dist/client-lib/index.d.ts.map +1 -0
- package/dist/client-lib/theme.d.ts +42 -0
- package/dist/client-lib/theme.d.ts.map +1 -0
- package/dist/server/api.d.ts +11 -0
- package/dist/server/api.d.ts.map +1 -0
- package/dist/server/api.js +1020 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +231 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +74 -0
- package/src/client/App.tsx +205 -0
- package/src/client/api.test.tsx +94 -0
- package/src/client/api.ts +30 -0
- package/src/client/components/Button.tsx +52 -0
- package/src/client/components/CopyButton.test.tsx +43 -0
- package/src/client/components/CopyButton.tsx +36 -0
- package/src/client/components/EnvBadge.tsx +32 -0
- package/src/client/components/MatrixGrid.tsx +265 -0
- package/src/client/components/Sidebar.tsx +337 -0
- package/src/client/components/StatusDot.tsx +30 -0
- package/src/client/components/TopBar.tsx +50 -0
- package/src/client/index.html +50 -0
- package/src/client/index.ts +18 -0
- package/src/client/main.tsx +15 -0
- package/src/client/public/favicon-96x96.png +0 -0
- package/src/client/public/favicon.ico +0 -0
- package/src/client/public/favicon.svg +16 -0
- package/src/client/screens/BackendScreen.test.tsx +611 -0
- package/src/client/screens/BackendScreen.tsx +836 -0
- package/src/client/screens/DiffView.test.tsx +130 -0
- package/src/client/screens/DiffView.tsx +547 -0
- package/src/client/screens/GitLogView.test.tsx +113 -0
- package/src/client/screens/GitLogView.tsx +192 -0
- package/src/client/screens/ImportScreen.tsx +710 -0
- package/src/client/screens/LintView.test.tsx +143 -0
- package/src/client/screens/LintView.tsx +589 -0
- package/src/client/screens/MatrixView.test.tsx +138 -0
- package/src/client/screens/MatrixView.tsx +143 -0
- package/src/client/screens/NamespaceEditor.test.tsx +694 -0
- package/src/client/screens/NamespaceEditor.tsx +1122 -0
- package/src/client/screens/RecipientsScreen.tsx +696 -0
- package/src/client/screens/ScanScreen.test.tsx +323 -0
- package/src/client/screens/ScanScreen.tsx +523 -0
- package/src/client/screens/ServiceIdentitiesScreen.tsx +1398 -0
- package/src/client/theme.ts +48 -0
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { theme } from "../theme";
|
|
3
|
+
import { apiFetch } from "../api";
|
|
4
|
+
import { TopBar } from "../components/TopBar";
|
|
5
|
+
import { EnvBadge } from "../components/EnvBadge";
|
|
6
|
+
import { Button } from "../components/Button";
|
|
7
|
+
import { CopyButton } from "../components/CopyButton";
|
|
8
|
+
import type { ClefManifest } from "@clef-sh/core";
|
|
9
|
+
|
|
10
|
+
interface EnvInfo {
|
|
11
|
+
type: string;
|
|
12
|
+
publicKey?: string;
|
|
13
|
+
kms?: { provider: string; keyId: string };
|
|
14
|
+
protected?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface IdentityInfo {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
namespaces: string[];
|
|
21
|
+
environments: Record<string, EnvInfo>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface EnvBackendConfig {
|
|
25
|
+
type: "age" | "kms";
|
|
26
|
+
provider: string;
|
|
27
|
+
keyId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface UpdateEnvState extends EnvBackendConfig {
|
|
31
|
+
originalType: "age" | "kms";
|
|
32
|
+
originalKeyId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ServiceIdentitiesScreenProps {
|
|
36
|
+
manifest: ClefManifest | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type View = "list" | "detail" | "create" | "keys" | "update" | "rotate-keys" | "delete-confirm";
|
|
40
|
+
|
|
41
|
+
export function ServiceIdentitiesScreen({ manifest }: ServiceIdentitiesScreenProps) {
|
|
42
|
+
const [view, setView] = useState<View>("list");
|
|
43
|
+
const [identities, setIdentities] = useState<IdentityInfo[]>([]);
|
|
44
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
45
|
+
const [error, setError] = useState("");
|
|
46
|
+
|
|
47
|
+
// Create form state
|
|
48
|
+
const [name, setName] = useState("");
|
|
49
|
+
const [description, setDescription] = useState("");
|
|
50
|
+
const [selectedNamespaces, setSelectedNamespaces] = useState<Set<string>>(new Set());
|
|
51
|
+
const [envBackends, setEnvBackends] = useState<Record<string, EnvBackendConfig>>({});
|
|
52
|
+
const [creating, setCreating] = useState(false);
|
|
53
|
+
const [createError, setCreateError] = useState("");
|
|
54
|
+
|
|
55
|
+
// Post-create / post-rotate keys
|
|
56
|
+
const [privateKeys, setPrivateKeys] = useState<Record<string, string>>({});
|
|
57
|
+
const [createdName, setCreatedName] = useState("");
|
|
58
|
+
|
|
59
|
+
// Update form state
|
|
60
|
+
const [updateEnvBackends, setUpdateEnvBackends] = useState<Record<string, UpdateEnvState>>({});
|
|
61
|
+
const [updating, setUpdating] = useState(false);
|
|
62
|
+
const [updateError, setUpdateError] = useState("");
|
|
63
|
+
|
|
64
|
+
// Rotate state
|
|
65
|
+
const [rotatingEnv, setRotatingEnv] = useState<string | undefined>(undefined);
|
|
66
|
+
const [rotatedKeys, setRotatedKeys] = useState<Record<string, string>>({});
|
|
67
|
+
|
|
68
|
+
// Delete state
|
|
69
|
+
const [deleting, setDeleting] = useState(false);
|
|
70
|
+
const [deleteError, setDeleteError] = useState("");
|
|
71
|
+
|
|
72
|
+
const load = useCallback(async () => {
|
|
73
|
+
try {
|
|
74
|
+
const res = await apiFetch("/api/service-identities");
|
|
75
|
+
if (res.ok) {
|
|
76
|
+
const data = await res.json();
|
|
77
|
+
setIdentities(data.identities);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Silently fail
|
|
81
|
+
}
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
load();
|
|
86
|
+
}, [load]);
|
|
87
|
+
|
|
88
|
+
const selectedIdentity = identities.find((i) => i.name === selected);
|
|
89
|
+
|
|
90
|
+
const openCreate = useCallback(() => {
|
|
91
|
+
setName("");
|
|
92
|
+
setDescription("");
|
|
93
|
+
setSelectedNamespaces(new Set());
|
|
94
|
+
const defaults: Record<string, EnvBackendConfig> = {};
|
|
95
|
+
for (const env of manifest?.environments ?? []) {
|
|
96
|
+
defaults[env.name] = { type: "age", provider: "aws", keyId: "" };
|
|
97
|
+
}
|
|
98
|
+
setEnvBackends(defaults);
|
|
99
|
+
setCreateError("");
|
|
100
|
+
setView("create");
|
|
101
|
+
}, [manifest]);
|
|
102
|
+
|
|
103
|
+
const openUpdate = useCallback((identity: IdentityInfo) => {
|
|
104
|
+
const defaults: Record<string, UpdateEnvState> = {};
|
|
105
|
+
for (const [envName, envInfo] of Object.entries(identity.environments)) {
|
|
106
|
+
const t: "age" | "kms" = envInfo.type === "kms" ? "kms" : "age";
|
|
107
|
+
defaults[envName] = {
|
|
108
|
+
type: t,
|
|
109
|
+
provider: envInfo.kms?.provider ?? "aws",
|
|
110
|
+
keyId: envInfo.kms?.keyId ?? "",
|
|
111
|
+
originalType: t,
|
|
112
|
+
originalKeyId: envInfo.kms?.keyId ?? "",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
setUpdateEnvBackends(defaults);
|
|
116
|
+
setUpdateError("");
|
|
117
|
+
setView("update");
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
const goList = useCallback(() => {
|
|
121
|
+
setSelected(null);
|
|
122
|
+
setError("");
|
|
123
|
+
setView("list");
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
const goDetail = useCallback(() => {
|
|
127
|
+
setError("");
|
|
128
|
+
setDeleteError("");
|
|
129
|
+
setView("detail");
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async function handleCreate() {
|
|
135
|
+
setCreating(true);
|
|
136
|
+
setCreateError("");
|
|
137
|
+
try {
|
|
138
|
+
const kmsEnvConfigs: Record<string, { provider: string; keyId: string }> = {};
|
|
139
|
+
for (const [envName, cfg] of Object.entries(envBackends)) {
|
|
140
|
+
if (cfg.type === "kms") {
|
|
141
|
+
kmsEnvConfigs[envName] = { provider: cfg.provider, keyId: cfg.keyId };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const body: Record<string, unknown> = {
|
|
145
|
+
name: name.trim(),
|
|
146
|
+
description: description.trim(),
|
|
147
|
+
namespaces: Array.from(selectedNamespaces),
|
|
148
|
+
};
|
|
149
|
+
if (Object.keys(kmsEnvConfigs).length > 0) {
|
|
150
|
+
body.kmsEnvConfigs = kmsEnvConfigs;
|
|
151
|
+
}
|
|
152
|
+
const res = await apiFetch("/api/service-identities", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "Content-Type": "application/json" },
|
|
155
|
+
body: JSON.stringify(body),
|
|
156
|
+
});
|
|
157
|
+
const data = await res.json();
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
setCreateError(data.error ?? "Failed to create service identity.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
setCreatedName(data.identity.name);
|
|
163
|
+
setPrivateKeys(data.privateKeys ?? {});
|
|
164
|
+
setView("keys");
|
|
165
|
+
} catch {
|
|
166
|
+
setCreateError("Network error. Check that the UI server is running.");
|
|
167
|
+
} finally {
|
|
168
|
+
setCreating(false);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function handleUpdate() {
|
|
173
|
+
if (!selected) return;
|
|
174
|
+
setUpdating(true);
|
|
175
|
+
setUpdateError("");
|
|
176
|
+
try {
|
|
177
|
+
const kmsEnvConfigs: Record<string, { provider: string; keyId: string }> = {};
|
|
178
|
+
for (const [envName, state] of Object.entries(updateEnvBackends)) {
|
|
179
|
+
if (state.type === "kms") {
|
|
180
|
+
if (state.originalType !== "kms" || state.keyId !== state.originalKeyId) {
|
|
181
|
+
kmsEnvConfigs[envName] = { provider: state.provider, keyId: state.keyId };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (Object.keys(kmsEnvConfigs).length === 0) {
|
|
186
|
+
setUpdateError("No changes to apply.");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const res = await apiFetch(`/api/service-identities/${encodeURIComponent(selected)}`, {
|
|
190
|
+
method: "PATCH",
|
|
191
|
+
headers: { "Content-Type": "application/json" },
|
|
192
|
+
body: JSON.stringify({ kmsEnvConfigs }),
|
|
193
|
+
});
|
|
194
|
+
const data = await res.json();
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
setUpdateError(data.error ?? "Failed to update service identity.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await load();
|
|
200
|
+
goDetail();
|
|
201
|
+
} catch {
|
|
202
|
+
setUpdateError("Network error. Check that the UI server is running.");
|
|
203
|
+
} finally {
|
|
204
|
+
setUpdating(false);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function handleRotate(envName: string) {
|
|
209
|
+
if (!selected) return;
|
|
210
|
+
setRotatingEnv(envName);
|
|
211
|
+
setError("");
|
|
212
|
+
try {
|
|
213
|
+
const res = await apiFetch(`/api/service-identities/${encodeURIComponent(selected)}/rotate`, {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: { "Content-Type": "application/json" },
|
|
216
|
+
body: JSON.stringify({ environment: envName }),
|
|
217
|
+
});
|
|
218
|
+
const data = await res.json();
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
setError(data.error ?? "Failed to rotate key.");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
setRotatedKeys(data.privateKeys ?? {});
|
|
224
|
+
await load();
|
|
225
|
+
setView("rotate-keys");
|
|
226
|
+
} catch {
|
|
227
|
+
setError("Network error. Check that the UI server is running.");
|
|
228
|
+
} finally {
|
|
229
|
+
setRotatingEnv(undefined);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function handleDelete() {
|
|
234
|
+
if (!selected) return;
|
|
235
|
+
setDeleting(true);
|
|
236
|
+
setDeleteError("");
|
|
237
|
+
try {
|
|
238
|
+
const res = await apiFetch(`/api/service-identities/${encodeURIComponent(selected)}`, {
|
|
239
|
+
method: "DELETE",
|
|
240
|
+
});
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
const data = await res.json();
|
|
243
|
+
setDeleteError(data.error ?? "Failed to delete service identity.");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await load();
|
|
247
|
+
goList();
|
|
248
|
+
} catch {
|
|
249
|
+
setDeleteError("Network error. Check that the UI server is running.");
|
|
250
|
+
} finally {
|
|
251
|
+
setDeleting(false);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── List view ─────────────────────────────────────────────────────────────────
|
|
256
|
+
if (view === "list") {
|
|
257
|
+
return (
|
|
258
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
259
|
+
<TopBar
|
|
260
|
+
title="Service Identities"
|
|
261
|
+
subtitle="Per-service cryptographic access scoping"
|
|
262
|
+
actions={
|
|
263
|
+
manifest && (
|
|
264
|
+
<Button variant="primary" onClick={openCreate}>
|
|
265
|
+
+ New identity
|
|
266
|
+
</Button>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
/>
|
|
270
|
+
<div style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
|
271
|
+
<div style={{ maxWidth: 620, margin: "0 auto" }}>
|
|
272
|
+
{error && <ErrorBanner>{error}</ErrorBanner>}
|
|
273
|
+
|
|
274
|
+
{identities.length === 0 && (
|
|
275
|
+
<div
|
|
276
|
+
style={{
|
|
277
|
+
textAlign: "center",
|
|
278
|
+
padding: "48px 24px",
|
|
279
|
+
color: theme.textMuted,
|
|
280
|
+
fontFamily: theme.sans,
|
|
281
|
+
fontSize: 13,
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
<div style={{ fontSize: 28, marginBottom: 12, opacity: 0.4 }}>{"\uD83D\uDD11"}</div>
|
|
285
|
+
No service identities configured.
|
|
286
|
+
{manifest && (
|
|
287
|
+
<div style={{ marginTop: 16 }}>
|
|
288
|
+
<Button variant="primary" onClick={openCreate}>
|
|
289
|
+
Create the first one
|
|
290
|
+
</Button>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
{identities.map((si) => (
|
|
297
|
+
<div
|
|
298
|
+
key={si.name}
|
|
299
|
+
role="button"
|
|
300
|
+
tabIndex={0}
|
|
301
|
+
data-testid={`si-${si.name}`}
|
|
302
|
+
onClick={() => {
|
|
303
|
+
setSelected(si.name);
|
|
304
|
+
setError("");
|
|
305
|
+
setView("detail");
|
|
306
|
+
}}
|
|
307
|
+
onKeyDown={(e) => {
|
|
308
|
+
if (e.key === "Enter") {
|
|
309
|
+
setSelected(si.name);
|
|
310
|
+
setView("detail");
|
|
311
|
+
}
|
|
312
|
+
}}
|
|
313
|
+
style={{
|
|
314
|
+
background: theme.surface,
|
|
315
|
+
border: `1px solid ${theme.border}`,
|
|
316
|
+
borderRadius: 8,
|
|
317
|
+
padding: "16px 20px",
|
|
318
|
+
marginBottom: 8,
|
|
319
|
+
cursor: "pointer",
|
|
320
|
+
transition: "all 0.12s",
|
|
321
|
+
}}
|
|
322
|
+
onMouseEnter={(e) => {
|
|
323
|
+
(e.currentTarget as HTMLDivElement).style.borderColor = theme.borderLight;
|
|
324
|
+
}}
|
|
325
|
+
onMouseLeave={(e) => {
|
|
326
|
+
(e.currentTarget as HTMLDivElement).style.borderColor = theme.border;
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
|
330
|
+
<span
|
|
331
|
+
style={{
|
|
332
|
+
fontFamily: theme.sans,
|
|
333
|
+
fontWeight: 600,
|
|
334
|
+
fontSize: 14,
|
|
335
|
+
color: theme.text,
|
|
336
|
+
}}
|
|
337
|
+
>
|
|
338
|
+
{si.name}
|
|
339
|
+
</span>
|
|
340
|
+
</div>
|
|
341
|
+
<div
|
|
342
|
+
style={{
|
|
343
|
+
fontFamily: theme.sans,
|
|
344
|
+
fontSize: 12,
|
|
345
|
+
color: theme.textMuted,
|
|
346
|
+
marginBottom: 10,
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
Scoped to: <span style={{ color: theme.text }}>{si.namespaces.join(", ")}</span>
|
|
350
|
+
</div>
|
|
351
|
+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
352
|
+
{Object.entries(si.environments).map(([envName, envInfo]) => (
|
|
353
|
+
<span
|
|
354
|
+
key={envName}
|
|
355
|
+
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
|
356
|
+
>
|
|
357
|
+
<EnvBadge env={envName} small />
|
|
358
|
+
<span
|
|
359
|
+
style={{
|
|
360
|
+
fontFamily: theme.mono,
|
|
361
|
+
fontSize: 9,
|
|
362
|
+
color: envInfo.type === "kms" ? theme.purple : theme.textDim,
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
{envInfo.type === "kms" ? "KMS" : "age"}
|
|
366
|
+
</span>
|
|
367
|
+
</span>
|
|
368
|
+
))}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
))}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Detail view ───────────────────────────────────────────────────────────────
|
|
379
|
+
if (view === "detail") {
|
|
380
|
+
return (
|
|
381
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
382
|
+
<TopBar
|
|
383
|
+
title={selectedIdentity?.name ?? selected ?? ""}
|
|
384
|
+
subtitle={selectedIdentity?.description}
|
|
385
|
+
actions={
|
|
386
|
+
<div style={{ display: "flex", gap: 6 }}>
|
|
387
|
+
{selectedIdentity && (
|
|
388
|
+
<Button
|
|
389
|
+
data-testid="update-backends-btn"
|
|
390
|
+
variant="ghost"
|
|
391
|
+
onClick={() => openUpdate(selectedIdentity)}
|
|
392
|
+
>
|
|
393
|
+
Update backends
|
|
394
|
+
</Button>
|
|
395
|
+
)}
|
|
396
|
+
<button
|
|
397
|
+
data-testid="back-button"
|
|
398
|
+
onClick={goList}
|
|
399
|
+
style={{
|
|
400
|
+
background: "none",
|
|
401
|
+
border: `1px solid ${theme.borderLight}`,
|
|
402
|
+
borderRadius: 6,
|
|
403
|
+
padding: "4px 12px",
|
|
404
|
+
cursor: "pointer",
|
|
405
|
+
fontFamily: theme.sans,
|
|
406
|
+
fontSize: 12,
|
|
407
|
+
color: theme.textMuted,
|
|
408
|
+
transition: "all 0.12s",
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
{"\u2190"} Back
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
}
|
|
415
|
+
/>
|
|
416
|
+
<div style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
|
417
|
+
<div style={{ maxWidth: 620, margin: "0 auto" }}>
|
|
418
|
+
{error && <ErrorBanner>{error}</ErrorBanner>}
|
|
419
|
+
|
|
420
|
+
{selectedIdentity && (
|
|
421
|
+
<>
|
|
422
|
+
<div style={{ marginBottom: 20 }}>
|
|
423
|
+
<Label>Scoped namespaces</Label>
|
|
424
|
+
<div style={{ display: "flex", gap: 6 }}>
|
|
425
|
+
{selectedIdentity.namespaces.map((ns) => (
|
|
426
|
+
<span
|
|
427
|
+
key={ns}
|
|
428
|
+
style={{
|
|
429
|
+
fontFamily: theme.mono,
|
|
430
|
+
fontSize: 11,
|
|
431
|
+
color: theme.accent,
|
|
432
|
+
background: theme.accentDim,
|
|
433
|
+
border: `1px solid ${theme.accent}33`,
|
|
434
|
+
borderRadius: 4,
|
|
435
|
+
padding: "2px 8px",
|
|
436
|
+
}}
|
|
437
|
+
>
|
|
438
|
+
{ns}
|
|
439
|
+
</span>
|
|
440
|
+
))}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<Label>Environment keys</Label>
|
|
445
|
+
|
|
446
|
+
{manifest?.environments.map((env) => {
|
|
447
|
+
const envInfo = selectedIdentity.environments[env.name];
|
|
448
|
+
if (!envInfo) return null;
|
|
449
|
+
const isProtected = envInfo.protected ?? false;
|
|
450
|
+
const isRotating = rotatingEnv === env.name;
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<div
|
|
454
|
+
key={env.name}
|
|
455
|
+
data-testid={`env-${env.name}`}
|
|
456
|
+
style={{
|
|
457
|
+
background: theme.surface,
|
|
458
|
+
border: `1px solid ${theme.border}`,
|
|
459
|
+
borderRadius: 8,
|
|
460
|
+
padding: "16px 20px",
|
|
461
|
+
marginBottom: 10,
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
<div
|
|
465
|
+
style={{
|
|
466
|
+
display: "flex",
|
|
467
|
+
alignItems: "center",
|
|
468
|
+
justifyContent: "space-between",
|
|
469
|
+
marginBottom: 12,
|
|
470
|
+
}}
|
|
471
|
+
>
|
|
472
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
473
|
+
<EnvBadge env={env.name} />
|
|
474
|
+
{isProtected && (
|
|
475
|
+
<span style={{ fontSize: 12, color: theme.red }}>{"\uD83D\uDD12"}</span>
|
|
476
|
+
)}
|
|
477
|
+
{envInfo.type === "kms" && (
|
|
478
|
+
<span
|
|
479
|
+
style={{
|
|
480
|
+
fontFamily: theme.mono,
|
|
481
|
+
fontSize: 10,
|
|
482
|
+
color: theme.purple,
|
|
483
|
+
background: theme.purpleDim,
|
|
484
|
+
border: `1px solid ${theme.purple}33`,
|
|
485
|
+
borderRadius: 3,
|
|
486
|
+
padding: "1px 6px",
|
|
487
|
+
}}
|
|
488
|
+
>
|
|
489
|
+
KMS
|
|
490
|
+
</span>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
{envInfo.type === "age" && (
|
|
494
|
+
<button
|
|
495
|
+
data-testid={`rotate-${env.name}`}
|
|
496
|
+
disabled={isRotating}
|
|
497
|
+
onClick={() => handleRotate(env.name)}
|
|
498
|
+
style={{
|
|
499
|
+
background: "none",
|
|
500
|
+
border: `1px solid ${theme.borderLight}`,
|
|
501
|
+
borderRadius: 5,
|
|
502
|
+
padding: "3px 10px",
|
|
503
|
+
cursor: isRotating ? "default" : "pointer",
|
|
504
|
+
fontFamily: theme.sans,
|
|
505
|
+
fontSize: 11,
|
|
506
|
+
color: isRotating ? theme.textDim : theme.textMuted,
|
|
507
|
+
opacity: isRotating ? 0.5 : 1,
|
|
508
|
+
}}
|
|
509
|
+
>
|
|
510
|
+
{isRotating ? "Rotating…" : "Rotate key"}
|
|
511
|
+
</button>
|
|
512
|
+
)}
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
{envInfo.type === "kms" && envInfo.kms && (
|
|
516
|
+
<div
|
|
517
|
+
style={{ fontFamily: theme.mono, fontSize: 11, color: theme.textMuted }}
|
|
518
|
+
>
|
|
519
|
+
<div style={{ marginBottom: 8 }}>
|
|
520
|
+
Authentication: <span style={{ color: theme.purple }}>IAM + KMS</span>
|
|
521
|
+
</div>
|
|
522
|
+
<div>
|
|
523
|
+
Provider:{" "}
|
|
524
|
+
<span style={{ color: theme.text }}>{envInfo.kms.provider}</span>
|
|
525
|
+
</div>
|
|
526
|
+
<div style={{ marginTop: 4 }}>
|
|
527
|
+
Key ID:{" "}
|
|
528
|
+
<span style={{ color: theme.text, wordBreak: "break-all" }}>
|
|
529
|
+
{envInfo.kms.keyId}
|
|
530
|
+
</span>
|
|
531
|
+
</div>
|
|
532
|
+
<div
|
|
533
|
+
style={{
|
|
534
|
+
marginTop: 10,
|
|
535
|
+
padding: "8px 12px",
|
|
536
|
+
background: theme.purpleDim,
|
|
537
|
+
border: `1px solid ${theme.purple}33`,
|
|
538
|
+
borderRadius: 4,
|
|
539
|
+
fontSize: 11,
|
|
540
|
+
color: theme.purple,
|
|
541
|
+
fontFamily: theme.sans,
|
|
542
|
+
}}
|
|
543
|
+
>
|
|
544
|
+
No keys to provision. CI and runtime authenticate via IAM role with
|
|
545
|
+
kms:Decrypt permission.
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
)}
|
|
549
|
+
|
|
550
|
+
{envInfo.type === "age" && (
|
|
551
|
+
<div
|
|
552
|
+
style={{ fontFamily: theme.mono, fontSize: 11, color: theme.textMuted }}
|
|
553
|
+
>
|
|
554
|
+
<div style={{ marginBottom: 8 }}>
|
|
555
|
+
Authentication: <span style={{ color: theme.green }}>age key</span>
|
|
556
|
+
</div>
|
|
557
|
+
<div>
|
|
558
|
+
Public key:{" "}
|
|
559
|
+
<span style={{ color: theme.text }}>
|
|
560
|
+
{envInfo.publicKey
|
|
561
|
+
? `${envInfo.publicKey.slice(0, 12)}...${envInfo.publicKey.slice(-6)}`
|
|
562
|
+
: "unknown"}
|
|
563
|
+
</span>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
</div>
|
|
568
|
+
);
|
|
569
|
+
})}
|
|
570
|
+
|
|
571
|
+
<div
|
|
572
|
+
style={{
|
|
573
|
+
marginTop: 32,
|
|
574
|
+
paddingTop: 20,
|
|
575
|
+
borderTop: `1px solid ${theme.border}`,
|
|
576
|
+
display: "flex",
|
|
577
|
+
justifyContent: "flex-end",
|
|
578
|
+
}}
|
|
579
|
+
>
|
|
580
|
+
<Button
|
|
581
|
+
data-testid="delete-identity-btn"
|
|
582
|
+
variant="danger"
|
|
583
|
+
onClick={() => {
|
|
584
|
+
setDeleteError("");
|
|
585
|
+
setView("delete-confirm");
|
|
586
|
+
}}
|
|
587
|
+
>
|
|
588
|
+
Delete identity
|
|
589
|
+
</Button>
|
|
590
|
+
</div>
|
|
591
|
+
</>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── Delete confirm view ───────────────────────────────────────────────────────
|
|
600
|
+
if (view === "delete-confirm") {
|
|
601
|
+
return (
|
|
602
|
+
<div
|
|
603
|
+
data-testid="delete-confirm-view"
|
|
604
|
+
style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
|
|
605
|
+
>
|
|
606
|
+
<TopBar title="Delete service identity" subtitle="This action cannot be undone" />
|
|
607
|
+
<div style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
|
608
|
+
<div style={{ maxWidth: 560, margin: "0 auto" }}>
|
|
609
|
+
{deleteError && <ErrorBanner>{deleteError}</ErrorBanner>}
|
|
610
|
+
|
|
611
|
+
<div
|
|
612
|
+
style={{
|
|
613
|
+
background: "#1a0a0a",
|
|
614
|
+
border: `1px solid ${theme.red}55`,
|
|
615
|
+
borderRadius: 8,
|
|
616
|
+
padding: "16px 20px",
|
|
617
|
+
marginBottom: 24,
|
|
618
|
+
fontFamily: theme.sans,
|
|
619
|
+
fontSize: 13,
|
|
620
|
+
color: theme.red,
|
|
621
|
+
}}
|
|
622
|
+
>
|
|
623
|
+
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
|
624
|
+
Delete <span style={{ fontFamily: theme.mono }}>{selected}</span>?
|
|
625
|
+
</div>
|
|
626
|
+
<div style={{ color: theme.textMuted, fontSize: 12, lineHeight: 1.6 }}>
|
|
627
|
+
This will remove the identity from{" "}
|
|
628
|
+
<span style={{ fontFamily: theme.mono }}>clef.yaml</span> and de-register its
|
|
629
|
+
recipients from all scoped encrypted files. Any runtimes currently using this
|
|
630
|
+
identity's private key will lose access on the next artifact refresh.
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
|
635
|
+
<Button
|
|
636
|
+
data-testid="cancel-delete-btn"
|
|
637
|
+
variant="ghost"
|
|
638
|
+
onClick={goDetail}
|
|
639
|
+
disabled={deleting}
|
|
640
|
+
>
|
|
641
|
+
Cancel
|
|
642
|
+
</Button>
|
|
643
|
+
<Button
|
|
644
|
+
data-testid="confirm-delete-btn"
|
|
645
|
+
variant="danger"
|
|
646
|
+
onClick={handleDelete}
|
|
647
|
+
disabled={deleting}
|
|
648
|
+
>
|
|
649
|
+
{deleting ? "Deleting…" : "Delete identity"}
|
|
650
|
+
</Button>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Rotate keys result view ───────────────────────────────────────────────────
|
|
659
|
+
if (view === "rotate-keys") {
|
|
660
|
+
return (
|
|
661
|
+
<div
|
|
662
|
+
data-testid="rotate-keys-view"
|
|
663
|
+
style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}
|
|
664
|
+
>
|
|
665
|
+
<TopBar title="Key rotated" subtitle={`New keys for ${selected}`} />
|
|
666
|
+
<div style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
|
667
|
+
<div style={{ maxWidth: 620, margin: "0 auto" }}>
|
|
668
|
+
<div
|
|
669
|
+
style={{
|
|
670
|
+
background: "#1a1200",
|
|
671
|
+
border: `1px solid ${theme.yellow}55`,
|
|
672
|
+
borderRadius: 8,
|
|
673
|
+
padding: "14px 18px",
|
|
674
|
+
marginBottom: 20,
|
|
675
|
+
fontFamily: theme.sans,
|
|
676
|
+
fontSize: 13,
|
|
677
|
+
color: theme.yellow,
|
|
678
|
+
display: "flex",
|
|
679
|
+
gap: 10,
|
|
680
|
+
alignItems: "flex-start",
|
|
681
|
+
}}
|
|
682
|
+
>
|
|
683
|
+
<span style={{ fontSize: 16, flexShrink: 0 }}>⚠</span>
|
|
684
|
+
<span>
|
|
685
|
+
Copy the new private key now — it will not be shown again. Provision it to the
|
|
686
|
+
runtime and invalidate the old key.
|
|
687
|
+
</span>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
<Label>New private keys</Label>
|
|
691
|
+
{Object.entries(rotatedKeys).map(([envName, key]) => (
|
|
692
|
+
<div
|
|
693
|
+
key={envName}
|
|
694
|
+
style={{
|
|
695
|
+
background: theme.surface,
|
|
696
|
+
border: `1px solid ${theme.border}`,
|
|
697
|
+
borderRadius: 8,
|
|
698
|
+
padding: "14px 18px",
|
|
699
|
+
marginBottom: 10,
|
|
700
|
+
}}
|
|
701
|
+
>
|
|
702
|
+
<div
|
|
703
|
+
style={{
|
|
704
|
+
display: "flex",
|
|
705
|
+
alignItems: "center",
|
|
706
|
+
justifyContent: "space-between",
|
|
707
|
+
marginBottom: 10,
|
|
708
|
+
}}
|
|
709
|
+
>
|
|
710
|
+
<EnvBadge env={envName} />
|
|
711
|
+
<CopyButton text={key} />
|
|
712
|
+
</div>
|
|
713
|
+
<div
|
|
714
|
+
style={{
|
|
715
|
+
fontFamily: theme.mono,
|
|
716
|
+
fontSize: 11,
|
|
717
|
+
color: theme.textMuted,
|
|
718
|
+
wordBreak: "break-all",
|
|
719
|
+
background: theme.bg,
|
|
720
|
+
borderRadius: 4,
|
|
721
|
+
padding: "8px 10px",
|
|
722
|
+
}}
|
|
723
|
+
>
|
|
724
|
+
{key}
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
))}
|
|
728
|
+
|
|
729
|
+
<div style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
|
|
730
|
+
<Button data-testid="rotate-done-btn" variant="primary" onClick={goDetail}>
|
|
731
|
+
Done
|
|
732
|
+
</Button>
|
|
733
|
+
</div>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
</div>
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── Update backends view ──────────────────────────────────────────────────────
|
|
741
|
+
if (view === "update") {
|
|
742
|
+
const environments = manifest?.environments ?? [];
|
|
743
|
+
|
|
744
|
+
const changedCount = Object.values(updateEnvBackends).filter((state) => {
|
|
745
|
+
if (state.type !== "kms") return false;
|
|
746
|
+
return state.originalType !== "kms" || state.keyId !== state.originalKeyId;
|
|
747
|
+
}).length;
|
|
748
|
+
|
|
749
|
+
const canUpdate =
|
|
750
|
+
changedCount > 0 &&
|
|
751
|
+
Object.entries(updateEnvBackends).every(([, state]) => {
|
|
752
|
+
if (state.type !== "kms") return true;
|
|
753
|
+
return state.keyId.trim() !== "";
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
return (
|
|
757
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
758
|
+
<TopBar
|
|
759
|
+
title="Update backends"
|
|
760
|
+
subtitle={`Environment backends for ${selected}`}
|
|
761
|
+
actions={
|
|
762
|
+
<button
|
|
763
|
+
onClick={goDetail}
|
|
764
|
+
style={{
|
|
765
|
+
background: "none",
|
|
766
|
+
border: `1px solid ${theme.borderLight}`,
|
|
767
|
+
borderRadius: 6,
|
|
768
|
+
padding: "4px 12px",
|
|
769
|
+
cursor: "pointer",
|
|
770
|
+
fontFamily: theme.sans,
|
|
771
|
+
fontSize: 12,
|
|
772
|
+
color: theme.textMuted,
|
|
773
|
+
}}
|
|
774
|
+
>
|
|
775
|
+
{"\u2190"} Cancel
|
|
776
|
+
</button>
|
|
777
|
+
}
|
|
778
|
+
/>
|
|
779
|
+
<div style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
|
780
|
+
<div style={{ maxWidth: 560, margin: "0 auto" }}>
|
|
781
|
+
{updateError && <ErrorBanner>{updateError}</ErrorBanner>}
|
|
782
|
+
|
|
783
|
+
<div
|
|
784
|
+
style={{
|
|
785
|
+
fontFamily: theme.sans,
|
|
786
|
+
fontSize: 12,
|
|
787
|
+
color: theme.textMuted,
|
|
788
|
+
marginBottom: 16,
|
|
789
|
+
lineHeight: 1.6,
|
|
790
|
+
}}
|
|
791
|
+
>
|
|
792
|
+
Switch age environments to KMS, or update an existing KMS key ID. To revert KMS to
|
|
793
|
+
age, delete and recreate the identity.
|
|
794
|
+
</div>
|
|
795
|
+
|
|
796
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 28 }}>
|
|
797
|
+
{environments.map((env) => {
|
|
798
|
+
const state = updateEnvBackends[env.name];
|
|
799
|
+
if (!state) return null;
|
|
800
|
+
|
|
801
|
+
return (
|
|
802
|
+
<div
|
|
803
|
+
key={env.name}
|
|
804
|
+
style={{
|
|
805
|
+
background: theme.surface,
|
|
806
|
+
border: `1px solid ${theme.border}`,
|
|
807
|
+
borderRadius: 8,
|
|
808
|
+
padding: "14px 16px",
|
|
809
|
+
}}
|
|
810
|
+
>
|
|
811
|
+
<div
|
|
812
|
+
style={{
|
|
813
|
+
display: "flex",
|
|
814
|
+
alignItems: "center",
|
|
815
|
+
justifyContent: "space-between",
|
|
816
|
+
marginBottom: state.type === "kms" ? 12 : 0,
|
|
817
|
+
}}
|
|
818
|
+
>
|
|
819
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
820
|
+
<EnvBadge env={env.name} />
|
|
821
|
+
{env.protected && (
|
|
822
|
+
<span style={{ fontSize: 11, color: theme.red }}>{"\uD83D\uDD12"}</span>
|
|
823
|
+
)}
|
|
824
|
+
</div>
|
|
825
|
+
<div style={{ display: "flex", gap: 4 }}>
|
|
826
|
+
{(["age", "kms"] as const).map((t) => {
|
|
827
|
+
const locked = state.originalType === "kms" && t === "age";
|
|
828
|
+
return (
|
|
829
|
+
<button
|
|
830
|
+
key={t}
|
|
831
|
+
data-testid={
|
|
832
|
+
t === "kms" ? `update-kms-toggle-${env.name}` : undefined
|
|
833
|
+
}
|
|
834
|
+
disabled={locked}
|
|
835
|
+
onClick={() => {
|
|
836
|
+
if (locked) return;
|
|
837
|
+
setUpdateEnvBackends((prev) => ({
|
|
838
|
+
...prev,
|
|
839
|
+
[env.name]: { ...state, type: t },
|
|
840
|
+
}));
|
|
841
|
+
}}
|
|
842
|
+
title={locked ? "KMS → age requires delete and recreate" : undefined}
|
|
843
|
+
style={{
|
|
844
|
+
background:
|
|
845
|
+
state.type === t
|
|
846
|
+
? t === "kms"
|
|
847
|
+
? theme.purple
|
|
848
|
+
: theme.accent
|
|
849
|
+
: "transparent",
|
|
850
|
+
border: `1px solid ${
|
|
851
|
+
state.type === t
|
|
852
|
+
? t === "kms"
|
|
853
|
+
? theme.purple
|
|
854
|
+
: theme.accent
|
|
855
|
+
: theme.border
|
|
856
|
+
}`,
|
|
857
|
+
borderRadius: 4,
|
|
858
|
+
padding: "3px 10px",
|
|
859
|
+
cursor: locked ? "not-allowed" : "pointer",
|
|
860
|
+
fontFamily: theme.mono,
|
|
861
|
+
fontSize: 11,
|
|
862
|
+
color: state.type === t ? "#fff" : theme.textMuted,
|
|
863
|
+
opacity: locked ? 0.4 : 1,
|
|
864
|
+
transition: "all 0.1s",
|
|
865
|
+
}}
|
|
866
|
+
>
|
|
867
|
+
{t.toUpperCase()}
|
|
868
|
+
</button>
|
|
869
|
+
);
|
|
870
|
+
})}
|
|
871
|
+
</div>
|
|
872
|
+
</div>
|
|
873
|
+
|
|
874
|
+
{state.type === "kms" && (
|
|
875
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
876
|
+
<select
|
|
877
|
+
value={state.provider}
|
|
878
|
+
onChange={(e) =>
|
|
879
|
+
setUpdateEnvBackends((prev) => ({
|
|
880
|
+
...prev,
|
|
881
|
+
[env.name]: { ...state, provider: e.target.value },
|
|
882
|
+
}))
|
|
883
|
+
}
|
|
884
|
+
style={{
|
|
885
|
+
...inputStyle,
|
|
886
|
+
width: 90,
|
|
887
|
+
flexShrink: 0,
|
|
888
|
+
padding: "7px 8px",
|
|
889
|
+
}}
|
|
890
|
+
>
|
|
891
|
+
<option value="aws">AWS</option>
|
|
892
|
+
<option value="gcp">GCP</option>
|
|
893
|
+
<option value="azure">Azure</option>
|
|
894
|
+
</select>
|
|
895
|
+
<input
|
|
896
|
+
data-testid={`update-keyid-${env.name}`}
|
|
897
|
+
value={state.keyId}
|
|
898
|
+
onChange={(e) =>
|
|
899
|
+
setUpdateEnvBackends((prev) => ({
|
|
900
|
+
...prev,
|
|
901
|
+
[env.name]: { ...state, keyId: e.target.value },
|
|
902
|
+
}))
|
|
903
|
+
}
|
|
904
|
+
placeholder="arn:aws:kms:… or key resource ID"
|
|
905
|
+
style={{ ...inputStyle, flex: 1 }}
|
|
906
|
+
/>
|
|
907
|
+
</div>
|
|
908
|
+
)}
|
|
909
|
+
</div>
|
|
910
|
+
);
|
|
911
|
+
})}
|
|
912
|
+
</div>
|
|
913
|
+
|
|
914
|
+
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
|
915
|
+
<Button
|
|
916
|
+
data-testid="update-cancel-btn"
|
|
917
|
+
variant="ghost"
|
|
918
|
+
onClick={goDetail}
|
|
919
|
+
disabled={updating}
|
|
920
|
+
>
|
|
921
|
+
Cancel
|
|
922
|
+
</Button>
|
|
923
|
+
<Button
|
|
924
|
+
data-testid="update-submit-btn"
|
|
925
|
+
variant="primary"
|
|
926
|
+
onClick={handleUpdate}
|
|
927
|
+
disabled={!canUpdate || updating}
|
|
928
|
+
>
|
|
929
|
+
{updating ? "Saving…" : "Save changes"}
|
|
930
|
+
</Button>
|
|
931
|
+
</div>
|
|
932
|
+
</div>
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ── Keys result view (post-creation) ─────────────────────────────────────────
|
|
939
|
+
if (view === "keys") {
|
|
940
|
+
const hasAgeKeys = Object.keys(privateKeys).length > 0;
|
|
941
|
+
return (
|
|
942
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
943
|
+
<TopBar title={`${createdName} created`} subtitle="Service identity ready" />
|
|
944
|
+
<div style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
|
945
|
+
<div style={{ maxWidth: 620, margin: "0 auto" }}>
|
|
946
|
+
{hasAgeKeys && (
|
|
947
|
+
<div
|
|
948
|
+
style={{
|
|
949
|
+
background: "#1a1200",
|
|
950
|
+
border: `1px solid ${theme.yellow}55`,
|
|
951
|
+
borderRadius: 8,
|
|
952
|
+
padding: "14px 18px",
|
|
953
|
+
marginBottom: 20,
|
|
954
|
+
fontFamily: theme.sans,
|
|
955
|
+
fontSize: 13,
|
|
956
|
+
color: theme.yellow,
|
|
957
|
+
display: "flex",
|
|
958
|
+
gap: 10,
|
|
959
|
+
alignItems: "flex-start",
|
|
960
|
+
}}
|
|
961
|
+
>
|
|
962
|
+
<span style={{ fontSize: 16, flexShrink: 0 }}>⚠</span>
|
|
963
|
+
<span>
|
|
964
|
+
Copy these private keys now — they will not be shown again. Store each key
|
|
965
|
+
securely and provision it to the relevant runtime.
|
|
966
|
+
</span>
|
|
967
|
+
</div>
|
|
968
|
+
)}
|
|
969
|
+
|
|
970
|
+
{!hasAgeKeys && (
|
|
971
|
+
<div
|
|
972
|
+
style={{
|
|
973
|
+
background: theme.purpleDim,
|
|
974
|
+
border: `1px solid ${theme.purple}44`,
|
|
975
|
+
borderRadius: 8,
|
|
976
|
+
padding: "14px 18px",
|
|
977
|
+
marginBottom: 20,
|
|
978
|
+
fontFamily: theme.sans,
|
|
979
|
+
fontSize: 13,
|
|
980
|
+
color: theme.purple,
|
|
981
|
+
}}
|
|
982
|
+
>
|
|
983
|
+
All environments use KMS. No private keys to provision — runtimes authenticate via
|
|
984
|
+
IAM role.
|
|
985
|
+
</div>
|
|
986
|
+
)}
|
|
987
|
+
|
|
988
|
+
<Label>Private keys</Label>
|
|
989
|
+
{Object.entries(privateKeys).map(([envName, key]) => (
|
|
990
|
+
<div
|
|
991
|
+
key={envName}
|
|
992
|
+
style={{
|
|
993
|
+
background: theme.surface,
|
|
994
|
+
border: `1px solid ${theme.border}`,
|
|
995
|
+
borderRadius: 8,
|
|
996
|
+
padding: "14px 18px",
|
|
997
|
+
marginBottom: 10,
|
|
998
|
+
}}
|
|
999
|
+
>
|
|
1000
|
+
<div
|
|
1001
|
+
style={{
|
|
1002
|
+
display: "flex",
|
|
1003
|
+
alignItems: "center",
|
|
1004
|
+
justifyContent: "space-between",
|
|
1005
|
+
marginBottom: 10,
|
|
1006
|
+
}}
|
|
1007
|
+
>
|
|
1008
|
+
<EnvBadge env={envName} />
|
|
1009
|
+
<CopyButton text={key} />
|
|
1010
|
+
</div>
|
|
1011
|
+
<div
|
|
1012
|
+
style={{
|
|
1013
|
+
fontFamily: theme.mono,
|
|
1014
|
+
fontSize: 11,
|
|
1015
|
+
color: theme.textMuted,
|
|
1016
|
+
wordBreak: "break-all",
|
|
1017
|
+
background: theme.bg,
|
|
1018
|
+
borderRadius: 4,
|
|
1019
|
+
padding: "8px 10px",
|
|
1020
|
+
}}
|
|
1021
|
+
>
|
|
1022
|
+
{key}
|
|
1023
|
+
</div>
|
|
1024
|
+
</div>
|
|
1025
|
+
))}
|
|
1026
|
+
|
|
1027
|
+
<div style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
|
|
1028
|
+
<Button
|
|
1029
|
+
variant="primary"
|
|
1030
|
+
onClick={() => {
|
|
1031
|
+
load();
|
|
1032
|
+
goList();
|
|
1033
|
+
}}
|
|
1034
|
+
>
|
|
1035
|
+
Done
|
|
1036
|
+
</Button>
|
|
1037
|
+
</div>
|
|
1038
|
+
</div>
|
|
1039
|
+
</div>
|
|
1040
|
+
</div>
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ── Create form ───────────────────────────────────────────────────────────────
|
|
1045
|
+
const namespaces = manifest?.namespaces ?? [];
|
|
1046
|
+
const environments = manifest?.environments ?? [];
|
|
1047
|
+
|
|
1048
|
+
const nameError =
|
|
1049
|
+
name.trim() && identities.some((i) => i.name === name.trim())
|
|
1050
|
+
? "A service identity with this name already exists."
|
|
1051
|
+
: "";
|
|
1052
|
+
const canSubmit =
|
|
1053
|
+
name.trim() !== "" &&
|
|
1054
|
+
!nameError &&
|
|
1055
|
+
selectedNamespaces.size > 0 &&
|
|
1056
|
+
environments.every((env) => {
|
|
1057
|
+
const cfg = envBackends[env.name];
|
|
1058
|
+
return cfg?.type === "age" || (cfg?.type === "kms" && cfg.provider && cfg.keyId.trim());
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
return (
|
|
1062
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
1063
|
+
<TopBar
|
|
1064
|
+
title="New service identity"
|
|
1065
|
+
subtitle="Scope cryptographic access to specific namespaces"
|
|
1066
|
+
actions={
|
|
1067
|
+
<button
|
|
1068
|
+
onClick={goList}
|
|
1069
|
+
style={{
|
|
1070
|
+
background: "none",
|
|
1071
|
+
border: `1px solid ${theme.borderLight}`,
|
|
1072
|
+
borderRadius: 6,
|
|
1073
|
+
padding: "4px 12px",
|
|
1074
|
+
cursor: "pointer",
|
|
1075
|
+
fontFamily: theme.sans,
|
|
1076
|
+
fontSize: 12,
|
|
1077
|
+
color: theme.textMuted,
|
|
1078
|
+
}}
|
|
1079
|
+
>
|
|
1080
|
+
{"\u2190"} Cancel
|
|
1081
|
+
</button>
|
|
1082
|
+
}
|
|
1083
|
+
/>
|
|
1084
|
+
<div style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
|
1085
|
+
<div style={{ maxWidth: 560, margin: "0 auto" }}>
|
|
1086
|
+
{createError && <ErrorBanner>{createError}</ErrorBanner>}
|
|
1087
|
+
|
|
1088
|
+
{/* Name */}
|
|
1089
|
+
<div style={{ marginBottom: 20 }}>
|
|
1090
|
+
<FieldLabel>Name</FieldLabel>
|
|
1091
|
+
<input
|
|
1092
|
+
data-testid="si-name-input"
|
|
1093
|
+
value={name}
|
|
1094
|
+
onChange={(e) => setName(e.target.value)}
|
|
1095
|
+
placeholder="e.g. api-gateway"
|
|
1096
|
+
style={inputStyle}
|
|
1097
|
+
/>
|
|
1098
|
+
{nameError && (
|
|
1099
|
+
<div
|
|
1100
|
+
style={{
|
|
1101
|
+
fontFamily: theme.sans,
|
|
1102
|
+
fontSize: 12,
|
|
1103
|
+
color: theme.red,
|
|
1104
|
+
marginTop: 6,
|
|
1105
|
+
}}
|
|
1106
|
+
>
|
|
1107
|
+
{nameError}
|
|
1108
|
+
</div>
|
|
1109
|
+
)}
|
|
1110
|
+
</div>
|
|
1111
|
+
|
|
1112
|
+
{/* Description */}
|
|
1113
|
+
<div style={{ marginBottom: 24 }}>
|
|
1114
|
+
<FieldLabel>Description (optional)</FieldLabel>
|
|
1115
|
+
<input
|
|
1116
|
+
value={description}
|
|
1117
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
1118
|
+
placeholder="e.g. API gateway service account"
|
|
1119
|
+
style={inputStyle}
|
|
1120
|
+
/>
|
|
1121
|
+
</div>
|
|
1122
|
+
|
|
1123
|
+
{/* Namespaces */}
|
|
1124
|
+
<div style={{ marginBottom: 24 }}>
|
|
1125
|
+
<FieldLabel>Namespaces</FieldLabel>
|
|
1126
|
+
<div
|
|
1127
|
+
style={{
|
|
1128
|
+
fontFamily: theme.sans,
|
|
1129
|
+
fontSize: 12,
|
|
1130
|
+
color: theme.textMuted,
|
|
1131
|
+
marginBottom: 10,
|
|
1132
|
+
}}
|
|
1133
|
+
>
|
|
1134
|
+
This identity can decrypt secrets only from the selected namespaces.
|
|
1135
|
+
</div>
|
|
1136
|
+
{namespaces.length === 0 && (
|
|
1137
|
+
<div style={{ fontFamily: theme.sans, fontSize: 12, color: theme.textDim }}>
|
|
1138
|
+
No namespaces defined in manifest.
|
|
1139
|
+
</div>
|
|
1140
|
+
)}
|
|
1141
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
1142
|
+
{namespaces.map((ns) => {
|
|
1143
|
+
const checked = selectedNamespaces.has(ns.name);
|
|
1144
|
+
return (
|
|
1145
|
+
<label
|
|
1146
|
+
key={ns.name}
|
|
1147
|
+
data-testid={`ns-checkbox-${ns.name}`}
|
|
1148
|
+
style={{
|
|
1149
|
+
display: "flex",
|
|
1150
|
+
alignItems: "center",
|
|
1151
|
+
gap: 10,
|
|
1152
|
+
padding: "10px 14px",
|
|
1153
|
+
background: checked ? theme.accentDim : theme.surface,
|
|
1154
|
+
border: `1px solid ${checked ? theme.accent + "55" : theme.border}`,
|
|
1155
|
+
borderRadius: 6,
|
|
1156
|
+
cursor: "pointer",
|
|
1157
|
+
transition: "all 0.1s",
|
|
1158
|
+
}}
|
|
1159
|
+
>
|
|
1160
|
+
<input
|
|
1161
|
+
type="checkbox"
|
|
1162
|
+
checked={checked}
|
|
1163
|
+
onChange={(e) => {
|
|
1164
|
+
const next = new Set(selectedNamespaces);
|
|
1165
|
+
if (e.target.checked) next.add(ns.name);
|
|
1166
|
+
else next.delete(ns.name);
|
|
1167
|
+
setSelectedNamespaces(next);
|
|
1168
|
+
}}
|
|
1169
|
+
style={{ accentColor: theme.accent }}
|
|
1170
|
+
/>
|
|
1171
|
+
<span
|
|
1172
|
+
style={{
|
|
1173
|
+
fontFamily: theme.mono,
|
|
1174
|
+
fontSize: 12,
|
|
1175
|
+
color: checked ? theme.accent : theme.text,
|
|
1176
|
+
}}
|
|
1177
|
+
>
|
|
1178
|
+
{ns.name}
|
|
1179
|
+
</span>
|
|
1180
|
+
{ns.description && (
|
|
1181
|
+
<span
|
|
1182
|
+
style={{ fontFamily: theme.sans, fontSize: 11, color: theme.textMuted }}
|
|
1183
|
+
>
|
|
1184
|
+
— {ns.description}
|
|
1185
|
+
</span>
|
|
1186
|
+
)}
|
|
1187
|
+
</label>
|
|
1188
|
+
);
|
|
1189
|
+
})}
|
|
1190
|
+
</div>
|
|
1191
|
+
</div>
|
|
1192
|
+
|
|
1193
|
+
{/* Per-environment backend */}
|
|
1194
|
+
<div style={{ marginBottom: 28 }}>
|
|
1195
|
+
<FieldLabel>Environment backends</FieldLabel>
|
|
1196
|
+
<div
|
|
1197
|
+
style={{
|
|
1198
|
+
fontFamily: theme.sans,
|
|
1199
|
+
fontSize: 12,
|
|
1200
|
+
color: theme.textMuted,
|
|
1201
|
+
marginBottom: 10,
|
|
1202
|
+
}}
|
|
1203
|
+
>
|
|
1204
|
+
Age generates a key pair per environment. KMS uses your cloud provider — no key
|
|
1205
|
+
material is provisioned.
|
|
1206
|
+
</div>
|
|
1207
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
1208
|
+
{environments.map((env) => {
|
|
1209
|
+
const cfg = envBackends[env.name] ?? { type: "age", provider: "aws", keyId: "" };
|
|
1210
|
+
return (
|
|
1211
|
+
<div
|
|
1212
|
+
key={env.name}
|
|
1213
|
+
style={{
|
|
1214
|
+
background: theme.surface,
|
|
1215
|
+
border: `1px solid ${theme.border}`,
|
|
1216
|
+
borderRadius: 8,
|
|
1217
|
+
padding: "14px 16px",
|
|
1218
|
+
}}
|
|
1219
|
+
>
|
|
1220
|
+
<div
|
|
1221
|
+
style={{
|
|
1222
|
+
display: "flex",
|
|
1223
|
+
alignItems: "center",
|
|
1224
|
+
justifyContent: "space-between",
|
|
1225
|
+
marginBottom: cfg.type === "kms" ? 12 : 0,
|
|
1226
|
+
}}
|
|
1227
|
+
>
|
|
1228
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
1229
|
+
<EnvBadge env={env.name} />
|
|
1230
|
+
{env.protected && (
|
|
1231
|
+
<span style={{ fontSize: 11, color: theme.red }}>{"\uD83D\uDD12"}</span>
|
|
1232
|
+
)}
|
|
1233
|
+
</div>
|
|
1234
|
+
<div style={{ display: "flex", gap: 4 }}>
|
|
1235
|
+
{(["age", "kms"] as const).map((t) => (
|
|
1236
|
+
<button
|
|
1237
|
+
key={t}
|
|
1238
|
+
onClick={() =>
|
|
1239
|
+
setEnvBackends((prev) => ({
|
|
1240
|
+
...prev,
|
|
1241
|
+
[env.name]: { ...cfg, type: t },
|
|
1242
|
+
}))
|
|
1243
|
+
}
|
|
1244
|
+
style={{
|
|
1245
|
+
background:
|
|
1246
|
+
cfg.type === t
|
|
1247
|
+
? t === "kms"
|
|
1248
|
+
? theme.purple
|
|
1249
|
+
: theme.accent
|
|
1250
|
+
: "transparent",
|
|
1251
|
+
border: `1px solid ${
|
|
1252
|
+
cfg.type === t
|
|
1253
|
+
? t === "kms"
|
|
1254
|
+
? theme.purple
|
|
1255
|
+
: theme.accent
|
|
1256
|
+
: theme.border
|
|
1257
|
+
}`,
|
|
1258
|
+
borderRadius: 4,
|
|
1259
|
+
padding: "3px 10px",
|
|
1260
|
+
cursor: "pointer",
|
|
1261
|
+
fontFamily: theme.mono,
|
|
1262
|
+
fontSize: 11,
|
|
1263
|
+
color: cfg.type === t ? "#fff" : theme.textMuted,
|
|
1264
|
+
transition: "all 0.1s",
|
|
1265
|
+
}}
|
|
1266
|
+
>
|
|
1267
|
+
{t.toUpperCase()}
|
|
1268
|
+
</button>
|
|
1269
|
+
))}
|
|
1270
|
+
</div>
|
|
1271
|
+
</div>
|
|
1272
|
+
|
|
1273
|
+
{cfg.type === "kms" && (
|
|
1274
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
1275
|
+
<select
|
|
1276
|
+
value={cfg.provider}
|
|
1277
|
+
onChange={(e) =>
|
|
1278
|
+
setEnvBackends((prev) => ({
|
|
1279
|
+
...prev,
|
|
1280
|
+
[env.name]: { ...cfg, provider: e.target.value },
|
|
1281
|
+
}))
|
|
1282
|
+
}
|
|
1283
|
+
style={{
|
|
1284
|
+
...inputStyle,
|
|
1285
|
+
width: 90,
|
|
1286
|
+
flexShrink: 0,
|
|
1287
|
+
padding: "7px 8px",
|
|
1288
|
+
}}
|
|
1289
|
+
>
|
|
1290
|
+
<option value="aws">AWS</option>
|
|
1291
|
+
<option value="gcp">GCP</option>
|
|
1292
|
+
<option value="azure">Azure</option>
|
|
1293
|
+
</select>
|
|
1294
|
+
<input
|
|
1295
|
+
value={cfg.keyId}
|
|
1296
|
+
onChange={(e) =>
|
|
1297
|
+
setEnvBackends((prev) => ({
|
|
1298
|
+
...prev,
|
|
1299
|
+
[env.name]: { ...cfg, keyId: e.target.value },
|
|
1300
|
+
}))
|
|
1301
|
+
}
|
|
1302
|
+
placeholder="arn:aws:kms:… or key resource ID"
|
|
1303
|
+
style={{ ...inputStyle, flex: 1 }}
|
|
1304
|
+
/>
|
|
1305
|
+
</div>
|
|
1306
|
+
)}
|
|
1307
|
+
</div>
|
|
1308
|
+
);
|
|
1309
|
+
})}
|
|
1310
|
+
</div>
|
|
1311
|
+
</div>
|
|
1312
|
+
|
|
1313
|
+
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
|
1314
|
+
<Button variant="ghost" onClick={goList} disabled={creating}>
|
|
1315
|
+
Cancel
|
|
1316
|
+
</Button>
|
|
1317
|
+
<Button
|
|
1318
|
+
data-testid="create-si-submit"
|
|
1319
|
+
variant="primary"
|
|
1320
|
+
onClick={handleCreate}
|
|
1321
|
+
disabled={!canSubmit || creating}
|
|
1322
|
+
>
|
|
1323
|
+
{creating ? "Creating…" : "Create identity"}
|
|
1324
|
+
</Button>
|
|
1325
|
+
</div>
|
|
1326
|
+
</div>
|
|
1327
|
+
</div>
|
|
1328
|
+
</div>
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
|
1333
|
+
|
|
1334
|
+
function Label({ children }: { children: React.ReactNode }) {
|
|
1335
|
+
return (
|
|
1336
|
+
<div
|
|
1337
|
+
style={{
|
|
1338
|
+
fontFamily: theme.sans,
|
|
1339
|
+
fontSize: 12,
|
|
1340
|
+
fontWeight: 600,
|
|
1341
|
+
color: theme.textMuted,
|
|
1342
|
+
marginBottom: 6,
|
|
1343
|
+
letterSpacing: "0.05em",
|
|
1344
|
+
textTransform: "uppercase",
|
|
1345
|
+
}}
|
|
1346
|
+
>
|
|
1347
|
+
{children}
|
|
1348
|
+
</div>
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function FieldLabel({ children }: { children: React.ReactNode }) {
|
|
1353
|
+
return (
|
|
1354
|
+
<div
|
|
1355
|
+
style={{
|
|
1356
|
+
fontFamily: theme.sans,
|
|
1357
|
+
fontSize: 12,
|
|
1358
|
+
fontWeight: 600,
|
|
1359
|
+
color: theme.textMuted,
|
|
1360
|
+
marginBottom: 6,
|
|
1361
|
+
}}
|
|
1362
|
+
>
|
|
1363
|
+
{children}
|
|
1364
|
+
</div>
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function ErrorBanner({ children }: { children: React.ReactNode }) {
|
|
1369
|
+
return (
|
|
1370
|
+
<div
|
|
1371
|
+
style={{
|
|
1372
|
+
background: theme.redDim,
|
|
1373
|
+
border: `1px solid ${theme.red}44`,
|
|
1374
|
+
borderRadius: 8,
|
|
1375
|
+
padding: "12px 16px",
|
|
1376
|
+
marginBottom: 16,
|
|
1377
|
+
fontFamily: theme.sans,
|
|
1378
|
+
fontSize: 13,
|
|
1379
|
+
color: theme.red,
|
|
1380
|
+
}}
|
|
1381
|
+
>
|
|
1382
|
+
{children}
|
|
1383
|
+
</div>
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const inputStyle: React.CSSProperties = {
|
|
1388
|
+
width: "100%",
|
|
1389
|
+
background: theme.surface,
|
|
1390
|
+
border: `1px solid ${theme.border}`,
|
|
1391
|
+
borderRadius: 6,
|
|
1392
|
+
padding: "8px 12px",
|
|
1393
|
+
fontFamily: theme.mono,
|
|
1394
|
+
fontSize: 12,
|
|
1395
|
+
color: theme.text,
|
|
1396
|
+
outline: "none",
|
|
1397
|
+
boxSizing: "border-box",
|
|
1398
|
+
};
|