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

View File

@ -1,41 +1,27 @@
// PingQL Dashboard — shared utilities // PingQL Dashboard — shared utilities
// Auth is now cookie-based. No localStorage needed.
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
function getAccountKey() {
return localStorage.getItem('pingql_key');
}
function setAccountKey(key) {
localStorage.setItem('pingql_key', key);
}
function logout() { function logout() {
localStorage.removeItem('pingql_key'); window.location.href = '/dashboard/logout';
window.location.href = '/dashboard';
} }
function requireAuth() { // requireAuth is a no-op now — server redirects to /dashboard if not authed
if (!getAccountKey()) { function requireAuth() { return true; }
window.location.href = '/dashboard';
return false;
}
return true;
}
async function api(path, opts = {}) { async function api(path, opts = {}) {
const key = getAccountKey();
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
...opts, ...opts,
credentials: 'same-origin', // send cookie automatically
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(key ? { Authorization: `Bearer ${key}` } : {}),
...opts.headers, ...opts.headers,
}, },
body: opts.body ? JSON.stringify(opts.body) : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined,
}); });
if (res.status === 401) { if (res.status === 401) {
logout(); window.location.href = '/dashboard';
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
const data = await res.json(); const data = await res.json();
@ -97,15 +83,12 @@ function escapeHtml(str) {
// Subscribe to live ping updates for a monitor via SSE (fetch-based for auth header support) // Subscribe to live ping updates for a monitor via SSE (fetch-based for auth header support)
// Returns an AbortController — call .abort() to close // Returns an AbortController — call .abort() to close
function watchMonitor(monitorId, onPing) { function watchMonitor(monitorId, onPing) {
const key = localStorage.getItem('pingql_key');
if (!key) return null;
const ac = new AbortController(); const ac = new AbortController();
async function connect() { async function connect() {
try { try {
const res = await fetch(`/monitors/${monitorId}/stream`, { const res = await fetch(`/monitors/${monitorId}/stream`, {
headers: { Authorization: `Bearer ${key}` }, credentials: 'same-origin',
signal: ac.signal, signal: ac.signal,
}); });
if (!res.ok || !res.body) return; if (!res.ok || !res.body) return;

View File

@ -3,13 +3,9 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PingQL — Login</title> <title>PingQL — Sign In</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <link rel="stylesheet" href="/dashboard/app.css">
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
.glow { box-shadow: 0 0 40px rgba(59, 130, 246, 0.08); }
.key-display { letter-spacing: 0.15em; }
</style>
</head> </head>
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center p-4"> <body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md"> <div class="w-full max-w-md">
@ -18,17 +14,23 @@
<p class="text-gray-500 text-sm mt-2">Uptime monitoring for developers</p> <p class="text-gray-500 text-sm mt-2">Uptime monitoring for developers</p>
</div> </div>
<!-- Login form --> <div class="bg-gray-900 rounded-xl p-6 border border-gray-800" style="box-shadow:0 0 40px rgba(59,130,246,0.08)">
<div id="screen-login" class="bg-gray-900 rounded-xl p-6 glow border border-gray-800">
<!-- Sign in form — works with or without JS -->
<div id="screen-login">
<form id="login-form" action="/account/login" method="POST">
<input type="hidden" name="_form" value="1">
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Account Key</label> <label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Account Key</label>
<input id="key-input" type="text" placeholder="XXXX-XXXX-XXXX-XXXX" <input id="key-input" name="key" type="text" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" spellcheck="false"
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 key-display text-center text-lg" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 tracking-widest text-center text-lg font-mono"
maxlength="19" autocomplete="off" spellping="false"> maxlength="19">
<button id="login-btn" <button type="submit"
class="w-full mt-3 bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors"> class="w-full mt-3 bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
Sign In Sign In
</button> </button>
<div id="login-error" class="text-red-400 text-sm mt-3 text-center hidden"></div> <div id="login-error" class="text-red-400 text-sm mt-3 text-center hidden"></div>
</form>
<div class="mt-6 pt-5 border-t border-gray-800 text-center"> <div class="mt-6 pt-5 border-t border-gray-800 text-center">
<p class="text-gray-500 text-sm mb-3">No account?</p> <p class="text-gray-500 text-sm mb-3">No account?</p>
<button id="register-btn" <button id="register-btn"
@ -38,38 +40,34 @@
</div> </div>
</div> </div>
<!-- Post-registration screen: show key + optional email --> <!-- Post-registration: show new key -->
<div id="screen-new-account" class="hidden"> <div id="screen-new-account" class="hidden">
<div class="bg-gray-900 rounded-xl p-6 glow border border-gray-800">
<div class="flex items-center gap-3 mb-5"> <div class="flex items-center gap-3 mb-5">
<div class="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400 text-lg"></div> <div class="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400 text-lg"></div>
<div> <div>
<p class="font-semibold text-white">Account created</p> <p class="font-semibold text-white">Account created</p>
<p class="text-xs text-gray-500">Save your key — it's the only way to log in</p> <p class="text-xs text-gray-500">Save your key — it's how you access your account</p>
</div> </div>
</div> </div>
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Your Account Key</label> <label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Your Account Key</label>
<div class="flex gap-2"> <div class="flex gap-2 mb-5">
<div id="new-key-display" <div id="new-key-display"
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-blue-400 key-display text-center text-lg font-bold select-all"></div> class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-blue-400 tracking-widest text-center text-lg font-bold font-mono select-all"></div>
<button id="copy-key-btn" <button id="copy-key-btn"
class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors text-sm"> class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors text-sm">
Copy Copy
</button> </button>
</div> </div>
<div class="pt-5 border-t border-gray-800">
<!-- Optional email --> <label class="block text-xs text-gray-500 uppercase tracking-wider mb-1">Email <span class="text-gray-600 normal-case">(optional — recovery only)</span></label>
<div class="mt-6 pt-5 border-t border-gray-800">
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-1">Email <span class="text-gray-600 normal-case">(optional)</span></label>
<p class="text-xs text-gray-600 mb-3">Used for account recovery only. Never shared.</p>
<input id="email-input" type="email" placeholder="you@example.com" <input id="email-input" type="email" placeholder="you@example.com"
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm"> class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm mb-3">
<div class="flex gap-2 mt-3"> <div class="flex gap-2">
<button id="save-email-btn" <button id="save-email-btn"
class="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors text-sm"> class="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors text-sm">
Save Email & Continue Save & Continue
</button> </button>
<button id="skip-email-btn" <button id="skip-email-btn"
class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-400 rounded-lg transition-colors text-sm"> class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-400 rounded-lg transition-colors text-sm">
@ -86,86 +84,68 @@
const API = ''; const API = '';
let newKey = null; let newKey = null;
// Already logged in? // Auto-format key input
if (localStorage.getItem('pingql_key')) {
window.location.href = '/dashboard/home';
}
// --- Login ---
const keyInput = document.getElementById('key-input'); const keyInput = document.getElementById('key-input');
document.getElementById('login-btn').addEventListener('click', login); if (keyInput) {
keyInput.addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
keyInput.addEventListener('input', e => { keyInput.addEventListener('input', e => {
let v = e.target.value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 16); let v = e.target.value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 16);
const parts = v.match(/.{1,4}/g) || []; e.target.value = (v.match(/.{1,4}/g) || []).join('-');
e.target.value = parts.join('-');
}); });
async function login() {
const key = keyInput.value.trim();
if (key.length < 19) return showError('Enter a valid account key');
setLoading('login-btn', true, 'Verifying...');
try {
const res = await fetch(`${API}/monitors/`, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) return showError('Invalid account key');
localStorage.setItem('pingql_key', key);
window.location.href = '/dashboard/home';
} catch { showError('Connection error'); }
finally { setLoading('login-btn', false, 'Sign In'); }
} }
// --- Register --- // JS-enhanced login (overrides form POST for better UX)
document.getElementById('register-btn').addEventListener('click', async () => { document.getElementById('login-form').addEventListener('submit', async (e) => {
setLoading('register-btn', true, 'Creating...'); e.preventDefault();
const key = keyInput.value.trim();
if (key.length < 19) return showError('Enter a valid account key');
try { try {
const res = await fetch(`${API}/account/register`, { const res = await fetch('/account/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), body: JSON.stringify({ key }),
});
if (!res.ok) return showError('Invalid account key');
window.location.href = '/dashboard/home';
} catch { showError('Connection error'); }
});
// Register
document.getElementById('register-btn').addEventListener('click', async () => {
try {
const res = await fetch('/account/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok || !data.key) return showError(data.error || 'Failed to create account'); if (!res.ok || !data.key) return showError(data.error || 'Failed');
newKey = data.key; newKey = data.key;
localStorage.setItem('pingql_key', newKey);
// Switch to new account screen // Set cookie via login endpoint
await fetch('/account/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: newKey }),
});
document.getElementById('screen-login').classList.add('hidden'); document.getElementById('screen-login').classList.add('hidden');
document.getElementById('screen-new-account').classList.remove('hidden'); document.getElementById('screen-new-account').classList.remove('hidden');
document.getElementById('new-key-display').textContent = newKey; document.getElementById('new-key-display').textContent = newKey;
} catch { showError('Connection error'); } } catch { showError('Connection error'); }
finally { setLoading('register-btn', false, 'Create Account'); }
}); });
// --- Copy key ---
document.getElementById('copy-key-btn').addEventListener('click', () => { document.getElementById('copy-key-btn').addEventListener('click', () => {
navigator.clipboard.writeText(newKey).then(() => { navigator.clipboard.writeText(newKey).then(() => {
const btn = document.getElementById('copy-key-btn'); const btn = document.getElementById('copy-key-btn');
btn.textContent = 'Copied!'; btn.textContent = 'Copied!'; btn.classList.add('text-green-400');
btn.classList.add('text-green-400');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 2000); setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 2000);
}); });
}); });
// --- Save email ---
document.getElementById('save-email-btn').addEventListener('click', async () => { document.getElementById('save-email-btn').addEventListener('click', async () => {
const email = document.getElementById('email-input').value.trim(); const email = document.getElementById('email-input').value.trim();
if (!email) return document.getElementById('skip-email-btn').click(); if (!email) { document.getElementById('skip-email-btn').click(); return; }
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
document.getElementById('email-error').textContent = 'Enter a valid email address';
document.getElementById('email-error').classList.remove('hidden');
return;
}
setLoading('save-email-btn', true, 'Saving...');
try { try {
await fetch(`${API}/account/email`, { await fetch('/account/email', {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${newKey}` },
body: JSON.stringify({ email }),
}); });
} catch { /* non-critical, continue anyway */ } } catch {}
finally { setLoading('save-email-btn', false, 'Save Email & Continue'); }
window.location.href = '/dashboard/home'; window.location.href = '/dashboard/home';
}); });
@ -173,17 +153,9 @@
window.location.href = '/dashboard/home'; window.location.href = '/dashboard/home';
}); });
// --- Helpers ---
function showError(msg) { function showError(msg) {
const el = document.getElementById('login-error'); const el = document.getElementById('login-error');
el.textContent = msg; el.textContent = msg; el.classList.remove('hidden');
el.classList.remove('hidden');
}
function setLoading(id, loading, label) {
const btn = document.getElementById(id);
btn.disabled = loading;
btn.textContent = label;
} }
</script> </script>
</body> </body>

View File

@ -12,23 +12,38 @@ function hashEmail(email: string): string {
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); 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) { export function requireAuth(app: Elysia) {
return app return app
.derive(async ({ headers, set }) => { .derive(async ({ headers, cookie, set }) => {
const key = headers["authorization"]?.replace("Bearer ", "").trim(); // 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) { if (!key) {
set.status = 401; set.status = 401;
return { accountId: null as string | null, keyId: null as string | null }; return { accountId: null as string | null, keyId: null as string | null };
} }
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`; const resolved = await resolveKey(key);
if (account) return { accountId: account.id as string, keyId: null as string | null }; if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId };
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 };
}
set.status = 401; set.status = 401;
return { accountId: null as string | null, keyId: null as string | null }; 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" }) 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 ──────────────────────────────────────────────────────── // ── Register ────────────────────────────────────────────────────────
.post("/register", async ({ body }) => { .post("/register", async ({ body }) => {
const key = generateKey(); const key = generateKey();

View File

@ -1,31 +1,99 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { Eta } from "eta"; import { Eta } from "eta";
import { resolve } from "path"; 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" }); 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), { return new Response(eta.render(template, data), {
headers: { "content-type": "text/html; charset=utf-8" }, 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"); const dashDir = resolve(import.meta.dir, "../dashboard");
export const dashboard = new Elysia() export const dashboard = new Elysia()
.get("/dashboard/app.js", () => Bun.file(`${dashDir}/app.js`)) .get("/dashboard/app.js", () => Bun.file(`${dashDir}/app.js`))
.get("/dashboard/app.css", () => Bun.file(`${dashDir}/app.css`)) .get("/dashboard/app.css", () => Bun.file(`${dashDir}/app.css`))
.get("/dashboard/query-builder.js",() => Bun.file(`${dashDir}/query-builder.js`)) .get("/dashboard/query-builder.js", () => Bun.file(`${dashDir}/query-builder.js`))
// Auth / login page (static — no nav needed) // Login page
.get("/dashboard", () => Bun.file(`${dashDir}/index.html`)) .get("/dashboard", ({ cookie }) => {
if (cookie?.pingql_key?.value) return redirect("/dashboard/home");
return Bun.file(`${dashDir}/index.html`);
})
// Rendered pages // Logout
.get("/dashboard/home", () => render("home", { nav: "monitors" })) .get("/dashboard/logout", ({ cookie }) => {
.get("/dashboard/settings", () => render("settings", { nav: "settings" })) cookie.pingql_key?.remove();
.get("/dashboard/monitors/new", () => render("new", { nav: "monitors" })) return redirect("/dashboard");
.get("/dashboard/monitors/:id", () => render("detail", { nav: "monitors" })) })
// 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`)); .get("/docs", () => Bun.file(`${dashDir}/docs.html`));

View File

@ -85,8 +85,9 @@ export const ingest = new Elysia()
}) })
// SSE: stream live pings — auth via Bearer header // SSE: stream live pings — auth via Bearer header
.get("/monitors/:id/stream", async ({ params, headers, error }) => { .get("/monitors/:id/stream", async ({ params, headers, cookie, error }) => {
const key = headers["authorization"]?.replace("Bearer ", "").trim(); const key = headers["authorization"]?.replace("Bearer ", "").trim()
?? cookie?.pingql_key?.value;
if (!key) return error(401, { error: "Unauthorized" }); if (!key) return error(401, { error: "Unauthorized" });

View File

@ -152,7 +152,6 @@
</main> </main>
<script> <script>
if (!requireAuth()) throw 'auth';
const monitorId = window.location.pathname.split('/').pop(); const monitorId = window.location.pathname.split('/').pop();
let editQuery = null; let editQuery = null;

View File

@ -12,17 +12,39 @@
</div> </div>
<div id="monitors-list" class="space-y-3"> <div id="monitors-list" class="space-y-3">
<div class="text-center py-16 text-gray-600">Loading...</div> <% if (it.monitors && it.monitors.length === 0) { %>
</div> <div id="empty-state" class="text-center py-16">
<div id="empty-state" class="hidden text-center py-16">
<p class="text-gray-500 mb-4">No monitors yet</p> <p class="text-gray-500 mb-4">No monitors yet</p>
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-6 py-3 rounded-lg transition-colors inline-block">Create your first monitor</a> <a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-6 py-3 rounded-lg transition-colors inline-block">Create your first monitor</a>
</div> </div>
<% } else if (it.monitors) { %>
<% it.monitors.forEach(function(m) { %>
<a href="/dashboard/monitors/<%= m.id %>" data-monitor-id="<%= m.id %>" class="block bg-gray-900 hover:bg-gray-800/80 border border-gray-800 rounded-xl p-4 transition-colors group">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<span class="status-dot w-2.5 h-2.5 rounded-full <%= m.last_ping == null ? 'bg-gray-600' : (m.last_ping.up ? 'bg-green-500' : 'bg-red-500') %>"></span>
<div class="min-w-0">
<div class="font-medium text-gray-100 group-hover:text-white truncate"><%= m.name %></div>
<div class="text-xs text-gray-500 truncate"><%= m.url %></div>
</div>
</div>
<div class="flex items-center gap-6 shrink-0 ml-4">
<div class="text-right">
<div class="text-sm text-gray-300 stat-latency"><%= m.last_ping ? m.last_ping.latency_ms + 'ms' : '—' %></div>
<div class="text-xs text-gray-500 stat-last"><%= m.last_ping ? '<span class="timestamp" data-ts="' + new Date(m.last_ping.checked_at).getTime() + '">just now</span>' : 'no pings' %></div>
</div>
<div class="text-xs px-2 py-1 rounded <%= m.enabled ? 'bg-gray-800 text-gray-400' : 'bg-yellow-900/30 text-yellow-500' %>"><%= m.enabled ? m.interval_s + 's' : 'paused' %></div>
</div>
</div>
</a>
<% }) %>
<% } else { %>
<div class="text-center py-16 text-gray-600">Loading...</div>
</div>
</main> </main>
<script> <script>
if (!requireAuth()) throw 'auth';
async function load() { async function load() {
try { try {

View File

@ -90,7 +90,6 @@
</main> </main>
<script> <script>
if (!requireAuth()) throw 'auth';
let currentQuery = null; let currentQuery = null;
// Show body section for non-GET methods // Show body section for non-GET methods

View File

@ -84,14 +84,10 @@
<script> <script>
if (!requireAuth()) throw 'auth';
const key = localStorage.getItem('pingql_key');
async function loadSettings() { async function loadSettings() {
const data = await api('/account/settings'); const data = await api('/account/settings');
document.getElementById('primary-key').textContent = key; document.getElementById('primary-key').textContent = data.account_id;
document.getElementById('created-at').textContent = new Date(data.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); document.getElementById('created-at').textContent = new Date(data.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
if (data.has_email) { if (data.has_email) {
@ -155,7 +151,8 @@
async function confirmReset() { async function confirmReset() {
if (!confirm('Rotate your primary key?\n\nYour current key will stop working immediately. Make sure to copy the new one.')) return; if (!confirm('Rotate your primary key?\n\nYour current key will stop working immediately. Make sure to copy the new one.')) return;
const data = await api('/account/reset-key', { method: 'POST', body: {} }); const data = await api('/account/reset-key', { method: 'POST', body: {} });
localStorage.setItem('pingql_key', data.key); // Re-auth with new key (updates cookie)
await fetch('/account/login', { method: 'POST', credentials: 'same-origin', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key: data.key }) });
document.getElementById('primary-key').textContent = data.key; document.getElementById('primary-key').textContent = data.key;
location.reload(); location.reload();
} }