feat: post-registration key screen + optional email step

This commit is contained in:
M1 2026-03-16 12:55:52 +04:00
parent 20cc8d534b
commit 692d7eb4f5
3 changed files with 156 additions and 78 deletions

View File

@ -8,121 +8,182 @@
<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>
</head>
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-md p-8">
<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="text-center mb-8">
<h1 class="text-3xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></h1>
<p class="text-gray-500 text-sm mt-2">Uptime monitoring for developers</p>
</div>
<div class="bg-gray-900 rounded-xl p-6 glow border border-gray-800">
<div id="login-form">
<label class="block text-sm text-gray-400 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 tracking-widest text-center text-lg"
maxlength="19" autocomplete="off" spellcheck="false">
<button id="login-btn"
class="w-full mt-4 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>
<div class="mt-6 pt-6 border-t border-gray-800 text-center">
<p class="text-gray-500 text-sm">No account?</p>
<!-- 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" spellcheck="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="mt-2 text-blue-400 hover:text-blue-300 text-sm font-medium transition-colors">
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 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">
<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>
</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 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>
<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>
<p class="text-xs text-yellow-500/80 mt-2">⚠ This key is shown once. Copy it now — we don't store it.</p>
<!-- 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 and uptime alerts. Never shared.</p>
<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">
<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
</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">
Skip
</button>
</div>
<div id="email-error" class="text-red-400 text-xs mt-2 hidden"></div>
</div>
</div>
</div>
</div>
<script>
const keyInput = document.getElementById('key-input');
const loginBtn = document.getElementById('login-btn');
const registerBtn = document.getElementById('register-btn');
const loginError = document.getElementById('login-error');
const API = '';
let newKey = null;
// Check if already logged in
// Already logged in?
if (localStorage.getItem('pingql_key')) {
window.location.href = '/dashboard/home';
}
// Auto-format key input with dashes
keyInput.addEventListener('input', (e) => {
let v = e.target.value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
if (v.length > 16) v = v.slice(0, 16);
// --- 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('-');
});
loginBtn.addEventListener('click', async () => {
async function login() {
const key = keyInput.value.trim();
if (!key || key.length < 19) {
showError('Enter a valid account key');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = 'Verifying...';
if (key.length < 19) return showError('Enter a valid account key');
setLoading('login-btn', true, 'Verifying...');
try {
const res = await fetch('/monitors/', {
headers: { Authorization: `Bearer ${key}` },
});
if (res.status === 401) {
showError('Invalid account key');
return;
}
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 (e) {
showError('Connection error');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Sign In';
}
});
} catch { showError('Connection error'); }
finally { setLoading('login-btn', false, 'Sign In'); }
}
keyInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') loginBtn.click();
});
registerBtn.addEventListener('click', async () => {
registerBtn.disabled = true;
registerBtn.textContent = 'Creating...';
// --- Register ---
document.getElementById('register-btn').addEventListener('click', async () => {
setLoading('register-btn', true, 'Creating...');
try {
const res = await fetch('/auth/register', {
const res = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json();
if (data.account_key) {
localStorage.setItem('pingql_key', data.account_key);
// Show key to user briefly
keyInput.value = data.account_key;
loginError.textContent = 'Account created! Key: ' + data.account_key + ' — save this!';
loginError.classList.remove('hidden');
loginError.classList.remove('text-red-400');
loginError.classList.add('text-green-400');
setTimeout(() => {
window.location.href = '/dashboard/home';
}, 3000);
}
} catch (e) {
showError('Failed to create account');
} finally {
registerBtn.disabled = false;
registerBtn.textContent = 'Create Account';
}
if (!res.ok || !data.key) return showError(data.error || 'Failed to create account');
newKey = data.key;
localStorage.setItem('pingql_key', newKey);
// Switch to new account screen
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');
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...');
try {
await fetch(`${API}/account/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${newKey}` },
body: JSON.stringify({ email }),
});
} catch { /* non-critical, continue anyway */ }
finally { setLoading('save-email-btn', false, 'Save Email & Continue'); }
window.location.href = '/dashboard/home';
});
document.getElementById('skip-email-btn').addEventListener('click', () => {
window.location.href = '/dashboard/home';
});
// --- Helpers ---
function showError(msg) {
loginError.textContent = msg;
loginError.classList.remove('hidden', 'text-green-400');
loginError.classList.add('text-red-400');
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;
}
</script>
</body>

View File

@ -3,7 +3,7 @@ import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import { checks } from "./routes/checks";
import { monitors } from "./routes/monitors";
import { auth } from "./routes/auth";
import { auth, account } from "./routes/auth";
import { internal } from "./routes/internal";
import { dashboard } from "./routes/dashboard";
import { migrate } from "./db";
@ -16,6 +16,7 @@ const app = new Elysia()
.get("/", () => ({ name: "PingQL", version: "0.1.0", docs: "/docs", dashboard: "/dashboard" }))
.use(dashboard)
.use(auth)
.use(account)
.use(monitors)
.use(checks)
.use(internal)

View File

@ -22,6 +22,22 @@ export function requireAuth(app: Elysia) {
});
}
export const account = new Elysia({ prefix: "/account" })
.use(requireAuth)
// Update email (post-registration or settings)
.post("/email", async ({ accountId, body }) => {
const emailHash = body.email
? createHash("sha256").update(body.email.toLowerCase().trim()).digest("hex")
: 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 recovery and notifications" })),
}),
detail: { summary: "Update account email", tags: ["account"] },
});
export const auth = new Elysia({ prefix: "/auth" })
// Create a new account — no email required
.post("/register", async ({ body }) => {