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
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.
+
+
+
+
+
+
+
+
+
+
Key created — copy it now.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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);