Skip to content

Commit 04e9920

Browse files
Manage API keys from the dashboard (#108)
1 parent d223819 commit 04e9920

3 files changed

Lines changed: 182 additions & 1 deletion

File tree

app/dashboard/[[...tab]]/page.tsx

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useEffect, useState, useCallback, useRef } from "react";
33
import { useParams } from "next/navigation";
44
import { copyText } from "@/lib/clipboard";
5+
import { formatApiKeyTime } from "@/lib/api-key-time";
56
import { formatStoredWebhookPayload } from "@/lib/webhook-payload";
67
import {
78
filterSignupsByQuery,
@@ -11,7 +12,7 @@ import {
1112
} from "@/lib/waitlist-filter";
1213
// Tab values double as URL slugs: /dashboard/<tab> (the default "page" tab lives at
1314
// bare /dashboard). Keep this in sync with the tab buttons below.
14-
const TABS = ["page", "videos", "waitlist", "auctions", "webhooks", "affiliates", "dns"] as const;
15+
const TABS = ["page", "videos", "waitlist", "auctions", "webhooks", "keys", "affiliates", "dns"] as const;
1516
type Tab = (typeof TABS)[number];
1617

1718
/**
@@ -132,6 +133,7 @@ export default function Dashboard() {
132133
<button className={`tab${tab === "waitlist" ? " on" : ""}`} onClick={() => setTab("waitlist")}>Waitlist</button>
133134
<button className={`tab${tab === "auctions" ? " on" : ""}`} onClick={() => setTab("auctions")}>Auctions</button>
134135
<button className={`tab${tab === "webhooks" ? " on" : ""}`} onClick={() => setTab("webhooks")}>Webhooks</button>
136+
<button className={`tab${tab === "keys" ? " on" : ""}`} onClick={() => setTab("keys")}>API keys</button>
135137
<button className={`tab${tab === "affiliates" ? " on" : ""}`} onClick={() => setTab("affiliates")}>Affiliates</button>
136138
<button className={`tab${tab === "dns" ? " on" : ""}`} onClick={() => setTab("dns")}>Custom domain</button>
137139
<a className="tab" href={PIT_RECORDS_URL}>Moshpit DNS ↗</a>
@@ -143,6 +145,8 @@ export default function Dashboard() {
143145
<AffiliatesPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
144146
) : tab === "webhooks" ? (
145147
<DomainWebhooksPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
148+
) : tab === "keys" ? (
149+
<ApiKeysPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
146150
) : tab === "auctions" ? (
147151
<AuctionsPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
148152
) : tab === "videos" ? (
@@ -1234,6 +1238,140 @@ function AffiliatesPanel({ onError, onOk }: { onError: (m: string) => void; onOk
12341238
);
12351239
}
12361240

1241+
type ApiKeyView = {
1242+
id: string;
1243+
name: string | null;
1244+
prefix: string;
1245+
created_at: string;
1246+
last_used: string | null;
1247+
revoked_at: string | null;
1248+
};
1249+
1250+
function ApiKeysPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (m: string) => void }) {
1251+
const [keys, setKeys] = useState<ApiKeyView[] | undefined>(undefined);
1252+
const [loadError, setLoadError] = useState<string | null>(null);
1253+
const [name, setName] = useState("");
1254+
const [created, setCreated] = useState<{ token: string; note: string } | null>(null);
1255+
const [busy, setBusy] = useState(false);
1256+
1257+
const load = async (): Promise<boolean> => {
1258+
setLoadError(null);
1259+
setKeys(undefined);
1260+
try {
1261+
const data = await api("/api/account/keys");
1262+
setKeys(data.keys || []);
1263+
return true;
1264+
} catch (e: any) {
1265+
const message = e.message || "Could not load API keys.";
1266+
setLoadError(message);
1267+
onError(message);
1268+
return false;
1269+
}
1270+
};
1271+
1272+
useEffect(() => {
1273+
void load();
1274+
// Loading is tied to opening the panel; mutations refresh explicitly.
1275+
// eslint-disable-next-line react-hooks/exhaustive-deps
1276+
}, []);
1277+
1278+
const create = async () => {
1279+
setBusy(true);
1280+
try {
1281+
const data = await api("/api/account/keys", "POST", { name: name.trim() || undefined });
1282+
setCreated({ token: data.token, note: data.note });
1283+
setName("");
1284+
if (await load()) onOk("API key created.");
1285+
} catch (e: any) {
1286+
onError(e.message || "Could not create API key.");
1287+
} finally {
1288+
setBusy(false);
1289+
}
1290+
};
1291+
1292+
const revoke = async (key: ApiKeyView) => {
1293+
const label = key.name || key.prefix;
1294+
if (!window.confirm(`Revoke ${label}? Scripts using it will stop working immediately.`)) return;
1295+
setBusy(true);
1296+
try {
1297+
await api(`/api/account/keys?id=${encodeURIComponent(key.id)}`, "DELETE");
1298+
if (await load()) onOk("API key revoked.");
1299+
} catch (e: any) {
1300+
onError(e.message || "Could not revoke API key.");
1301+
} finally {
1302+
setBusy(false);
1303+
}
1304+
};
1305+
1306+
return (
1307+
<section className="card2">
1308+
<h2>API keys</h2>
1309+
<p className="sub">Use a key as a Bearer token from scripts and CI. New tokens are shown once and are not stored in recoverable form.</p>
1310+
1311+
<div className="row">
1312+
<input
1313+
className="inp"
1314+
maxLength={80}
1315+
placeholder="key name — e.g. deploy workflow"
1316+
value={name}
1317+
disabled={busy || Boolean(created)}
1318+
onChange={(e) => setName(e.target.value)}
1319+
onKeyDown={(e) => { if (e.key === "Enter" && !busy && !created) void create(); }}
1320+
/>
1321+
<button type="button" className="btn2" disabled={busy || Boolean(created)} onClick={() => void create()}>
1322+
{busy ? "Working…" : "Create key"}
1323+
</button>
1324+
</div>
1325+
1326+
{created && (
1327+
<div className="secret" role="status" style={{ marginTop: 14 }}>
1328+
<b>Copy this key now</b>
1329+
<p className="sub" style={{ margin: "6px 0" }}>{created.note}</p>
1330+
<code style={{ display: "block", overflowWrap: "anywhere", marginBottom: 10 }}>{created.token}</code>
1331+
<span className="row-actions">
1332+
<button
1333+
type="button"
1334+
className="btn2"
1335+
onClick={async () => {
1336+
if (await copyText(created.token)) onOk("API key copied.");
1337+
else onError("Copy failed — select the key and copy it manually.");
1338+
}}
1339+
>
1340+
Copy key
1341+
</button>
1342+
<button type="button" className="btn2 ghost" onClick={() => setCreated(null)}>Dismiss</button>
1343+
</span>
1344+
</div>
1345+
)}
1346+
1347+
<h3 className="ed-h">Your keys</h3>
1348+
{loadError ? (
1349+
<p className="sub" role="alert">Could not load API keys: {loadError}</p>
1350+
) : keys === undefined ? (
1351+
<p className="sub">Loading…</p>
1352+
) : (
1353+
<ul className="list">
1354+
{keys.map((key) => (
1355+
<li key={key.id}>
1356+
<span>
1357+
<b>{key.name || "Unnamed key"}</b>
1358+
<span className="muted"> · {key.prefix}… · created {formatApiKeyTime(key.created_at)}</span>
1359+
<span className="muted"> · last used {formatApiKeyTime(key.last_used)}</span>
1360+
</span>
1361+
{key.revoked_at ? (
1362+
<span className="muted">revoked {formatApiKeyTime(key.revoked_at)}</span>
1363+
) : (
1364+
<button type="button" className="btn2 ghost" disabled={busy} onClick={() => void revoke(key)}>Revoke</button>
1365+
)}
1366+
</li>
1367+
))}
1368+
{keys.length === 0 && <li className="muted">No API keys yet.</li>}
1369+
</ul>
1370+
)}
1371+
</section>
1372+
);
1373+
}
1374+
12371375
function ProjectWebhooks({ project, onError }: { project: Project; onError: (m: string) => void }) {
12381376
const [out, setOut] = useState<any[]>([]);
12391377
const [inb, setInb] = useState<any[]>([]);

lib/api-key-time.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/** Normalize timestamps written by SQLite's datetime() as UTC ISO strings. */
2+
export function normalizeApiKeyTimestamp(value: string): string {
3+
return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(value)
4+
? `${value.replace(" ", "T")}Z`
5+
: value;
6+
}
7+
8+
export function formatApiKeyTime(value: string | null): string {
9+
if (!value) return "never";
10+
const date = new Date(normalizeApiKeyTimestamp(value));
11+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
12+
}

tests/api-key-time.test.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { formatApiKeyTime, normalizeApiKeyTimestamp } from "../lib/api-key-time.ts";
5+
6+
test("SQLite API key timestamps are interpreted as UTC", () => {
7+
assert.equal(
8+
normalizeApiKeyTimestamp("2026-08-05 12:00:00"),
9+
"2026-08-05T12:00:00Z",
10+
);
11+
assert.equal(
12+
normalizeApiKeyTimestamp("2026-08-05 12:00:00.123"),
13+
"2026-08-05T12:00:00.123Z",
14+
);
15+
});
16+
17+
test("qualified API key timestamps are left unchanged", () => {
18+
assert.equal(
19+
normalizeApiKeyTimestamp("2026-08-05T12:00:00Z"),
20+
"2026-08-05T12:00:00Z",
21+
);
22+
assert.equal(
23+
normalizeApiKeyTimestamp("2026-08-05T12:00:00+02:00"),
24+
"2026-08-05T12:00:00+02:00",
25+
);
26+
});
27+
28+
test("missing and invalid API key timestamps remain readable", () => {
29+
assert.equal(formatApiKeyTime(null), "never");
30+
assert.equal(formatApiKeyTime("not a timestamp"), "not a timestamp");
31+
});

0 commit comments

Comments
 (0)