// hyprcrd relay UI let me = null; let allTenants = []; function $(id) { return document.getElementById(id); } /** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'. * Multiple toasts stack vertically, oldest at the top, and each one * slides in from the right + fades out after a fixed timeout. The * legacy `toast(msg, true)` second-arg style still works (true → err). */ function toast(msg, kind = 'info') { if (kind === true) kind = 'err'; if (kind === false) kind = 'info'; const stack = $('toast-stack'); if (!stack) return; const el = document.createElement('div'); el.className = 'toast ' + kind; const icon = kind === 'err' ? 'ph-warning-circle' : kind === 'ok' ? 'ph-check-circle' : 'ph-info'; el.innerHTML = ``; el.querySelector('.toast-msg').textContent = msg; stack.appendChild(el); // Trigger the slide-in next frame so the transition runs requestAnimationFrame(() => el.classList.add('show')); setTimeout(() => { el.classList.add('fade-out'); setTimeout(() => el.remove(), 350); }, 2800); } async function api(path, opts = {}) { const res = await fetch(path, { method: opts.method || 'GET', headers: opts.body ? { 'Content-Type': 'application/json' } : {}, body: opts.body ? JSON.stringify(opts.body) : undefined, credentials: 'same-origin', }); return res.json().catch(() => ({ error: 'invalid response' })); } function showView(name) { // If the user hasn't changed their password yet, force them to that view // unless they're currently in setup/login/change-password (admin/anon flows) if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) { name = 'change-password'; } ['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => { $('view-' + v)?.classList.add('hidden'); }); $('view-' + name)?.classList.remove('hidden'); if (['compose', 'account', 'admin'].includes(name)) { $('nav-buttons').classList.remove('hidden'); $('nav-compose').classList.toggle('active', name === 'compose'); $('nav-account').classList.toggle('active', name === 'account'); $('nav-admin').classList.toggle('active', name === 'admin'); $('nav-burger')?.classList.remove('hidden'); // Picking a view also collapses the mobile menu so it doesn't // hover over the freshly-shown view. $('nav-buttons').classList.remove('menu-open'); } else { // Setup, login, and forced change-password hide all nav $('nav-buttons').classList.add('hidden'); $('nav-burger')?.classList.add('hidden'); } if (name === 'compose') { loadTenants().then(() => populateRecipientPicker()); loadInbox(); startInboxPoll(); $('compose-body')?.focus(); } else { stopInboxPoll(); } if (name === 'admin') { loadUsers(); } else if (name === 'account') { loadAccountSecret(); } else if (name === 'change-password') { $('cp-current')?.focus(); $('cp-err').textContent = ''; } } // ── Inbox auto-poll ── // // Refresh the inbox every 15s while the compose view is showing so new // messages from other tenants surface without a manual reload. Stops // the moment the user navigates away (account/admin/login) so we don't // hammer the server in the background. let inboxPollHandle = null; function startInboxPoll() { stopInboxPoll(); inboxPollHandle = setInterval(() => { if (!$('view-compose')?.classList.contains('hidden')) loadInbox(); }, 15000); } function stopInboxPoll() { if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; } } /** Burger toggle for the mobile nav. The button is hidden on desktop * via CSS, on mobile it expands #nav-buttons as a dropdown panel * positioned under the header. */ function toggleNavMenu() { $('nav-buttons')?.classList.toggle('menu-open'); } window.toggleNavMenu = toggleNavMenu; /** Tap-target on the header inbox count: switches to the compose view * if needed, then smooth-scrolls #inbox-section into view and briefly * highlights it so the user can't miss it after the scroll lands. * Same handler on desktop and mobile — desktop just scrolls a few px * if at all (the inbox is already on screen). */ function jumpToInbox() { if (!me) return; if ($('view-compose')?.classList.contains('hidden')) { showView('compose'); } // Wait one frame so the (possibly just-shown) view has measurable // dimensions before scrollIntoView fires requestAnimationFrame(() => { const target = $('inbox-section'); if (!target) return; target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.classList.remove('flash-highlight'); // Restart the animation by toggling the class void target.offsetWidth; target.classList.add('flash-highlight'); setTimeout(() => target.classList.remove('flash-highlight'), 1600); }); } window.jumpToInbox = jumpToInbox; // Tap outside the menu closes it document.addEventListener('click', (e) => { const nav = $('nav-buttons'); const burger = $('nav-burger'); if (!nav?.classList.contains('menu-open')) return; if (nav.contains(e.target) || burger?.contains(e.target)) return; nav.classList.remove('menu-open'); }); async function doChangePassword() { const current = $('cp-current').value; const newPw = $('cp-new').value; const newPw2 = $('cp-new2').value; const err = $('cp-err'); err.textContent = ''; if (!current || !newPw) { err.textContent = 'all fields required'; return; } if (newPw.length < 4) { err.textContent = 'new password too short'; return; } if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; } if (current === newPw) { err.textContent = 'new password must differ from current'; return; } const res = await api('api/account/password', { method: 'POST', body: { current, new: newPw } }); if (res.ok) { toast('password changed'); $('cp-current').value = ''; $('cp-new').value = ''; $('cp-new2').value = ''; // After password change, route based on whether they have a secret const status = await refreshMe(); if (!status.has_secret) { showView('account'); toast('now set your client secret'); } else { showView('compose'); } } else { err.textContent = res.error || 'failed'; } } // ── Setup ── async function doSetup() { const username = $('setup-username').value.trim(); const password = $('setup-password').value; const superSecret = $('setup-super-secret').value; const err = $('setup-err'); err.textContent = ''; if (!username) { err.textContent = 'username required'; return; } if (password.length < 4) { err.textContent = 'password too short'; return; } if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; } const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } }); if (res.ok) { await refreshMe(); showView('account'); toast('admin created — now set your client secret'); } else { err.textContent = res.error || 'failed'; } } // ── Login ── async function doLogin() { const username = $('login-username').value.trim(); const password = $('login-password').value; const err = $('login-err'); err.textContent = ''; if (!username || !password) { err.textContent = 'username and password required'; return; } const res = await api('api/login', { method: 'POST', body: { username, password } }); if (res.ok) { const status = await refreshMe(); routePostLogin(status); } else { err.textContent = res.error || 'login failed'; } } // Decide which view to land on after a successful login / on initial load. // Order: forced password change → set client secret → compose. function routePostLogin(status) { if (!status.password_changed) { showView('change-password'); // Pre-fill the current-password input from what they just typed if // we're coming from the login form (otherwise it's empty) const lp = $('login-password'); if (lp && lp.value) { $('cp-current').value = lp.value; lp.value = ''; } toast('change your password before continuing'); } else if (!status.has_secret) { showView('account'); toast('set your client secret to start using the relay'); } else { showView('compose'); } } async function doLogout() { await api('api/logout', { method: 'POST' }); me = null; $('nav-user').textContent = ''; $('nav-admin').classList.add('hidden'); showView('login'); } async function refreshMe() { const status = await api('api/status'); if (status.logged_in) { me = { username: status.username, is_admin: status.is_admin, has_secret: status.has_secret, password_changed: status.password_changed, }; $('nav-user').innerHTML = `${escapeHtml(me.username)}` + (me.is_admin ? 'admin' : ''); $('nav-admin').classList.toggle('hidden', !me.is_admin); const n = status.inbox_count || 0; $('header-status').innerHTML = `${n} in inbox`; $('header-status').classList.toggle('has-unread', n > 0); } return status; } // ── Compose ── async function loadTenants() { const res = await api('api/tenants'); if (res.tenants) allTenants = res.tenants; } function populateRecipientPicker() { const sel = $('compose-recipient'); const field = $('compose-recipient-field'); sel.innerHTML = ''; // Always include "you" first as the default recipient (the value // the form submits even when the picker is hidden) if (me) { const opt = document.createElement('option'); opt.value = me.username; opt.textContent = `${me.username} (you)`; sel.appendChild(opt); } for (const t of allTenants) { if (me && t.username === me.username) continue; const opt = document.createElement('option'); opt.value = t.username; opt.textContent = t.username; sel.appendChild(opt); } // Solo tenant → no choice to make. Hide the picker entirely so // the compose form is just title + body. if (field) { field.classList.toggle('hidden', allTenants.length <= 1); } } function clearCompose() { $('compose-title').value = ''; $('compose-body').value = ''; $('compose-body').focus(); setComposeStatus(''); } function setComposeStatus(text, kind = '') { const el = $('compose-status'); el.textContent = text; el.className = 'status ' + kind; } async function sendCompose() { const title = $('compose-title').value; const body = $('compose-body').value; const recipient = $('compose-recipient').value; if (!body.trim()) { setComposeStatus('body required', 'err'); return; } setComposeStatus('sending...'); const res = await api('api/messages', { method: 'POST', body: { title, body, recipient } }); if (res.ok) { setComposeStatus(`sent to ${res.recipient}`, 'ok'); $('compose-title').value = ''; $('compose-body').value = ''; $('compose-body').focus(); loadInbox(); refreshMe(); } else { setComposeStatus(res.error || 'failed', 'err'); } } // ── Inbox (counts only — content is encrypted for the hyprcrd client) ── async function loadInbox() { const list = $('inbox-list'); list.innerHTML = `