@clef-sh/ui 0.1.20 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-DPWHjBbB.js +34 -0
- package/dist/client/assets/index-qsLTYpc9.css +2 -0
- package/dist/client/clef.svg +2 -0
- package/dist/client/index.html +3 -31
- package/dist/client-lib/components/Button.d.ts +1 -1
- package/dist/client-lib/components/Button.d.ts.map +1 -1
- package/dist/client-lib/components/CopyButton.d.ts.map +1 -1
- package/dist/client-lib/components/EnvBadge.d.ts.map +1 -1
- package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -1
- package/dist/client-lib/components/Sidebar.d.ts +1 -1
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
- package/dist/client-lib/components/StatusDot.d.ts.map +1 -1
- package/dist/client-lib/components/SyncPanel.d.ts.map +1 -1
- package/dist/client-lib/components/TopBar.d.ts +6 -0
- package/dist/client-lib/components/TopBar.d.ts.map +1 -1
- package/dist/client-lib/primitives/Badge.d.ts +11 -0
- package/dist/client-lib/primitives/Badge.d.ts.map +1 -0
- package/dist/client-lib/primitives/Card.d.ts +28 -0
- package/dist/client-lib/primitives/Card.d.ts.map +1 -0
- package/dist/client-lib/primitives/Dialog.d.ts +30 -0
- package/dist/client-lib/primitives/Dialog.d.ts.map +1 -0
- package/dist/client-lib/primitives/EmptyState.d.ts +10 -0
- package/dist/client-lib/primitives/EmptyState.d.ts.map +1 -0
- package/dist/client-lib/primitives/Field.d.ts +36 -0
- package/dist/client-lib/primitives/Field.d.ts.map +1 -0
- package/dist/client-lib/primitives/Input.d.ts +6 -0
- package/dist/client-lib/primitives/Input.d.ts.map +1 -0
- package/dist/client-lib/primitives/Stat.d.ts +11 -0
- package/dist/client-lib/primitives/Stat.d.ts.map +1 -0
- package/dist/client-lib/primitives/Table.d.ts +37 -0
- package/dist/client-lib/primitives/Table.d.ts.map +1 -0
- package/dist/client-lib/primitives/Tabs.d.ts +29 -0
- package/dist/client-lib/primitives/Tabs.d.ts.map +1 -0
- package/dist/client-lib/primitives/Toast.d.ts +16 -0
- package/dist/client-lib/primitives/Toast.d.ts.map +1 -0
- package/dist/client-lib/primitives/Toolbar.d.ts +29 -0
- package/dist/client-lib/primitives/Toolbar.d.ts.map +1 -0
- package/dist/client-lib/primitives/index.d.ts +23 -0
- package/dist/client-lib/primitives/index.d.ts.map +1 -0
- package/dist/client-lib/theme.d.ts +18 -41
- package/dist/client-lib/theme.d.ts.map +1 -1
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +215 -0
- package/dist/server/api.js.map +1 -1
- package/dist/server/envelope.d.ts +15 -0
- package/dist/server/envelope.d.ts.map +1 -0
- package/dist/server/envelope.js +310 -0
- package/dist/server/envelope.js.map +1 -0
- package/package.json +7 -2
- package/src/client/App.tsx +16 -41
- package/src/client/components/Button.tsx +13 -22
- package/src/client/components/CopyButton.tsx +5 -12
- package/src/client/components/EnvBadge.tsx +30 -15
- package/src/client/components/MatrixGrid.tsx +108 -252
- package/src/client/components/Sidebar.tsx +123 -199
- package/src/client/components/StatusDot.tsx +10 -15
- package/src/client/components/SyncPanel.tsx +14 -62
- package/src/client/components/TopBar.tsx +11 -36
- package/src/client/index.html +1 -30
- package/src/client/main.tsx +1 -0
- package/src/client/primitives/Badge.test.tsx +47 -0
- package/src/client/primitives/Badge.tsx +64 -0
- package/src/client/primitives/Card.test.tsx +50 -0
- package/src/client/primitives/Card.tsx +85 -0
- package/src/client/primitives/Dialog.test.tsx +55 -0
- package/src/client/primitives/Dialog.tsx +96 -0
- package/src/client/primitives/EmptyState.test.tsx +25 -0
- package/src/client/primitives/EmptyState.tsx +38 -0
- package/src/client/primitives/Field.test.tsx +46 -0
- package/src/client/primitives/Field.tsx +95 -0
- package/src/client/primitives/Input.tsx +26 -0
- package/src/client/primitives/Stat.test.tsx +32 -0
- package/src/client/primitives/Stat.tsx +52 -0
- package/src/client/primitives/Table.test.tsx +58 -0
- package/src/client/primitives/Table.tsx +113 -0
- package/src/client/primitives/Tabs.test.tsx +44 -0
- package/src/client/primitives/Tabs.tsx +100 -0
- package/src/client/primitives/Toast.test.tsx +77 -0
- package/src/client/primitives/Toast.tsx +89 -0
- package/src/client/primitives/Toolbar.test.tsx +50 -0
- package/src/client/primitives/Toolbar.tsx +86 -0
- package/src/client/primitives/index.ts +43 -0
- package/src/client/public/clef.svg +2 -0
- package/src/client/screens/BackendScreen.tsx +104 -363
- package/src/client/screens/DiffView.tsx +187 -378
- package/src/client/screens/EnvelopeScreen.test.tsx +542 -0
- package/src/client/screens/EnvelopeScreen.tsx +948 -0
- package/src/client/screens/GitLogView.tsx +48 -106
- package/src/client/screens/ImportScreen.tsx +105 -308
- package/src/client/screens/LintView.tsx +184 -379
- package/src/client/screens/ManifestScreen.tsx +283 -445
- package/src/client/screens/MatrixView.tsx +75 -91
- package/src/client/screens/NamespaceEditor.tsx +234 -609
- package/src/client/screens/PolicyView.tsx +183 -453
- package/src/client/screens/RecipientsScreen.tsx +71 -350
- package/src/client/screens/ResetScreen.tsx +67 -237
- package/src/client/screens/ScanScreen.tsx +85 -249
- package/src/client/screens/SchemaEditor.test.tsx +237 -0
- package/src/client/screens/SchemaEditor.tsx +435 -0
- package/src/client/screens/ServiceIdentitiesScreen.tsx +251 -788
- package/src/client/styles.css +77 -0
- package/src/client/theme.ts +27 -48
- package/dist/client/assets/index-Db6WgHgY.js +0 -38
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { apiFetch } from "../api";
|
|
3
|
+
import { Toolbar } from "../primitives";
|
|
4
|
+
import { Button } from "../components/Button";
|
|
5
|
+
import { CopyButton } from "../components/CopyButton";
|
|
6
|
+
import type { DecryptResult, InspectResult, SignatureStatus, VerifyResult } from "@clef-sh/core";
|
|
7
|
+
|
|
8
|
+
// Mirrors the server-side shape from packages/ui/src/server/envelope.ts.
|
|
9
|
+
// We inline the TS type here instead of importing because the server file
|
|
10
|
+
// is not an exported package entry, and the shape is small enough that a
|
|
11
|
+
// duplicated interface is clearer than a cross-package import.
|
|
12
|
+
interface EnvelopeConfig {
|
|
13
|
+
ageIdentity: {
|
|
14
|
+
configured: boolean;
|
|
15
|
+
source: "CLEF_AGE_KEY_FILE" | "CLEF_AGE_KEY" | null;
|
|
16
|
+
path: string | null;
|
|
17
|
+
};
|
|
18
|
+
aws: { hasCredentials: boolean; profile: string | null };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Must match `formatRevealWarning` in @clef-sh/core/envelope-debug/warnings.
|
|
22
|
+
// We re-format client-side instead of calling the core helper because the
|
|
23
|
+
// warning is UI chrome, not a data contract — the test pins the literal.
|
|
24
|
+
function revealWarningText(singleKey?: string): string {
|
|
25
|
+
if (singleKey) {
|
|
26
|
+
return `value for key "${singleKey}" will be printed in this window until the reveal timer expires`;
|
|
27
|
+
}
|
|
28
|
+
return "all decrypted values will be printed in this window until the reveal timer expires";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Short on purpose: the envelope debugger is for momentary peeks during
|
|
32
|
+
// triage (paste artifact, glance at value, move on), not editing — there's
|
|
33
|
+
// no workflow that needs the value visible for minutes at a time, and a
|
|
34
|
+
// long visible window is the bigger risk on a shared screen.
|
|
35
|
+
const REVEAL_TIMEOUT_MS = 15 * 1000;
|
|
36
|
+
|
|
37
|
+
const TEXTAREA_BASE =
|
|
38
|
+
"w-full bg-ink-950 text-bone border border-edge-strong rounded-md outline-none font-mono focus-visible:border-gold-500";
|
|
39
|
+
|
|
40
|
+
export function EnvelopeScreen() {
|
|
41
|
+
// Paste + inspect
|
|
42
|
+
const [rawJson, setRawJson] = useState("");
|
|
43
|
+
const [loaded, setLoaded] = useState(false);
|
|
44
|
+
const [loading, setLoading] = useState(false);
|
|
45
|
+
const [inspect, setInspect] = useState<InspectResult | null>(null);
|
|
46
|
+
|
|
47
|
+
// Verify
|
|
48
|
+
const [signerKey, setSignerKey] = useState("");
|
|
49
|
+
const [verifyLoading, setVerifyLoading] = useState(false);
|
|
50
|
+
const [verify, setVerify] = useState<VerifyResult | null>(null);
|
|
51
|
+
|
|
52
|
+
// Decrypt
|
|
53
|
+
const [decrypt, setDecrypt] = useState<DecryptResult | null>(null);
|
|
54
|
+
const [decryptLoading, setDecryptLoading] = useState(false);
|
|
55
|
+
const [revealedKeys, setRevealedKeys] = useState<Record<string, string>>({});
|
|
56
|
+
const [revealDeadline, setRevealDeadline] = useState<number | null>(null);
|
|
57
|
+
const [now, setNow] = useState<number>(() => Date.now());
|
|
58
|
+
const revealTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
59
|
+
|
|
60
|
+
// Config
|
|
61
|
+
const [config, setConfig] = useState<EnvelopeConfig | null>(null);
|
|
62
|
+
|
|
63
|
+
// Bring up the server-side config on mount so the Decrypt card knows what
|
|
64
|
+
// identity is available before the user even pastes.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
apiFetch("/api/envelope/config")
|
|
67
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
68
|
+
.then((data) => setConfig(data))
|
|
69
|
+
.catch(() => setConfig(null));
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// Drive the countdown banner. Interval only runs while a reveal is active.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!revealDeadline) return;
|
|
75
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
76
|
+
return () => clearInterval(id);
|
|
77
|
+
}, [revealDeadline]);
|
|
78
|
+
|
|
79
|
+
// Clean up the auto-clear timer on unmount.
|
|
80
|
+
useEffect(() => () => clearTimeout(revealTimeoutRef.current), []);
|
|
81
|
+
|
|
82
|
+
const scheduleAutoClear = useCallback(() => {
|
|
83
|
+
clearTimeout(revealTimeoutRef.current);
|
|
84
|
+
const deadline = Date.now() + REVEAL_TIMEOUT_MS;
|
|
85
|
+
setRevealDeadline(deadline);
|
|
86
|
+
setNow(Date.now());
|
|
87
|
+
revealTimeoutRef.current = setTimeout(() => {
|
|
88
|
+
setRevealedKeys({});
|
|
89
|
+
setRevealDeadline(null);
|
|
90
|
+
}, REVEAL_TIMEOUT_MS);
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const resetEnvelopeState = useCallback(() => {
|
|
94
|
+
clearTimeout(revealTimeoutRef.current);
|
|
95
|
+
setInspect(null);
|
|
96
|
+
setVerify(null);
|
|
97
|
+
setDecrypt(null);
|
|
98
|
+
setRevealedKeys({});
|
|
99
|
+
setRevealDeadline(null);
|
|
100
|
+
setSignerKey("");
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const handleLoad = useCallback(async () => {
|
|
104
|
+
if (!rawJson.trim()) return;
|
|
105
|
+
setLoading(true);
|
|
106
|
+
resetEnvelopeState();
|
|
107
|
+
try {
|
|
108
|
+
const res = await apiFetch("/api/envelope/inspect", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: JSON.stringify({ raw: rawJson }),
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
setInspect(null);
|
|
115
|
+
setLoaded(false);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const data = (await res.json()) as InspectResult;
|
|
119
|
+
setInspect(data);
|
|
120
|
+
setLoaded(!data.error);
|
|
121
|
+
} catch {
|
|
122
|
+
setInspect(null);
|
|
123
|
+
setLoaded(false);
|
|
124
|
+
} finally {
|
|
125
|
+
setLoading(false);
|
|
126
|
+
}
|
|
127
|
+
}, [rawJson, resetEnvelopeState]);
|
|
128
|
+
|
|
129
|
+
const handleVerify = useCallback(async () => {
|
|
130
|
+
if (!loaded) return;
|
|
131
|
+
setVerifyLoading(true);
|
|
132
|
+
try {
|
|
133
|
+
const res = await apiFetch("/api/envelope/verify", {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify({ raw: rawJson, signerKey: signerKey || undefined }),
|
|
137
|
+
});
|
|
138
|
+
const data = (await res.json()) as VerifyResult;
|
|
139
|
+
setVerify(data);
|
|
140
|
+
} catch {
|
|
141
|
+
setVerify(null);
|
|
142
|
+
} finally {
|
|
143
|
+
setVerifyLoading(false);
|
|
144
|
+
}
|
|
145
|
+
}, [loaded, rawJson, signerKey]);
|
|
146
|
+
|
|
147
|
+
const handleDecryptKeys = useCallback(async () => {
|
|
148
|
+
if (!loaded) return;
|
|
149
|
+
setDecryptLoading(true);
|
|
150
|
+
try {
|
|
151
|
+
const res = await apiFetch("/api/envelope/decrypt", {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify({ raw: rawJson }),
|
|
155
|
+
});
|
|
156
|
+
const data = (await res.json()) as DecryptResult;
|
|
157
|
+
setDecrypt(data);
|
|
158
|
+
setRevealedKeys({});
|
|
159
|
+
setRevealDeadline(null);
|
|
160
|
+
} catch {
|
|
161
|
+
setDecrypt(null);
|
|
162
|
+
} finally {
|
|
163
|
+
setDecryptLoading(false);
|
|
164
|
+
}
|
|
165
|
+
}, [loaded, rawJson]);
|
|
166
|
+
|
|
167
|
+
const handleRevealAll = useCallback(async () => {
|
|
168
|
+
if (!loaded) return;
|
|
169
|
+
setDecryptLoading(true);
|
|
170
|
+
try {
|
|
171
|
+
const res = await apiFetch("/api/envelope/decrypt", {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "Content-Type": "application/json" },
|
|
174
|
+
body: JSON.stringify({ raw: rawJson, reveal: true }),
|
|
175
|
+
});
|
|
176
|
+
const data = (await res.json()) as DecryptResult;
|
|
177
|
+
setDecrypt(data);
|
|
178
|
+
if (data.values) {
|
|
179
|
+
setRevealedKeys(data.values);
|
|
180
|
+
scheduleAutoClear();
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Keep previous state — network failure shouldn't clear prior keys list.
|
|
184
|
+
} finally {
|
|
185
|
+
setDecryptLoading(false);
|
|
186
|
+
}
|
|
187
|
+
}, [loaded, rawJson, scheduleAutoClear]);
|
|
188
|
+
|
|
189
|
+
const handleRevealOne = useCallback(
|
|
190
|
+
async (key: string) => {
|
|
191
|
+
if (!loaded) return;
|
|
192
|
+
try {
|
|
193
|
+
const res = await apiFetch("/api/envelope/decrypt", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
body: JSON.stringify({ raw: rawJson, key }),
|
|
197
|
+
});
|
|
198
|
+
const data = (await res.json()) as DecryptResult;
|
|
199
|
+
if (!data.error && data.values && key in data.values) {
|
|
200
|
+
setRevealedKeys((prev) => ({ ...prev, [key]: data.values![key] }));
|
|
201
|
+
scheduleAutoClear();
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// Swallow — row stays hidden on failure.
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[loaded, rawJson, scheduleAutoClear],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const handleHideOne = useCallback((key: string) => {
|
|
211
|
+
setRevealedKeys((prev) => {
|
|
212
|
+
const next = { ...prev };
|
|
213
|
+
delete next[key];
|
|
214
|
+
return next;
|
|
215
|
+
});
|
|
216
|
+
}, []);
|
|
217
|
+
|
|
218
|
+
const handleExportJson = useCallback(() => {
|
|
219
|
+
// Client-side download — server never touches the filesystem for this.
|
|
220
|
+
const blob = new Blob([JSON.stringify(revealedKeys, null, 2)], {
|
|
221
|
+
type: "application/json",
|
|
222
|
+
});
|
|
223
|
+
const url = URL.createObjectURL(blob);
|
|
224
|
+
const a = document.createElement("a");
|
|
225
|
+
a.href = url;
|
|
226
|
+
a.download = "envelope-revealed.json";
|
|
227
|
+
a.click();
|
|
228
|
+
URL.revokeObjectURL(url);
|
|
229
|
+
}, [revealedKeys]);
|
|
230
|
+
|
|
231
|
+
const anyRevealed = Object.keys(revealedKeys).length > 0;
|
|
232
|
+
const singleKeyRevealed =
|
|
233
|
+
Object.keys(revealedKeys).length === 1 ? Object.keys(revealedKeys)[0] : undefined;
|
|
234
|
+
|
|
235
|
+
const countdownMs = revealDeadline ? Math.max(0, revealDeadline - now) : 0;
|
|
236
|
+
const countdown = useMemo(() => formatCountdown(countdownMs), [countdownMs]);
|
|
237
|
+
|
|
238
|
+
const rawSnapshot = useMemo(() => {
|
|
239
|
+
const parts: Record<string, unknown> = {};
|
|
240
|
+
if (inspect) parts.inspect = inspect;
|
|
241
|
+
if (verify) parts.verify = verify;
|
|
242
|
+
if (decrypt) parts.decrypt = decrypt;
|
|
243
|
+
return Object.keys(parts).length > 0 ? JSON.stringify(parts, null, 2) : null;
|
|
244
|
+
}, [inspect, verify, decrypt]);
|
|
245
|
+
|
|
246
|
+
const [showRawJson, setShowRawJson] = useState(false);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div className="flex flex-1 flex-col overflow-hidden">
|
|
250
|
+
<Toolbar>
|
|
251
|
+
<div>
|
|
252
|
+
<Toolbar.Title>Envelope Debugger</Toolbar.Title>
|
|
253
|
+
<Toolbar.Subtitle>paste a packed artifact — inspect, verify, decrypt</Toolbar.Subtitle>
|
|
254
|
+
</div>
|
|
255
|
+
{rawSnapshot ? (
|
|
256
|
+
<Toolbar.Actions>
|
|
257
|
+
<Button onClick={() => setShowRawJson((s) => !s)}>
|
|
258
|
+
{showRawJson ? "Hide raw JSON" : "View raw JSON"}
|
|
259
|
+
</Button>
|
|
260
|
+
</Toolbar.Actions>
|
|
261
|
+
) : null}
|
|
262
|
+
</Toolbar>
|
|
263
|
+
|
|
264
|
+
<div
|
|
265
|
+
className="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-3.5"
|
|
266
|
+
data-testid="envelope-screen"
|
|
267
|
+
>
|
|
268
|
+
<PasteArea
|
|
269
|
+
rawJson={rawJson}
|
|
270
|
+
setRawJson={setRawJson}
|
|
271
|
+
onLoad={handleLoad}
|
|
272
|
+
loading={loading}
|
|
273
|
+
/>
|
|
274
|
+
|
|
275
|
+
{inspect && <InspectCard result={inspect} />}
|
|
276
|
+
|
|
277
|
+
{loaded && inspect && !inspect.error && (
|
|
278
|
+
<VerifyCard
|
|
279
|
+
signerKey={signerKey}
|
|
280
|
+
setSignerKey={setSignerKey}
|
|
281
|
+
onVerify={handleVerify}
|
|
282
|
+
loading={verifyLoading}
|
|
283
|
+
result={verify}
|
|
284
|
+
signaturePresent={inspect.signature.present}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{loaded && inspect && !inspect.error && (
|
|
289
|
+
<DecryptCard
|
|
290
|
+
config={config}
|
|
291
|
+
result={decrypt}
|
|
292
|
+
loading={decryptLoading}
|
|
293
|
+
onDecryptKeys={handleDecryptKeys}
|
|
294
|
+
onRevealAll={handleRevealAll}
|
|
295
|
+
onRevealOne={handleRevealOne}
|
|
296
|
+
onHideOne={handleHideOne}
|
|
297
|
+
onExportJson={handleExportJson}
|
|
298
|
+
revealedKeys={revealedKeys}
|
|
299
|
+
anyRevealed={anyRevealed}
|
|
300
|
+
singleKeyRevealed={singleKeyRevealed}
|
|
301
|
+
countdown={countdown}
|
|
302
|
+
/>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
{showRawJson && rawSnapshot && (
|
|
306
|
+
<pre
|
|
307
|
+
data-testid="raw-json"
|
|
308
|
+
className="font-mono text-[11px] bg-ink-850 border border-edge rounded-lg p-3.5 text-ash max-h-[320px] overflow-y-auto"
|
|
309
|
+
>
|
|
310
|
+
{rawSnapshot}
|
|
311
|
+
</pre>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
319
|
+
// PasteArea
|
|
320
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
interface PasteAreaProps {
|
|
323
|
+
rawJson: string;
|
|
324
|
+
setRawJson: (s: string) => void;
|
|
325
|
+
onLoad: () => void;
|
|
326
|
+
loading: boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function PasteArea({ rawJson, setRawJson, onLoad, loading }: PasteAreaProps) {
|
|
330
|
+
// Client-side shape validation — cheap feedback before the round-trip.
|
|
331
|
+
const parseState = useMemo(() => {
|
|
332
|
+
if (!rawJson.trim()) return { state: "empty" as const };
|
|
333
|
+
try {
|
|
334
|
+
JSON.parse(rawJson);
|
|
335
|
+
const bytes = new Blob([rawJson]).size;
|
|
336
|
+
return { state: "valid" as const, bytes };
|
|
337
|
+
} catch {
|
|
338
|
+
return { state: "invalid" as const };
|
|
339
|
+
}
|
|
340
|
+
}, [rawJson]);
|
|
341
|
+
|
|
342
|
+
const statusColor =
|
|
343
|
+
parseState.state === "invalid"
|
|
344
|
+
? "text-stop-500"
|
|
345
|
+
: parseState.state === "valid"
|
|
346
|
+
? "text-go-500"
|
|
347
|
+
: "text-ash";
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<Card title="Paste" subtitle="paste a packed envelope JSON">
|
|
351
|
+
<textarea
|
|
352
|
+
data-testid="envelope-paste-textarea"
|
|
353
|
+
value={rawJson}
|
|
354
|
+
onChange={(e) => setRawJson(e.target.value)}
|
|
355
|
+
placeholder={'{ "version": 1, "identity": "...", ... }'}
|
|
356
|
+
spellCheck={false}
|
|
357
|
+
className={`${TEXTAREA_BASE} min-h-[120px] max-h-[280px] resize-y text-[12px] p-2.5`}
|
|
358
|
+
/>
|
|
359
|
+
<div className="flex items-center justify-between mt-2.5">
|
|
360
|
+
<span className={`font-mono text-[11px] ${statusColor}`} data-testid="paste-status">
|
|
361
|
+
{parseState.state === "valid"
|
|
362
|
+
? `✓ valid (${formatBytes(parseState.bytes)})`
|
|
363
|
+
: parseState.state === "invalid"
|
|
364
|
+
? "✕ invalid JSON"
|
|
365
|
+
: "paste to begin"}
|
|
366
|
+
</span>
|
|
367
|
+
<Button
|
|
368
|
+
variant="primary"
|
|
369
|
+
onClick={onLoad}
|
|
370
|
+
disabled={parseState.state !== "valid" || loading}
|
|
371
|
+
>
|
|
372
|
+
{loading ? "Loading…" : "Load"}
|
|
373
|
+
</Button>
|
|
374
|
+
</div>
|
|
375
|
+
</Card>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
380
|
+
// InspectCard
|
|
381
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
interface InspectCardProps {
|
|
384
|
+
result: InspectResult;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function InspectCard({ result }: InspectCardProps) {
|
|
388
|
+
if (result.error) {
|
|
389
|
+
return (
|
|
390
|
+
<Card title="Inspect" tone="error">
|
|
391
|
+
<ErrorRow code={result.error.code} message={result.error.message} />
|
|
392
|
+
</Card>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// The type union admits nulls for error cases. We've already early-returned
|
|
397
|
+
// above on result.error, so the rest of the fields are present in practice
|
|
398
|
+
// — null coalesce at each site to keep TS happy rather than assert.
|
|
399
|
+
const hash = result.ciphertextHash ?? "";
|
|
400
|
+
const envelope = result.envelope;
|
|
401
|
+
const rows: [string, React.ReactNode][] = [
|
|
402
|
+
["version", <Mono key="v">{result.version === null ? "?" : String(result.version)}</Mono>],
|
|
403
|
+
["identity", <Mono key="i">{result.identity ?? "?"}</Mono>],
|
|
404
|
+
["environment", <Mono key="e">{result.environment ?? "?"}</Mono>],
|
|
405
|
+
[
|
|
406
|
+
"packedAt",
|
|
407
|
+
<Mono key="p">
|
|
408
|
+
{result.packedAt ?? "?"}
|
|
409
|
+
{result.packedAtAgeMs !== null ? ` (${formatAge(result.packedAtAgeMs)})` : ""}
|
|
410
|
+
</Mono>,
|
|
411
|
+
],
|
|
412
|
+
["revision", <Mono key="r">{result.revision ?? "?"}</Mono>],
|
|
413
|
+
[
|
|
414
|
+
"ciphertextHash",
|
|
415
|
+
<span key="ch" className="flex items-center gap-2">
|
|
416
|
+
<Mono>{hash ? shortHash(hash) : "?"}</Mono>
|
|
417
|
+
<StatusPill
|
|
418
|
+
tone={
|
|
419
|
+
result.ciphertextHashVerified === true
|
|
420
|
+
? "ok"
|
|
421
|
+
: result.ciphertextHashVerified === false
|
|
422
|
+
? "fail"
|
|
423
|
+
: "muted"
|
|
424
|
+
}
|
|
425
|
+
label={
|
|
426
|
+
result.ciphertextHashVerified === true
|
|
427
|
+
? "verified"
|
|
428
|
+
: result.ciphertextHashVerified === false
|
|
429
|
+
? "MISMATCH"
|
|
430
|
+
: "not checked"
|
|
431
|
+
}
|
|
432
|
+
/>
|
|
433
|
+
{hash && <CopyButton text={hash} />}
|
|
434
|
+
</span>,
|
|
435
|
+
],
|
|
436
|
+
[
|
|
437
|
+
"ciphertext bytes",
|
|
438
|
+
<Mono key="cb">
|
|
439
|
+
{result.ciphertextBytes === null ? "?" : String(result.ciphertextBytes)}
|
|
440
|
+
</Mono>,
|
|
441
|
+
],
|
|
442
|
+
[
|
|
443
|
+
"envelope",
|
|
444
|
+
<Mono key="env">
|
|
445
|
+
{envelope ? envelope.provider : "?"}
|
|
446
|
+
{envelope && envelope.kms ? ` · ${envelope.kms.keyId}` : ""}
|
|
447
|
+
</Mono>,
|
|
448
|
+
],
|
|
449
|
+
[
|
|
450
|
+
"signature",
|
|
451
|
+
<span key="sig" className="flex items-center gap-2">
|
|
452
|
+
{result.signature.present ? (
|
|
453
|
+
<>
|
|
454
|
+
<Mono>{result.signature.algorithm ?? "unknown"}</Mono>
|
|
455
|
+
<StatusPill tone="muted" label="run verify to check" />
|
|
456
|
+
</>
|
|
457
|
+
) : (
|
|
458
|
+
<StatusPill tone="muted" label="absent" />
|
|
459
|
+
)}
|
|
460
|
+
</span>,
|
|
461
|
+
],
|
|
462
|
+
[
|
|
463
|
+
"expiry",
|
|
464
|
+
result.expiresAt ? (
|
|
465
|
+
<span key="ex" className="flex items-center gap-2">
|
|
466
|
+
<Mono>{result.expiresAt}</Mono>
|
|
467
|
+
<StatusPill
|
|
468
|
+
tone={result.expired ? "fail" : "ok"}
|
|
469
|
+
label={result.expired ? "EXPIRED" : "ok"}
|
|
470
|
+
/>
|
|
471
|
+
</span>
|
|
472
|
+
) : (
|
|
473
|
+
<Mono key="ex">none</Mono>
|
|
474
|
+
),
|
|
475
|
+
],
|
|
476
|
+
[
|
|
477
|
+
"revocation",
|
|
478
|
+
result.revoked ? (
|
|
479
|
+
<StatusPill key="rv" tone="fail" label={`REVOKED at ${result.revokedAt ?? "?"}`} />
|
|
480
|
+
) : (
|
|
481
|
+
<Mono key="rv">none</Mono>
|
|
482
|
+
),
|
|
483
|
+
],
|
|
484
|
+
];
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<Card title="Inspect" subtitle="auto-populates from the pasted JSON">
|
|
488
|
+
<div className="flex flex-col gap-1.5">
|
|
489
|
+
{rows.map(([label, value]) => (
|
|
490
|
+
<KeyValueRow key={label} label={label} value={value} />
|
|
491
|
+
))}
|
|
492
|
+
</div>
|
|
493
|
+
</Card>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
498
|
+
// VerifyCard
|
|
499
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
interface VerifyCardProps {
|
|
502
|
+
signerKey: string;
|
|
503
|
+
setSignerKey: (s: string) => void;
|
|
504
|
+
onVerify: () => void;
|
|
505
|
+
loading: boolean;
|
|
506
|
+
result: VerifyResult | null;
|
|
507
|
+
signaturePresent: boolean;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function VerifyCard({
|
|
511
|
+
signerKey,
|
|
512
|
+
setSignerKey,
|
|
513
|
+
onVerify,
|
|
514
|
+
loading,
|
|
515
|
+
result,
|
|
516
|
+
signaturePresent,
|
|
517
|
+
}: VerifyCardProps) {
|
|
518
|
+
const subtitle = signaturePresent
|
|
519
|
+
? "paste the signer public key (PEM or base64 DER SPKI) to verify the signature"
|
|
520
|
+
: "no signature on this artifact — verify only checks hash / expiry / revocation";
|
|
521
|
+
|
|
522
|
+
const overallClasses =
|
|
523
|
+
result?.overall === "pass"
|
|
524
|
+
? "bg-go-500/15 text-go-500"
|
|
525
|
+
: result?.overall === "fail"
|
|
526
|
+
? "bg-stop-500/10 text-stop-500"
|
|
527
|
+
: "bg-edge text-ash";
|
|
528
|
+
|
|
529
|
+
return (
|
|
530
|
+
<Card title="Verify" subtitle={subtitle}>
|
|
531
|
+
{signaturePresent && (
|
|
532
|
+
<textarea
|
|
533
|
+
data-testid="envelope-signer-key"
|
|
534
|
+
value={signerKey}
|
|
535
|
+
onChange={(e) => setSignerKey(e.target.value)}
|
|
536
|
+
placeholder={"-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"}
|
|
537
|
+
spellCheck={false}
|
|
538
|
+
className={`${TEXTAREA_BASE} min-h-[72px] max-h-[160px] resize-y text-[11px] p-2 mb-2.5`}
|
|
539
|
+
/>
|
|
540
|
+
)}
|
|
541
|
+
<div className="flex justify-end mb-2.5">
|
|
542
|
+
<Button variant="primary" onClick={onVerify} disabled={loading}>
|
|
543
|
+
{loading ? "Verifying…" : "Run verify"}
|
|
544
|
+
</Button>
|
|
545
|
+
</div>
|
|
546
|
+
{result?.error && <ErrorRow code={result.error.code} message={result.error.message} />}
|
|
547
|
+
{result && !result.error && (
|
|
548
|
+
<div className="flex flex-col gap-1.5">
|
|
549
|
+
<CheckRow
|
|
550
|
+
label="ciphertext hash"
|
|
551
|
+
status={result.checks.hash.status === "ok" ? "pass" : "fail"}
|
|
552
|
+
detail={result.checks.hash.status}
|
|
553
|
+
/>
|
|
554
|
+
<CheckRow
|
|
555
|
+
label="signature"
|
|
556
|
+
status={mapSignatureStatus(result.checks.signature.status)}
|
|
557
|
+
detail={`${result.checks.signature.status}${
|
|
558
|
+
result.checks.signature.algorithm ? ` (${result.checks.signature.algorithm})` : ""
|
|
559
|
+
}`}
|
|
560
|
+
/>
|
|
561
|
+
<CheckRow
|
|
562
|
+
label="expiry"
|
|
563
|
+
status={
|
|
564
|
+
result.checks.expiry.status === "expired"
|
|
565
|
+
? "fail"
|
|
566
|
+
: result.checks.expiry.status === "absent"
|
|
567
|
+
? "muted"
|
|
568
|
+
: "pass"
|
|
569
|
+
}
|
|
570
|
+
detail={result.checks.expiry.expiresAt ?? "no expiry"}
|
|
571
|
+
/>
|
|
572
|
+
<CheckRow
|
|
573
|
+
label="revocation"
|
|
574
|
+
status={result.checks.revocation.status === "revoked" ? "fail" : "muted"}
|
|
575
|
+
detail={result.checks.revocation.revokedAt ?? "not revoked"}
|
|
576
|
+
/>
|
|
577
|
+
<div
|
|
578
|
+
className={`mt-1.5 px-3 py-2 rounded-md font-sans text-[12px] font-bold tracking-[0.05em] ${overallClasses}`}
|
|
579
|
+
data-testid="verify-overall"
|
|
580
|
+
>
|
|
581
|
+
OVERALL: {result.overall.toUpperCase()}
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
)}
|
|
585
|
+
</Card>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
590
|
+
// DecryptCard
|
|
591
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
interface DecryptCardProps {
|
|
594
|
+
config: EnvelopeConfig | null;
|
|
595
|
+
result: DecryptResult | null;
|
|
596
|
+
loading: boolean;
|
|
597
|
+
onDecryptKeys: () => void;
|
|
598
|
+
onRevealAll: () => void;
|
|
599
|
+
onRevealOne: (key: string) => void;
|
|
600
|
+
onHideOne: (key: string) => void;
|
|
601
|
+
onExportJson: () => void;
|
|
602
|
+
revealedKeys: Record<string, string>;
|
|
603
|
+
anyRevealed: boolean;
|
|
604
|
+
singleKeyRevealed: string | undefined;
|
|
605
|
+
countdown: string;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function DecryptCard({
|
|
609
|
+
config,
|
|
610
|
+
result,
|
|
611
|
+
loading,
|
|
612
|
+
onDecryptKeys,
|
|
613
|
+
onRevealAll,
|
|
614
|
+
onRevealOne,
|
|
615
|
+
onHideOne,
|
|
616
|
+
onExportJson,
|
|
617
|
+
revealedKeys,
|
|
618
|
+
anyRevealed,
|
|
619
|
+
singleKeyRevealed,
|
|
620
|
+
countdown,
|
|
621
|
+
}: DecryptCardProps) {
|
|
622
|
+
const identityConfigured = config?.ageIdentity.configured === true;
|
|
623
|
+
const identityInline = identityConfigured && config!.ageIdentity.source === "CLEF_AGE_KEY";
|
|
624
|
+
const identityLabel = identityConfigured
|
|
625
|
+
? config!.ageIdentity.source === "CLEF_AGE_KEY_FILE"
|
|
626
|
+
? `$CLEF_AGE_KEY_FILE · ${config!.ageIdentity.path ?? ""}`
|
|
627
|
+
: "$CLEF_AGE_KEY (inline)"
|
|
628
|
+
: "no identity configured on server — relaunch clef ui with CLEF_AGE_KEY_FILE set";
|
|
629
|
+
|
|
630
|
+
// Subtitle spells out the invariant that bit the operator the first time:
|
|
631
|
+
// the envelope must be encrypted for whichever key the server is using,
|
|
632
|
+
// which is usually a service identity's key, not the operator's own.
|
|
633
|
+
const subtitle = identityConfigured
|
|
634
|
+
? `Decrypting with ${identityLabel}. The pasted envelope must be encrypted for this key — usually a service identity's age key, not your personal one.`
|
|
635
|
+
: identityLabel;
|
|
636
|
+
|
|
637
|
+
return (
|
|
638
|
+
<Card title="Decrypt" subtitle={subtitle}>
|
|
639
|
+
{identityInline && (
|
|
640
|
+
<div
|
|
641
|
+
data-testid="inline-key-warning"
|
|
642
|
+
className="mb-3 px-3 py-2 rounded-md bg-warn-500/15 border border-warn-500/40 text-warn-500 font-sans text-[12px] leading-relaxed"
|
|
643
|
+
>
|
|
644
|
+
{"⚠ "} This key was passed inline via <code>$CLEF_AGE_KEY</code>, which lands the secret
|
|
645
|
+
in your shell history (<code>~/.zsh_history</code>, <code>~/.bash_history</code>) and in{" "}
|
|
646
|
+
<code>ps aux</code> while the process runs. Prefer pointing at a file:{" "}
|
|
647
|
+
<code>CLEF_AGE_KEY_FILE=/path/to/key clef ui</code>. Rotate the current key if it may
|
|
648
|
+
already have been captured.
|
|
649
|
+
</div>
|
|
650
|
+
)}
|
|
651
|
+
{result?.error && (
|
|
652
|
+
<ErrorRow
|
|
653
|
+
code={result.error.code}
|
|
654
|
+
message={result.error.message}
|
|
655
|
+
hint={decryptErrorHint(result.error, identityConfigured)}
|
|
656
|
+
/>
|
|
657
|
+
)}
|
|
658
|
+
|
|
659
|
+
{!result && (
|
|
660
|
+
<div className="flex justify-end">
|
|
661
|
+
<Button
|
|
662
|
+
variant="primary"
|
|
663
|
+
onClick={onDecryptKeys}
|
|
664
|
+
disabled={loading || !identityConfigured}
|
|
665
|
+
data-testid="decrypt-keys"
|
|
666
|
+
>
|
|
667
|
+
{loading ? "Decrypting…" : "Decrypt (keys)"}
|
|
668
|
+
</Button>
|
|
669
|
+
</div>
|
|
670
|
+
)}
|
|
671
|
+
|
|
672
|
+
{result && !result.error && (
|
|
673
|
+
<>
|
|
674
|
+
{anyRevealed && (
|
|
675
|
+
<div
|
|
676
|
+
data-testid="reveal-banner"
|
|
677
|
+
className="mb-3 px-3 py-2.5 rounded-md bg-warn-500/15 border border-warn-500/40 text-warn-500 font-sans text-[12px] flex items-center justify-between gap-2.5"
|
|
678
|
+
>
|
|
679
|
+
<span>
|
|
680
|
+
{"⚠ "} {revealWarningText(singleKeyRevealed)}
|
|
681
|
+
</span>
|
|
682
|
+
<span className="font-mono text-[11px]" data-testid="reveal-countdown">
|
|
683
|
+
auto-clears in {countdown}
|
|
684
|
+
</span>
|
|
685
|
+
</div>
|
|
686
|
+
)}
|
|
687
|
+
|
|
688
|
+
<div className="flex flex-col gap-1.5">
|
|
689
|
+
{result.keys.map((k) => {
|
|
690
|
+
const revealed = Object.prototype.hasOwnProperty.call(revealedKeys, k);
|
|
691
|
+
return (
|
|
692
|
+
<div
|
|
693
|
+
key={k}
|
|
694
|
+
data-testid={`decrypt-row-${k}`}
|
|
695
|
+
className="flex items-center gap-2.5 px-2.5 py-2 bg-ink-950 border border-edge rounded-md"
|
|
696
|
+
>
|
|
697
|
+
<span className="font-mono text-[12px] text-bone basis-[200px] shrink-0 grow-0 overflow-hidden text-ellipsis">
|
|
698
|
+
{k}
|
|
699
|
+
</span>
|
|
700
|
+
<span
|
|
701
|
+
className={`font-mono text-[12px] flex-1 overflow-hidden text-ellipsis whitespace-nowrap ${
|
|
702
|
+
revealed ? "text-bone" : "text-ash-dim"
|
|
703
|
+
}`}
|
|
704
|
+
data-testid={`decrypt-value-${k}`}
|
|
705
|
+
>
|
|
706
|
+
{revealed ? revealedKeys[k] : "●".repeat(10)}
|
|
707
|
+
</span>
|
|
708
|
+
<button
|
|
709
|
+
data-testid={`reveal-toggle-${k}`}
|
|
710
|
+
onClick={() => (revealed ? onHideOne(k) : onRevealOne(k))}
|
|
711
|
+
className="bg-transparent border border-edge-strong rounded text-ash font-mono text-[11px] px-2 py-0.5 cursor-pointer"
|
|
712
|
+
>
|
|
713
|
+
{revealed ? "hide" : "reveal"}
|
|
714
|
+
</button>
|
|
715
|
+
{revealed && <CopyButton text={revealedKeys[k]} />}
|
|
716
|
+
</div>
|
|
717
|
+
);
|
|
718
|
+
})}
|
|
719
|
+
</div>
|
|
720
|
+
|
|
721
|
+
<div className="mt-3.5 flex justify-between gap-2">
|
|
722
|
+
<Button onClick={onDecryptKeys} disabled={loading}>
|
|
723
|
+
{"↻"} Re-fetch keys
|
|
724
|
+
</Button>
|
|
725
|
+
<div className="flex gap-2">
|
|
726
|
+
{anyRevealed && (
|
|
727
|
+
<Button onClick={onExportJson} data-testid="export-json">
|
|
728
|
+
Export JSON
|
|
729
|
+
</Button>
|
|
730
|
+
)}
|
|
731
|
+
<Button
|
|
732
|
+
variant="primary"
|
|
733
|
+
onClick={onRevealAll}
|
|
734
|
+
disabled={loading}
|
|
735
|
+
data-testid="reveal-all"
|
|
736
|
+
>
|
|
737
|
+
Reveal all
|
|
738
|
+
</Button>
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
</>
|
|
742
|
+
)}
|
|
743
|
+
</Card>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
748
|
+
// Shared primitives
|
|
749
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
interface CardProps {
|
|
752
|
+
title: string;
|
|
753
|
+
subtitle?: string;
|
|
754
|
+
tone?: "default" | "error";
|
|
755
|
+
children: React.ReactNode;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function Card({ title, subtitle, tone = "default", children }: CardProps) {
|
|
759
|
+
const borderClasses = tone === "error" ? "border-stop-500/40" : "border-edge";
|
|
760
|
+
return (
|
|
761
|
+
<div
|
|
762
|
+
className={`bg-ink-850 border rounded-lg p-4 ${borderClasses}`}
|
|
763
|
+
data-testid={`envelope-card-${title.toLowerCase()}`}
|
|
764
|
+
>
|
|
765
|
+
<div className="mb-2.5">
|
|
766
|
+
<div className="font-sans text-[13px] font-bold text-bone">{title}</div>
|
|
767
|
+
{subtitle && <div className="font-mono text-[10px] text-ash mt-0.5">{subtitle}</div>}
|
|
768
|
+
</div>
|
|
769
|
+
{children}
|
|
770
|
+
</div>
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function KeyValueRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|
775
|
+
return (
|
|
776
|
+
<div className="flex items-center gap-2.5">
|
|
777
|
+
<span className="font-mono text-[11px] text-ash basis-[140px] shrink-0 grow-0">{label}</span>
|
|
778
|
+
<span className="flex-1 min-w-0 text-[12px]">{value}</span>
|
|
779
|
+
</div>
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function CheckRow({
|
|
784
|
+
label,
|
|
785
|
+
status,
|
|
786
|
+
detail,
|
|
787
|
+
}: {
|
|
788
|
+
label: string;
|
|
789
|
+
status: "pass" | "fail" | "warn" | "muted";
|
|
790
|
+
detail: string;
|
|
791
|
+
}) {
|
|
792
|
+
const toneColor =
|
|
793
|
+
status === "pass"
|
|
794
|
+
? "text-go-500"
|
|
795
|
+
: status === "fail"
|
|
796
|
+
? "text-stop-500"
|
|
797
|
+
: status === "warn"
|
|
798
|
+
? "text-warn-500"
|
|
799
|
+
: "text-ash";
|
|
800
|
+
const icon = status === "pass" ? "✓" : status === "fail" ? "✕" : "·";
|
|
801
|
+
return (
|
|
802
|
+
<div
|
|
803
|
+
className="flex items-center gap-2.5 px-2.5 py-1.5 bg-ink-950 border border-edge rounded-md"
|
|
804
|
+
data-testid={`verify-row-${label.replace(/\s+/g, "-")}`}
|
|
805
|
+
>
|
|
806
|
+
<span className={`font-mono font-bold w-3 ${toneColor}`}>{icon}</span>
|
|
807
|
+
<span className="font-sans text-[12px] text-bone basis-[140px] shrink-0 grow-0">{label}</span>
|
|
808
|
+
<span className="font-mono text-[11px] text-ash flex-1">{detail}</span>
|
|
809
|
+
</div>
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function StatusPill({ tone, label }: { tone: "ok" | "fail" | "warn" | "muted"; label: string }) {
|
|
814
|
+
const toneClasses =
|
|
815
|
+
tone === "ok"
|
|
816
|
+
? "text-go-500 bg-go-500/15 border-go-500/40"
|
|
817
|
+
: tone === "fail"
|
|
818
|
+
? "text-stop-500 bg-stop-500/10 border-stop-500/40"
|
|
819
|
+
: tone === "warn"
|
|
820
|
+
? "text-warn-500 bg-warn-500/15 border-warn-500/40"
|
|
821
|
+
: "text-ash bg-ash/10 border-ash/30";
|
|
822
|
+
return (
|
|
823
|
+
<span
|
|
824
|
+
className={`font-mono text-[10px] font-bold border rounded-sm px-1.5 py-px ${toneClasses}`}
|
|
825
|
+
>
|
|
826
|
+
{label}
|
|
827
|
+
</span>
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
interface ErrorHint {
|
|
832
|
+
title: string;
|
|
833
|
+
commands: string[];
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function ErrorRow({ code, message, hint }: { code: string; message: string; hint?: ErrorHint }) {
|
|
837
|
+
return (
|
|
838
|
+
<div
|
|
839
|
+
role="alert"
|
|
840
|
+
data-testid="envelope-error"
|
|
841
|
+
className="px-3 py-2.5 bg-stop-500/10 border border-stop-500/40 rounded-md font-mono text-[11px] text-stop-500"
|
|
842
|
+
>
|
|
843
|
+
<div>
|
|
844
|
+
<strong>{code}</strong> {"—"} {message}
|
|
845
|
+
</div>
|
|
846
|
+
{hint && (
|
|
847
|
+
<div
|
|
848
|
+
data-testid="envelope-error-hint"
|
|
849
|
+
className="mt-2.5 pt-2.5 border-t border-stop-500/30 text-bone font-sans text-[12px] leading-relaxed"
|
|
850
|
+
>
|
|
851
|
+
<div className="mb-2">{hint.title}</div>
|
|
852
|
+
<pre className="m-0 px-2.5 py-2 bg-ink-950 border border-edge rounded text-gold-500 font-mono text-[11px] whitespace-pre-wrap break-all">
|
|
853
|
+
{hint.commands.join("\n")}
|
|
854
|
+
</pre>
|
|
855
|
+
</div>
|
|
856
|
+
)}
|
|
857
|
+
</div>
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Pick a hint block for a decrypt error. Targets the two most common
|
|
863
|
+
* support questions:
|
|
864
|
+
* - "no key configured at all" (key_resolution_failed)
|
|
865
|
+
* - "server has a key but it's not the right one for this envelope"
|
|
866
|
+
* (decrypt_failed with an age-encryption "no identity matched" message)
|
|
867
|
+
*
|
|
868
|
+
* Detection of the second case is string-sniffing on the age library's
|
|
869
|
+
* error text — brittle if the library changes its wording, but the worst
|
|
870
|
+
* case is that the hint quietly doesn't render and the raw error shows.
|
|
871
|
+
*/
|
|
872
|
+
function decryptErrorHint(
|
|
873
|
+
error: { code: string; message: string },
|
|
874
|
+
identityConfigured: boolean,
|
|
875
|
+
): ErrorHint | undefined {
|
|
876
|
+
if (error.code === "key_resolution_failed") {
|
|
877
|
+
return {
|
|
878
|
+
title:
|
|
879
|
+
"No age key on the server. Stop this server (Ctrl-C) and relaunch clef ui pointing at a key file:",
|
|
880
|
+
commands: [
|
|
881
|
+
"CLEF_AGE_KEY_FILE=/path/to/your-key.txt clef ui",
|
|
882
|
+
"# avoid CLEF_AGE_KEY='AGE-SECRET-KEY-...' — the key ends up in shell history",
|
|
883
|
+
],
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
if (error.code === "decrypt_failed" && /no identity matched/i.test(error.message)) {
|
|
887
|
+
return {
|
|
888
|
+
title: identityConfigured
|
|
889
|
+
? "The server's age key isn't one of this envelope's recipients. This usually means the envelope was packed for a service identity — relaunch clef ui with that identity's key:"
|
|
890
|
+
: "This envelope's recipients don't include any key on the server. Relaunch clef ui with the matching age key:",
|
|
891
|
+
commands: [
|
|
892
|
+
"# find the service identity's private key and launch with it",
|
|
893
|
+
"CLEF_AGE_KEY_FILE=/path/to/service-identity.key clef ui",
|
|
894
|
+
],
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function Mono({ children }: { children: React.ReactNode }) {
|
|
901
|
+
return <span className="font-mono text-[12px] text-bone">{children}</span>;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
905
|
+
// Helpers
|
|
906
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
function mapSignatureStatus(status: SignatureStatus): "pass" | "fail" | "warn" | "muted" {
|
|
909
|
+
switch (status) {
|
|
910
|
+
case "valid":
|
|
911
|
+
return "pass";
|
|
912
|
+
case "invalid":
|
|
913
|
+
return "fail";
|
|
914
|
+
case "not_verified":
|
|
915
|
+
return "warn";
|
|
916
|
+
default:
|
|
917
|
+
return "muted";
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function formatAge(ms: number): string {
|
|
922
|
+
const seconds = Math.floor(ms / 1000);
|
|
923
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
924
|
+
const minutes = Math.floor(seconds / 60);
|
|
925
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
926
|
+
const hours = Math.floor(minutes / 60);
|
|
927
|
+
if (hours < 24) return `${hours}h ago`;
|
|
928
|
+
const days = Math.floor(hours / 24);
|
|
929
|
+
return `${days}d ago`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function formatBytes(n: number): string {
|
|
933
|
+
if (n < 1024) return `${n} B`;
|
|
934
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
935
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function shortHash(h: string): string {
|
|
939
|
+
if (h.length <= 16) return h;
|
|
940
|
+
return `${h.slice(0, 8)}…${h.slice(-8)}`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function formatCountdown(ms: number): string {
|
|
944
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
945
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
946
|
+
const seconds = totalSeconds % 60;
|
|
947
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
948
|
+
}
|