diff --git a/apps/web/src/dashboard/home.html b/apps/web/src/dashboard/home.html index 1394202..fbb6ccd 100644 --- a/apps/web/src/dashboard/home.html +++ b/apps/web/src/dashboard/home.html @@ -17,6 +17,7 @@ PingQL
+ New Monitor + Settings
diff --git a/apps/web/src/dashboard/settings.html b/apps/web/src/dashboard/settings.html new file mode 100644 index 0000000..448787b --- /dev/null +++ b/apps/web/src/dashboard/settings.html @@ -0,0 +1,234 @@ + + + + + + PingQL — Settings + + + + + + + +
+ +

Settings

+ + +
+

Account

+
+
+ +
+ + +
+
+
+ +

+
+
+
+ + +
+

Recovery Email

+

Used for account recovery only. Stored as a one-way hash — we can't read it.

+
+ + + +
+ +
+ + +
+

Rotate Primary Key

+

Generates a new primary key. Your old key will stop working immediately. Sub-keys are not affected.

+ +
+ + +
+
+
+

API Keys

+

Create separate keys for different apps, scripts, or teammates.

+
+ +
+ + + + + + + + +
+

No API keys yet.

+
+
+ +
+ + + + + diff --git a/apps/web/src/db.ts b/apps/web/src/db.ts index e4abae9..14e4a84 100644 --- a/apps/web/src/db.ts +++ b/apps/web/src/db.ts @@ -42,5 +42,15 @@ export async function migrate() { await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; + await sql` + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + label TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + last_used_at TIMESTAMPTZ + ) + `; + console.log("DB ready"); } diff --git a/apps/web/src/routes/auth.ts b/apps/web/src/routes/auth.ts index 749a8d2..0abd853 100644 --- a/apps/web/src/routes/auth.ts +++ b/apps/web/src/routes/auth.ts @@ -2,34 +2,48 @@ import { Elysia, t } from "elysia"; import { randomBytes, createHash } from "crypto"; import sql from "../db"; -function generateAccountKey(): string { +function generateKey(): string { const bytes = randomBytes(8); const hex = bytes.toString("hex").toUpperCase(); return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`; } +function hashEmail(email: string): string { + return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); +} + export function requireAuth(app: Elysia) { return app.derive(async ({ headers, error }) => { const key = headers["authorization"]?.replace("Bearer ", "").trim(); if (!key) return error(401, { error: "Missing account key. Use: Authorization: Bearer " }); + // Check primary account key const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`; - if (!account) return error(401, { error: "Invalid account key" }); + if (account) { + return { accountId: account.id, keyId: null as string | null }; + } - return { accountId: account.id }; + // Check sub-key + const [apiKey] = await sql` + SELECT id, account_id FROM api_keys WHERE id = ${key} + `; + if (apiKey) { + // Update last_used_at async (don't await) + sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${key}`.catch(() => {}); + return { accountId: apiKey.account_id, keyId: apiKey.id as string }; + } + + return error(401, { error: "Invalid account key" }); }); } export const account = new Elysia({ prefix: "/account" }) - // Create account + + // ── Register ──────────────────────────────────────────────────────── .post("/register", async ({ body }) => { - const key = generateAccountKey(); - const emailHash = body.email - ? createHash("sha256").update(body.email.toLowerCase().trim()).digest("hex") - : null; - + const key = generateKey(); + const emailHash = body.email ? hashEmail(body.email) : null; await sql`INSERT INTO accounts (id, email_hash) VALUES (${key}, ${emailHash})`; - return { key, ...(body.email ? { email_registered: true } : { email_registered: false }), @@ -38,20 +52,60 @@ export const account = new Elysia({ prefix: "/account" }) body: t.Object({ email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })), }), - detail: { summary: "Create account", tags: ["account"] }, + }) + + // ── Auth-required routes below ─────────────────────────────────────── + .use(requireAuth) + + // Get account settings + .get("/settings", async ({ accountId }) => { + const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`; + const keys = await sql`SELECT id, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; + return { + account_id: acc.id, + has_email: !!acc.email_hash, + created_at: acc.created_at, + api_keys: keys, + }; }) // Update email - .use(requireAuth) .post("/email", async ({ accountId, body }) => { - const emailHash = body.email - ? createHash("sha256").update(body.email.toLowerCase().trim()).digest("hex") - : null; + const emailHash = body.email ? hashEmail(body.email) : null; await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`; return { ok: true }; }, { body: t.Object({ - email: t.Optional(t.String({ description: "Email for account recovery only." })), + email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))), }), - detail: { summary: "Update account email", tags: ["account"] }, + }) + + // Reset primary key — generates a new one, old one immediately invalid + .post("/reset-key", async ({ accountId }) => { + const newKey = generateKey(); + await sql`UPDATE accounts SET id = ${newKey} WHERE id = ${accountId}`; + return { + key: newKey, + message: "Primary key rotated. Your old key is now invalid.", + }; + }) + + // Create a sub-key (for different apps or shared access) + .post("/keys", async ({ accountId, body }) => { + const key = generateKey(); + await sql`INSERT INTO api_keys (id, account_id, label) VALUES (${key}, ${accountId}, ${body.label})`; + return { key, label: body.label }; + }, { + body: t.Object({ + label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }), + }), + }) + + // Delete a sub-key + .delete("/keys/:id", async ({ accountId, params, error }) => { + const [deleted] = await sql` + DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id + `; + if (!deleted) return error(404, { error: "Key not found" }); + return { deleted: true }; }); diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index b0dd2cb..d25b55f 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -12,4 +12,5 @@ export const dashboard = new Elysia() .get("/dashboard/home", () => Bun.file(`${dir}/home.html`), hide) .get("/dashboard/monitors/new", () => Bun.file(`${dir}/new.html`), hide) .get("/dashboard/monitors/:id", () => Bun.file(`${dir}/detail.html`), hide) + .get("/dashboard/settings", () => Bun.file(`${dir}/settings.html`), hide) .get("/docs", () => Bun.file(`${dir}/docs.html`), hide);