// hyprcrd relay UI let me = null; let allTenants = []; function $(id) { return document.getElementById(id); } function toast(msg, isError = false) { const t = $('toast'); t.textContent = msg; t.classList.toggle('err', isError); t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 2200); } 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(); $('compose-body')?.focus(); } else if (name === 'admin') { loadUsers(); } else if (name === 'account') { loadAccountSecret(); } else if (name === 'change-password') { $('cp-current')?.focus(); $('cp-err').textContent = ''; } } /** 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 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); $('header-status').textContent = (status.inbox_count || 0) + ' in inbox'; } 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 status = await api('api/status'); $('inbox-count').textContent = status.inbox_count || 0; const list = $('inbox-list'); if (!status.inbox_count) { list.innerHTML = '
no messages waiting. they will be picked up by the next hyprcrd poll.
'; } else { list.innerHTML = `
${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.
`; } } // ── Admin: users ── async function loadUsers() { const res = await api('api/users'); if (res.error) { toast(res.error, true); return; } const users = res.users || []; $('users-count').textContent = users.length; const list = $('users-list'); if (!users.length) { list.innerHTML = '
no users yet
'; return; } list.innerHTML = users.map(u => `
${escapeHtml(u.username)} ${u.is_admin ? 'admin' : ''}
created ${u.created_at}
${u.username !== me?.username ? `` : ''}
`).join(''); } async function createUser() { const username = $('new-username').value.trim(); const password = $('new-password').value; const isAdmin = $('new-is-admin').checked; const status = $('create-user-status'); status.textContent = 'creating...'; status.className = 'status'; const res = await api('api/users', { method: 'POST', body: { username, password, is_admin: isAdmin } }); if (res.ok) { status.textContent = 'created — they set their own client secret on first login'; status.className = 'status ok'; $('new-username').value = ''; $('new-password').value = ''; $('new-is-admin').checked = false; loadUsers(); setTimeout(() => { status.textContent = ''; }, 3000); } else { status.textContent = res.error || 'failed'; status.className = 'status err'; } } async function resetUserPassword(id, username) { const newPw = prompt(`new password for ${username} (min 4 chars):`); if (!newPw || newPw.length < 4) { toast('password too short', true); return; } const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } }); if (res.ok) toast('password updated'); else toast(res.error || 'failed', true); } async function deleteUser(id, username) { if (!confirm(`Delete user "${username}"? All their messages and attachments will be removed.`)) return; const res = await api('api/users/' + id, { method: 'DELETE' }); if (res.ok) { toast('user deleted'); loadUsers(); } else toast(res.error || 'failed', true); } // ── Account: client secret management ── let currentAccountSecret = null; async function loadAccountSecret() { setAccountStatus(''); const res = await api('api/account/secret'); if (res.has_secret) { currentAccountSecret = res.secret; $('account-current').classList.remove('hidden'); $('account-no-secret').classList.add('hidden'); $('account-secret-display').type = 'password'; $('account-secret-display').value = currentAccountSecret; $('account-secret-toggle').innerHTML = ' show'; } else { currentAccountSecret = null; $('account-current').classList.add('hidden'); $('account-no-secret').classList.remove('hidden'); } $('account-secret-input').value = ''; } function toggleAccountSecret() { const inp = $('account-secret-display'); const btn = $('account-secret-toggle'); if (inp.type === 'password') { inp.type = 'text'; btn.innerHTML = ' hide'; } else { inp.type = 'password'; btn.innerHTML = ' show'; } } async function copyAccountSecret() { if (!currentAccountSecret) return; try { await navigator.clipboard.writeText(currentAccountSecret); toast('secret copied to clipboard'); } catch { toast('copy failed — show and copy manually', true); } } async function saveAccountSecret() { const secret = $('account-secret-input').value; if (secret.length < 8) { setAccountStatus('secret needs at least 8 chars', 'err'); return; } setAccountStatus('saving...'); const res = await api('api/account/secret', { method: 'POST', body: { secret } }); if (res.ok) { setAccountStatus('saved', 'ok'); await loadAccountSecret(); await refreshMe(); setTimeout(() => setAccountStatus(''), 2000); } else { setAccountStatus(res.error || 'failed', 'err'); } } async function generateAccountSecret() { if (currentAccountSecret) { if (!confirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.')) return; } setAccountStatus('generating...'); const res = await api('api/account/secret', { method: 'POST', body: { generate: true } }); if (res.ok) { setAccountStatus('generated — copy it to your hyprcrd clients now', 'ok'); await loadAccountSecret(); await refreshMe(); // Auto-show the new secret so user can copy $('account-secret-display').type = 'text'; $('account-secret-toggle').innerHTML = ' hide'; } else { setAccountStatus(res.error || 'failed', 'err'); } } async function clearAccountSecret() { if (!confirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.')) return; const res = await api('api/account/secret', { method: 'DELETE' }); if (res.ok) { setAccountStatus('cleared', 'ok'); await loadAccountSecret(); await refreshMe(); } else { setAccountStatus(res.error || 'failed', 'err'); } } function setAccountStatus(text, kind = '') { const el = $('account-status'); if (!el) return; el.textContent = text; el.className = 'status ' + kind; } // ── Helpers ── function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } // ── Init ── (async () => { const status = await api('api/status'); if (status.setup) { showView('setup'); } else if (!status.logged_in) { showView('login'); } else { await refreshMe(); routePostLogin(status); } })(); // Keyboard document.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { if (document.activeElement?.id === 'login-password') { e.preventDefault(); doLogin(); } if (document.activeElement?.id === 'setup-secret') { e.preventDefault(); doSetup(); } if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); } } });