feat: cookie-based auth, SSR dashboard, JS-optional login
This commit is contained in:
@@ -1,41 +1,27 @@
|
||||
// PingQL Dashboard — shared utilities
|
||||
// Auth is now cookie-based. No localStorage needed.
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function getAccountKey() {
|
||||
return localStorage.getItem('pingql_key');
|
||||
}
|
||||
|
||||
function setAccountKey(key) {
|
||||
localStorage.setItem('pingql_key', key);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('pingql_key');
|
||||
window.location.href = '/dashboard';
|
||||
window.location.href = '/dashboard/logout';
|
||||
}
|
||||
|
||||
function requireAuth() {
|
||||
if (!getAccountKey()) {
|
||||
window.location.href = '/dashboard';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// requireAuth is a no-op now — server redirects to /dashboard if not authed
|
||||
function requireAuth() { return true; }
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const key = getAccountKey();
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...opts,
|
||||
credentials: 'same-origin', // send cookie automatically
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
||||
...opts.headers,
|
||||
},
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
logout();
|
||||
window.location.href = '/dashboard';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
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)
|
||||
// Returns an AbortController — call .abort() to close
|
||||
function watchMonitor(monitorId, onPing) {
|
||||
const key = localStorage.getItem('pingql_key');
|
||||
if (!key) return null;
|
||||
|
||||
const ac = new AbortController();
|
||||
|
||||
async function connect() {
|
||||
try {
|
||||
const res = await fetch(`/monitors/${monitorId}/stream`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
credentials: 'same-origin',
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) return;
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<style>
|
||||
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>
|
||||
<link rel="stylesheet" href="/dashboard/app.css">
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
@@ -18,58 +14,60 @@
|
||||
<p class="text-gray-500 text-sm mt-2">Uptime monitoring for developers</p>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<div id="screen-login" class="bg-gray-900 rounded-xl p-6 glow border border-gray-800">
|
||||
<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"
|
||||
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"
|
||||
maxlength="19" autocomplete="off" spellping="false">
|
||||
<button id="login-btn"
|
||||
class="w-full mt-3 bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
||||
Sign In
|
||||
</button>
|
||||
<div id="login-error" class="text-red-400 text-sm mt-3 text-center hidden"></div>
|
||||
<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>
|
||||
<button id="register-btn"
|
||||
class="w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-300 font-medium py-3 rounded-lg transition-colors">
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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)">
|
||||
|
||||
<!-- Post-registration screen: show key + optional email -->
|
||||
<div id="screen-new-account" class="hidden">
|
||||
<div 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>
|
||||
<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 tracking-widest text-center text-lg font-mono"
|
||||
maxlength="19">
|
||||
<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">
|
||||
Sign In
|
||||
</button>
|
||||
<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">
|
||||
<p class="text-gray-500 text-sm mb-3">No account?</p>
|
||||
<button id="register-btn"
|
||||
class="w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-300 font-medium py-3 rounded-lg transition-colors">
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post-registration: show new key -->
|
||||
<div id="screen-new-account" class="hidden">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
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"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Optional email -->
|
||||
<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>
|
||||
<div class="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 — recovery only)</span></label>
|
||||
<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">
|
||||
<div class="flex gap-2 mt-3">
|
||||
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">
|
||||
<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">
|
||||
Save Email & Continue
|
||||
Save & Continue
|
||||
</button>
|
||||
<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">
|
||||
@@ -86,86 +84,68 @@
|
||||
const API = '';
|
||||
let newKey = null;
|
||||
|
||||
// Already logged in?
|
||||
if (localStorage.getItem('pingql_key')) {
|
||||
window.location.href = '/dashboard/home';
|
||||
// Auto-format key input
|
||||
const keyInput = document.getElementById('key-input');
|
||||
if (keyInput) {
|
||||
keyInput.addEventListener('input', e => {
|
||||
let v = e.target.value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 16);
|
||||
e.target.value = (v.match(/.{1,4}/g) || []).join('-');
|
||||
});
|
||||
}
|
||||
|
||||
// --- Login ---
|
||||
const keyInput = document.getElementById('key-input');
|
||||
document.getElementById('login-btn').addEventListener('click', login);
|
||||
keyInput.addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
|
||||
|
||||
keyInput.addEventListener('input', e => {
|
||||
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 = parts.join('-');
|
||||
});
|
||||
|
||||
async function login() {
|
||||
// JS-enhanced login (overrides form POST for better UX)
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
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 ---
|
||||
document.getElementById('register-btn').addEventListener('click', async () => {
|
||||
setLoading('register-btn', true, 'Creating...');
|
||||
try {
|
||||
const res = await fetch(`${API}/account/register`, {
|
||||
const res = await fetch('/account/login', {
|
||||
method: 'POST',
|
||||
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();
|
||||
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;
|
||||
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-new-account').classList.remove('hidden');
|
||||
document.getElementById('new-key-display').textContent = newKey;
|
||||
} catch { showError('Connection error'); }
|
||||
finally { setLoading('register-btn', false, 'Create Account'); }
|
||||
});
|
||||
|
||||
// --- Copy key ---
|
||||
document.getElementById('copy-key-btn').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(newKey).then(() => {
|
||||
const btn = document.getElementById('copy-key-btn');
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('text-green-400');
|
||||
btn.textContent = 'Copied!'; btn.classList.add('text-green-400');
|
||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Save email ---
|
||||
document.getElementById('save-email-btn').addEventListener('click', async () => {
|
||||
const email = document.getElementById('email-input').value.trim();
|
||||
if (!email) return document.getElementById('skip-email-btn').click();
|
||||
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...');
|
||||
if (!email) { document.getElementById('skip-email-btn').click(); return; }
|
||||
try {
|
||||
await fetch(`${API}/account/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${newKey}` },
|
||||
body: JSON.stringify({ email }),
|
||||
await fetch('/account/email', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }),
|
||||
});
|
||||
} catch { /* non-critical, continue anyway */ }
|
||||
finally { setLoading('save-email-btn', false, 'Save Email & Continue'); }
|
||||
} catch {}
|
||||
window.location.href = '/dashboard/home';
|
||||
});
|
||||
|
||||
@@ -173,17 +153,9 @@
|
||||
window.location.href = '/dashboard/home';
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('login-error');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function setLoading(id, loading, label) {
|
||||
const btn = document.getElementById(id);
|
||||
btn.disabled = loading;
|
||||
btn.textContent = label;
|
||||
el.textContent = msg; el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user