diff --git a/app/dashboard/[[...tab]]/page.tsx b/app/dashboard/[[...tab]]/page.tsx index 64a811e..bcad2d6 100644 --- a/app/dashboard/[[...tab]]/page.tsx +++ b/app/dashboard/[[...tab]]/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useParams } from "next/navigation"; import { copyText } from "@/lib/clipboard"; +import { formatApiKeyTime } from "@/lib/api-key-time"; import { formatStoredWebhookPayload } from "@/lib/webhook-payload"; import { filterSignupsByQuery, @@ -10,7 +11,7 @@ import { } from "@/lib/waitlist-filter"; // Tab values double as URL slugs: /dashboard/ (the default "page" tab lives at // bare /dashboard). Keep this in sync with the tab buttons below. -const TABS = ["page", "videos", "waitlist", "auctions", "webhooks", "affiliates", "dns"] as const; +const TABS = ["page", "videos", "waitlist", "auctions", "webhooks", "keys", "affiliates", "dns"] as const; type Tab = (typeof TABS)[number]; /** @@ -131,6 +132,7 @@ export default function Dashboard() { + Moshpit DNS ↗ @@ -142,6 +144,8 @@ export default function Dashboard() { say(m, false)} onOk={(m) => say(m, true)} /> ) : tab === "webhooks" ? ( say(m, false)} onOk={(m) => say(m, true)} /> + ) : tab === "keys" ? ( + say(m, false)} onOk={(m) => say(m, true)} /> ) : tab === "auctions" ? ( say(m, false)} onOk={(m) => say(m, true)} /> ) : tab === "videos" ? ( @@ -1211,6 +1215,140 @@ function AffiliatesPanel({ onError, onOk }: { onError: (m: string) => void; onOk ); } +type ApiKeyView = { + id: string; + name: string | null; + prefix: string; + created_at: string; + last_used: string | null; + revoked_at: string | null; +}; + +function ApiKeysPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (m: string) => void }) { + const [keys, setKeys] = useState(undefined); + const [loadError, setLoadError] = useState(null); + const [name, setName] = useState(""); + const [created, setCreated] = useState<{ token: string; note: string } | null>(null); + const [busy, setBusy] = useState(false); + + const load = async (): Promise => { + setLoadError(null); + setKeys(undefined); + try { + const data = await api("/api/account/keys"); + setKeys(data.keys || []); + return true; + } catch (e: any) { + const message = e.message || "Could not load API keys."; + setLoadError(message); + onError(message); + return false; + } + }; + + useEffect(() => { + void load(); + // Loading is tied to opening the panel; mutations refresh explicitly. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const create = async () => { + setBusy(true); + try { + const data = await api("/api/account/keys", "POST", { name: name.trim() || undefined }); + setCreated({ token: data.token, note: data.note }); + setName(""); + if (await load()) onOk("API key created."); + } catch (e: any) { + onError(e.message || "Could not create API key."); + } finally { + setBusy(false); + } + }; + + const revoke = async (key: ApiKeyView) => { + const label = key.name || key.prefix; + if (!window.confirm(`Revoke ${label}? Scripts using it will stop working immediately.`)) return; + setBusy(true); + try { + await api(`/api/account/keys?id=${encodeURIComponent(key.id)}`, "DELETE"); + if (await load()) onOk("API key revoked."); + } catch (e: any) { + onError(e.message || "Could not revoke API key."); + } finally { + setBusy(false); + } + }; + + return ( +
+

API keys

+

Use a key as a Bearer token from scripts and CI. New tokens are shown once and are not stored in recoverable form.

+ +
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !busy && !created) void create(); }} + /> + +
+ + {created && ( +
+ Copy this key now +

{created.note}

+ {created.token} + + + + +
+ )} + +

Your keys

+ {loadError ? ( +

Could not load API keys: {loadError}

+ ) : keys === undefined ? ( +

Loading…

+ ) : ( +
    + {keys.map((key) => ( +
  • + + {key.name || "Unnamed key"} + · {key.prefix}… · created {formatApiKeyTime(key.created_at)} + · last used {formatApiKeyTime(key.last_used)} + + {key.revoked_at ? ( + revoked {formatApiKeyTime(key.revoked_at)} + ) : ( + + )} +
  • + ))} + {keys.length === 0 &&
  • No API keys yet.
  • } +
+ )} +
+ ); +} + function ProjectWebhooks({ project, onError }: { project: Project; onError: (m: string) => void }) { const [out, setOut] = useState([]); const [inb, setInb] = useState([]); diff --git a/lib/api-key-time.ts b/lib/api-key-time.ts new file mode 100644 index 0000000..597d8c6 --- /dev/null +++ b/lib/api-key-time.ts @@ -0,0 +1,12 @@ +/** Normalize timestamps written by SQLite's datetime() as UTC ISO strings. */ +export function normalizeApiKeyTimestamp(value: string): string { + return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(value) + ? `${value.replace(" ", "T")}Z` + : value; +} + +export function formatApiKeyTime(value: string | null): string { + if (!value) return "never"; + const date = new Date(normalizeApiKeyTimestamp(value)); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} diff --git a/tests/api-key-time.test.mjs b/tests/api-key-time.test.mjs new file mode 100644 index 0000000..24c4ae7 --- /dev/null +++ b/tests/api-key-time.test.mjs @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { formatApiKeyTime, normalizeApiKeyTimestamp } from "../lib/api-key-time.ts"; + +test("SQLite API key timestamps are interpreted as UTC", () => { + assert.equal( + normalizeApiKeyTimestamp("2026-08-05 12:00:00"), + "2026-08-05T12:00:00Z", + ); + assert.equal( + normalizeApiKeyTimestamp("2026-08-05 12:00:00.123"), + "2026-08-05T12:00:00.123Z", + ); +}); + +test("qualified API key timestamps are left unchanged", () => { + assert.equal( + normalizeApiKeyTimestamp("2026-08-05T12:00:00Z"), + "2026-08-05T12:00:00Z", + ); + assert.equal( + normalizeApiKeyTimestamp("2026-08-05T12:00:00+02:00"), + "2026-08-05T12:00:00+02:00", + ); +}); + +test("missing and invalid API key timestamps remain readable", () => { + assert.equal(formatApiKeyTime(null), "never"); + assert.equal(formatApiKeyTime("not a timestamp"), "not a timestamp"); +});