feat: cookie-based auth, SSR dashboard, JS-optional login

This commit is contained in:
M1
2026-03-16 17:25:59 +04:00
parent 8e4cb84599
commit ef56b47b09
9 changed files with 253 additions and 163 deletions
+59 -10
View File
@@ -12,23 +12,38 @@ function hashEmail(email: string): string {
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
}
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> {
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
if (account) return { accountId: account.id, keyId: null };
const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE id = ${key}`;
if (apiKey) {
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${key}`.catch(() => {});
return { accountId: apiKey.account_id, keyId: apiKey.id };
}
return null;
}
// Exported for SSR use in dashboard route
export { resolveKey };
export function requireAuth(app: Elysia) {
return app
.derive(async ({ headers, set }) => {
const key = headers["authorization"]?.replace("Bearer ", "").trim();
.derive(async ({ headers, cookie, set }) => {
// 1. Bearer token (API clients)
const bearer = headers["authorization"]?.replace("Bearer ", "").trim();
// 2. Cookie (dashboard / SSR)
const cookieKey = cookie?.pingql_key?.value;
const key = bearer || cookieKey;
if (!key) {
set.status = 401;
return { accountId: null as string | null, keyId: null as string | null };
}
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
if (account) return { accountId: account.id as string, keyId: null as string | null };
const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE id = ${key}`;
if (apiKey) {
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${key}`.catch(() => {});
return { accountId: apiKey.account_id as string, keyId: apiKey.id as string };
}
const resolved = await resolveKey(key);
if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId };
set.status = 401;
return { accountId: null as string | null, keyId: null as string | null };
@@ -41,8 +56,42 @@ export function requireAuth(app: Elysia) {
});
}
const COOKIE_OPTS = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
maxAge: 60 * 60 * 24 * 365, // 1 year
};
export const account = new Elysia({ prefix: "/account" })
// ── Login (sets cookie) ──────────────────────────────────────────────
.post("/login", async ({ body, cookie, set }) => {
const key = (body.key as string)?.trim();
if (!key) { set.status = 400; return { error: "Key required" }; }
const resolved = await resolveKey(key);
if (!resolved) {
set.status = 401;
// If it's a form POST, redirect back with error
if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; }
return { error: "Invalid account key" };
}
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
// Form POST → redirect to dashboard
if ((body as any)._form) { set.redirect = "/dashboard/home"; return; }
return { ok: true };
}, { detail: { hide: true } })
// ── Logout ───────────────────────────────────────────────────────────
.get("/logout", ({ cookie, set }) => {
cookie.pingql_key.remove();
set.redirect = "/dashboard";
}, { detail: { hide: true } })
// ── Register ────────────────────────────────────────────────────────
.post("/register", async ({ body }) => {
const key = generateKey();
+81 -13
View File
@@ -1,31 +1,99 @@
import { Elysia } from "elysia";
import { Eta } from "eta";
import { resolve } from "path";
import { resolveKey } from "./auth";
import sql from "../db";
const eta = new Eta({ views: resolve(import.meta.dir, "../views"), cache: true, defaultExtension: ".ejs" });
function render(template: string, data: Record<string, unknown> = {}) {
function html(template: string, data: Record<string, unknown> = {}) {
return new Response(eta.render(template, data), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
// Static dashboard assets
function redirect(to: string) {
return new Response(null, { status: 302, headers: { Location: to } });
}
async function getAccountId(cookie: any, headers: any): Promise<string | null> {
const key = cookie?.pingql_key?.value || headers["authorization"]?.replace("Bearer ", "").trim();
if (!key) return null;
const resolved = await resolveKey(key);
return resolved?.accountId ?? null;
}
const dashDir = resolve(import.meta.dir, "../dashboard");
export const dashboard = new Elysia()
.get("/dashboard/app.js", () => Bun.file(`${dashDir}/app.js`))
.get("/dashboard/app.css", () => Bun.file(`${dashDir}/app.css`))
.get("/dashboard/query-builder.js",() => Bun.file(`${dashDir}/query-builder.js`))
.get("/dashboard/app.js", () => Bun.file(`${dashDir}/app.js`))
.get("/dashboard/app.css", () => Bun.file(`${dashDir}/app.css`))
.get("/dashboard/query-builder.js", () => Bun.file(`${dashDir}/query-builder.js`))
// Auth / login page (static — no nav needed)
.get("/dashboard", () => Bun.file(`${dashDir}/index.html`))
// Login page
.get("/dashboard", ({ cookie }) => {
if (cookie?.pingql_key?.value) return redirect("/dashboard/home");
return Bun.file(`${dashDir}/index.html`);
})
// Rendered pages
.get("/dashboard/home", () => render("home", { nav: "monitors" }))
.get("/dashboard/settings", () => render("settings", { nav: "settings" }))
.get("/dashboard/monitors/new", () => render("new", { nav: "monitors" }))
.get("/dashboard/monitors/:id", () => render("detail", { nav: "monitors" }))
// Logout
.get("/dashboard/logout", ({ cookie }) => {
cookie.pingql_key?.remove();
return redirect("/dashboard");
})
// Docs (static)
// Home — SSR monitor list
.get("/dashboard/home", async ({ cookie, headers }) => {
const accountId = await getAccountId(cookie, headers);
if (!accountId) return redirect("/dashboard");
const monitors = await sql`
SELECT m.*, (
SELECT row_to_json(p) FROM pings p
WHERE p.monitor_id = m.id ORDER BY p.checked_at DESC LIMIT 1
) as last_ping
FROM monitors m WHERE m.account_id = ${accountId}
ORDER BY m.created_at DESC
`;
return html("home", { nav: "monitors", monitors, accountId });
})
// Settings — SSR account info
.get("/dashboard/settings", async ({ cookie, headers }) => {
const accountId = await getAccountId(cookie, headers);
if (!accountId) return redirect("/dashboard");
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
const apiKeys = await sql`SELECT id, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
return html("settings", { nav: "settings", account: acc, apiKeys, accountId });
})
// New monitor
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
const accountId = await getAccountId(cookie, headers);
if (!accountId) return redirect("/dashboard");
return html("new", { nav: "monitors", scripts: ["/dashboard/query-builder.js"] });
})
// Monitor detail — SSR with initial data
.get("/dashboard/monitors/:id", async ({ cookie, headers, params }) => {
const accountId = await getAccountId(cookie, headers);
if (!accountId) return redirect("/dashboard");
const [monitor] = await sql`
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
`;
if (!monitor) return redirect("/dashboard/home");
const pings = await sql`
SELECT * FROM pings WHERE monitor_id = ${params.id}
ORDER BY checked_at DESC LIMIT 100
`;
return html("detail", { nav: "monitors", monitor, pings, scripts: ["/dashboard/query-builder.js"] });
})
// Docs
.get("/docs", () => Bun.file(`${dashDir}/docs.html`));
+3 -2
View File
@@ -85,8 +85,9 @@ export const ingest = new Elysia()
})
// SSE: stream live pings — auth via Bearer header
.get("/monitors/:id/stream", async ({ params, headers, error }) => {
const key = headers["authorization"]?.replace("Bearer ", "").trim();
.get("/monitors/:id/stream", async ({ params, headers, cookie, error }) => {
const key = headers["authorization"]?.replace("Bearer ", "").trim()
?? cookie?.pingql_key?.value;
if (!key) return error(401, { error: "Unauthorized" });