Skip to content

Commit b5f6bed

Browse files
Add key pin controls to the TLD dashboard (#84)
1 parent d6cfb7e commit b5f6bed

1 file changed

Lines changed: 235 additions & 3 deletions

File tree

components/MoshpitTlds.tsx

Lines changed: 235 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,33 @@ type Tld = {
1818
created_at: string;
1919
};
2020

21+
type PinKind = "tls" | "mtp";
22+
23+
type Pin = {
24+
tld: string;
25+
pin: string;
26+
kind: PinKind;
27+
note: string | null;
28+
created_at: string;
29+
};
30+
31+
type PinDraft = {
32+
kind: PinKind;
33+
pin: string;
34+
note: string;
35+
};
36+
37+
const emptyPinDraft = (): PinDraft => ({ kind: "tls", pin: "", note: "" });
38+
39+
const formatCreatedAt = (value: string) => {
40+
const sqliteUtc = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value);
41+
const date = new Date(sqliteUtc ? `${value.replace(" ", "T")}Z` : value);
42+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
43+
};
44+
45+
const tldRowId = (tld: string) => `moshpit-tld-${tld}`;
46+
const pinPanelId = (tld: string) => `moshpit-pins-${tld}`;
47+
2148
export default function MoshpitTlds() {
2249
const [tlds, setTlds] = useState<Tld[]>([]);
2350
const [loading, setLoading] = useState(true);
@@ -29,6 +56,8 @@ export default function MoshpitTlds() {
2956
const [aliasTarget, setAliasTarget] = useState<Record<string, string>>({});
3057
const [exempt, setExempt] = useState<Record<string, string[]>>({});
3158
const [exemptDraft, setExemptDraft] = useState<Record<string, string>>({});
59+
const [pins, setPins] = useState<Partial<Record<string, Pin[]>>>({});
60+
const [pinDrafts, setPinDrafts] = useState<Partial<Record<string, PinDraft>>>({});
3261

3362
const load = useCallback(async () => {
3463
setError(null);
@@ -61,6 +90,25 @@ export default function MoshpitTlds() {
6190
setExempt((prev) => ({ ...prev, [tld]: json.labels ?? json.exempt ?? [] }));
6291
}, []);
6392

93+
const loadPins = useCallback(async (tld: string) => {
94+
const key = `pins:load:${tld}`;
95+
setBusy(key);
96+
setError(null);
97+
try {
98+
const res = await fetch(`/api/moshpit/tlds/${tld}/pins`, { cache: "no-store" });
99+
const json = (await res.json().catch(() => ({}))) as { pins?: Pin[]; error?: string };
100+
if (!res.ok) {
101+
setError(json.error || `Could not load key pins (${res.status})`);
102+
return;
103+
}
104+
setPins((prev) => ({ ...prev, [tld]: json.pins ?? [] }));
105+
} catch (e) {
106+
setError(e instanceof Error ? e.message : "Could not load key pins");
107+
} finally {
108+
setBusy(null);
109+
}
110+
}, []);
111+
64112
const run = useCallback(
65113
async (key: string, fn: () => Promise<Response>, okMessage: string) => {
66114
setBusy(key);
@@ -102,6 +150,44 @@ export default function MoshpitTlds() {
102150
if (ok) setClaim("");
103151
};
104152

153+
const publishPin = async (tld: string) => {
154+
const draft = pinDrafts[tld] ?? emptyPinDraft();
155+
const pin = draft.pin.trim();
156+
if (!pin) return;
157+
158+
const ok = await run(
159+
`pin:add:${tld}`,
160+
() =>
161+
fetch(`/api/moshpit/tlds/${tld}/pins`, {
162+
method: "POST",
163+
headers: { "Content-Type": "application/json" },
164+
body: JSON.stringify({ pin, kind: draft.kind, note: draft.note.trim() }),
165+
}),
166+
`Published ${draft.kind.toUpperCase()} key for .${tld}`,
167+
);
168+
if (!ok) return;
169+
170+
setPinDrafts((prev) => ({ ...prev, [tld]: emptyPinDraft() }));
171+
await loadPins(tld);
172+
};
173+
174+
const withdrawPin = async (tld: string, entry: Pin) => {
175+
const confirmed = window.confirm(
176+
`Withdraw this ${entry.kind.toUpperCase()} key from .${tld}? Connections using it may stop working.`,
177+
);
178+
if (!confirmed) return;
179+
180+
const ok = await run(
181+
`pin:remove:${tld}:${entry.pin}`,
182+
() =>
183+
fetch(`/api/moshpit/tlds/${tld}/pins?pin=${encodeURIComponent(entry.pin)}`, {
184+
method: "DELETE",
185+
}),
186+
`Withdrew ${entry.kind.toUpperCase()} key from .${tld}`,
187+
);
188+
if (ok) await loadPins(tld);
189+
};
190+
105191
if (loading) return <section className="card2"><h2>Moshpit TLDs</h2><p className="sub">Loading…</p></section>;
106192

107193
return (
@@ -126,8 +212,8 @@ export default function MoshpitTlds() {
126212
</button>
127213
</div>
128214

129-
{error ? <p className="sub" style={{ color: "#ff6b6b" }}>{error}</p> : null}
130-
{note ? <p className="sub" style={{ color: "#5ad18c" }}>{note}</p> : null}
215+
{error ? <p className="sub" role="alert" style={{ color: "#ff6b6b" }}>{error}</p> : null}
216+
{note ? <p className="sub" role="status" aria-live="polite" style={{ color: "#5ad18c" }}>{note}</p> : null}
131217

132218
{tlds.length === 0 ? (
133219
<p className="sub">You don&apos;t hold any TLDs yet.</p>
@@ -136,8 +222,14 @@ export default function MoshpitTlds() {
136222
{tlds.map((t) => {
137223
const target = aliasTarget[t.tld] ?? "";
138224
const labels = exempt[t.tld];
225+
const publishedPins = pins[t.tld];
226+
const pinDraft = pinDrafts[t.tld] ?? emptyPinDraft();
139227
return (
140-
<li key={t.tld} style={{ borderTop: "1px solid rgba(255,255,255,.08)", padding: "12px 0" }}>
228+
<li
229+
id={tldRowId(t.tld)}
230+
key={t.tld}
231+
style={{ borderTop: "1px solid rgba(255,255,255,.08)", padding: "12px 0", scrollMarginTop: 16 }}
232+
>
141233
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
142234
<b>.{t.tld}</b>
143235
{t.alias_of ? (
@@ -192,6 +284,30 @@ export default function MoshpitTlds() {
192284
<button onClick={() => void loadExempt(t.tld)}>
193285
{labels ? "Refresh held-back names" : "Held-back names"}
194286
</button>
287+
288+
<button
289+
type="button"
290+
onClick={() => {
291+
if (publishedPins) {
292+
setPins((prev) => {
293+
const next = { ...prev };
294+
delete next[t.tld];
295+
return next;
296+
});
297+
} else {
298+
void loadPins(t.tld);
299+
}
300+
}}
301+
disabled={busy === `pins:load:${t.tld}`}
302+
aria-expanded={publishedPins !== undefined}
303+
aria-controls={pinPanelId(t.tld)}
304+
>
305+
{busy === `pins:load:${t.tld}`
306+
? "Loading keys…"
307+
: publishedPins
308+
? "Hide key pins"
309+
: "Key pins"}
310+
</button>
195311
</div>
196312

197313
{labels ? (
@@ -257,6 +373,122 @@ export default function MoshpitTlds() {
257373
</div>
258374
</div>
259375
) : null}
376+
377+
{publishedPins ? (
378+
<div id={pinPanelId(t.tld)} style={{ marginTop: 12 }}>
379+
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
380+
<p className="sub" style={{ margin: 0, flex: 1 }}>
381+
{t.alias_of
382+
? `Pins published directly on .${t.tld} protect held-back names only. Names that follow the redirect use .${t.alias_of}'s pins.`
383+
: "Publish both old and new keys during rotation, then withdraw the old one after deployment."}
384+
</p>
385+
{t.alias_of ? (
386+
<button
387+
type="button"
388+
onClick={() => {
389+
void loadPins(t.alias_of!);
390+
document
391+
.getElementById(tldRowId(t.alias_of!))
392+
?.scrollIntoView({ behavior: "smooth", block: "start" });
393+
}}
394+
disabled={busy === `pins:load:${t.alias_of}`}
395+
>
396+
Manage .{t.alias_of} pins
397+
</button>
398+
) : null}
399+
<button
400+
type="button"
401+
onClick={() => void loadPins(t.tld)}
402+
disabled={busy === `pins:load:${t.tld}`}
403+
>
404+
Refresh
405+
</button>
406+
</div>
407+
408+
{publishedPins.length === 0 ? (
409+
<p className="sub">No TLS or MTP keys published yet.</p>
410+
) : (
411+
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0" }}>
412+
{publishedPins.map((entry) => (
413+
<li
414+
key={entry.pin}
415+
style={{
416+
borderTop: "1px solid rgba(255,255,255,.08)",
417+
padding: "8px 0",
418+
}}
419+
>
420+
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
421+
<b>{entry.kind.toUpperCase()}</b>
422+
<code style={{ overflowWrap: "anywhere", flex: 1 }}>{entry.pin}</code>
423+
<span className="sub">{formatCreatedAt(entry.created_at)}</span>
424+
<button
425+
type="button"
426+
onClick={() => void withdrawPin(t.tld, entry)}
427+
disabled={busy === `pin:remove:${t.tld}:${entry.pin}`}
428+
>
429+
{busy === `pin:remove:${t.tld}:${entry.pin}` ? "Withdrawing…" : "Withdraw"}
430+
</button>
431+
</div>
432+
{entry.note ? <p className="sub" style={{ margin: "4px 0 0" }}>{entry.note}</p> : null}
433+
</li>
434+
))}
435+
</ul>
436+
)}
437+
438+
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
439+
<select
440+
value={pinDraft.kind}
441+
onChange={(e) =>
442+
setPinDrafts((prev) => ({
443+
...prev,
444+
[t.tld]: { ...pinDraft, kind: e.target.value as PinKind },
445+
}))
446+
}
447+
aria-label={`Key type for .${t.tld}`}
448+
>
449+
<option value="tls">TLS</option>
450+
<option value="mtp">MTP</option>
451+
</select>
452+
<input
453+
value={pinDraft.pin}
454+
onChange={(e) =>
455+
setPinDrafts((prev) => ({
456+
...prev,
457+
[t.tld]: { ...pinDraft, pin: e.target.value },
458+
}))
459+
}
460+
placeholder="base64 SHA-256 SPKI pin"
461+
aria-label={`Key pin for .${t.tld}`}
462+
spellCheck={false}
463+
style={{ flex: "2 1 280px" }}
464+
/>
465+
<input
466+
value={pinDraft.note}
467+
onChange={(e) =>
468+
setPinDrafts((prev) => ({
469+
...prev,
470+
[t.tld]: { ...pinDraft, note: e.target.value },
471+
}))
472+
}
473+
placeholder="note (optional)"
474+
aria-label={`Key note for .${t.tld}`}
475+
maxLength={200}
476+
style={{ flex: "1 1 180px" }}
477+
/>
478+
<button
479+
type="button"
480+
onClick={() => void publishPin(t.tld)}
481+
disabled={busy === `pin:add:${t.tld}` || !pinDraft.pin.trim()}
482+
>
483+
{busy === `pin:add:${t.tld}`
484+
? "Publishing…"
485+
: t.alias_of
486+
? "Publish held-back key"
487+
: "Publish key"}
488+
</button>
489+
</div>
490+
</div>
491+
) : null}
260492
</li>
261493
);
262494
})}

0 commit comments

Comments
 (0)