// 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'; } // Block admin view on mobile — even if someone tries programmatically if (name === 'admin' && window.matchMedia('(max-width: 700px)').matches) { name = 'compose'; } ['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-buttons').style.display = 'flex'; $('nav-compose').classList.toggle('active', name === 'compose'); $('nav-account').classList.toggle('active', name === 'account'); $('nav-admin').classList.toggle('active', name === 'admin'); } else { // Setup, login, and forced change-password hide all nav $('nav-buttons').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 = ''; } } 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 = '