Files
hyprcrd/relay/ui/app.js
T
thandClaude Opus 4.6 838dfe2a6e relay: custom confirm dialog, custom recipient dropdown, inbox polish
Replaces all native confirm() calls with styled relayConfirm() promise.
Replaces native <select> recipient picker with custom dropdown control.
Adds synced badge, title-only preview, delete-all, inline delete confirm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:40:21 +02:00

682 lines
27 KiB
JavaScript

// 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). */
/** Custom confirm dialog — returns a Promise<boolean>. Replaces native
* confirm() across the relay UI so all confirmations are styled. */
function relayConfirm(msg, { okLabel = 'Confirm', danger = true } = {}) {
return new Promise(resolve => {
const el = $('relay-confirm');
if (!el) { resolve(confirm(msg)); return; } // fallback
$('relay-confirm-msg').textContent = msg;
const yesBtn = $('relay-confirm-yes');
yesBtn.textContent = okLabel;
yesBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
el.classList.remove('hidden');
const cleanup = (result) => {
el.classList.add('hidden');
yesBtn.replaceWith(yesBtn.cloneNode(true)); // strip listeners
$('relay-confirm-no').replaceWith($('relay-confirm-no').cloneNode(true));
resolve(result);
};
$('relay-confirm-yes').addEventListener('click', () => cleanup(true), { once: true });
$('relay-confirm-no').addEventListener('click', () => cleanup(false), { once: true });
});
}
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 = `<i class="ph ${icon} toast-icon"></i><span class="toast-msg"></span>`;
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 = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
$('nav-admin').classList.toggle('hidden', !me.is_admin);
const n = status.inbox_count || 0;
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${n} in inbox</span>`;
$('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 hidden = $('compose-recipient');
const btn = $('compose-recipient-btn');
const label = $('compose-recipient-label');
const dd = $('compose-recipient-dropdown');
const field = $('compose-recipient-field');
if (!hidden || !btn || !dd) return;
// Build the options list: self first, then all other tenants
const options = [];
if (me) options.push({ value: me.username, text: `${me.username} (you)` });
for (const t of allTenants) {
if (me && t.username === me.username) continue;
options.push({ value: t.username, text: t.username });
}
// Default to self
if (options.length && !hidden.value) {
hidden.value = options[0].value;
label.textContent = options[0].text;
}
// Render dropdown items
dd.innerHTML = options.map(o => `
<button class="recipient-option" data-value="${escapeHtml(o.value)}" style="display:block;width:100%;text-align:left;padding:7px 12px;font-size:12px;font-family:var(--mono);color:var(--text);background:none;border:none;cursor:pointer;transition:background .1s;">
<i class="ph ph-user-circle" style="font-size:12px;color:var(--accent2);margin-right:6px;"></i>${escapeHtml(o.text)}
</button>
`).join('');
dd.querySelectorAll('.recipient-option').forEach(opt => {
opt.addEventListener('mouseenter', () => opt.style.background = 'var(--raised)');
opt.addEventListener('mouseleave', () => opt.style.background = 'none');
opt.addEventListener('click', () => {
hidden.value = opt.dataset.value;
label.textContent = opt.textContent.trim();
dd.classList.add('hidden');
});
});
// Toggle dropdown on button click
btn.onclick = () => dd.classList.toggle('hidden');
// Close on outside click
document.addEventListener('click', (e) => {
if (!btn.contains(e.target) && !dd.contains(e.target)) dd.classList.add('hidden');
});
// Solo tenant → hide the picker
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 = `
<div class="skeleton-row"><div class="skel tall"></div></div>
<div class="skeleton-row"><div class="skel short"></div></div>
<div class="skeleton-row"><div class="skel"></div></div>
`;
const res = await api('api/messages/inbox');
const messages = res?.messages || [];
$('inbox-count').textContent = messages.length;
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
// Mirror count into the header status so the unread dot stays in sync
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
$('header-status').classList.toggle('has-unread', messages.length > 0);
if (!messages.length) {
list.innerHTML = `
<div class="empty-state">
<i class="ph ph-tray icon"></i>
<div class="label">inbox empty</div>
<div class="hint">Messages from other tenants will show up here.</div>
</div>`;
return;
}
list.innerHTML = messages.map(m => `
<div class="inbox-item" data-id="${m.id}">
<div class="content">
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">new</span>'}</div>
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
</div>
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
<button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button>
</div>
`).join('');
}
async function deleteMessage(id, btn) {
// Inline confirmation: replace the trash button with yes/no.
// Event listeners are wired via JS (not inline onclick) to avoid
// passing escaped HTML through attributes.
const row = btn.closest('.inbox-item');
if (!row) return;
const origHtml = btn.outerHTML;
const wrap = document.createElement('span');
wrap.className = 'del-confirm';
wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;font-family:var(--pixel);font-size:9px;';
wrap.innerHTML = `
<span style="color:var(--danger);">delete?</span>
<button class="del-yes" style="background:var(--danger);color:var(--bg);border:none;border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">yes</button>
<button class="del-no" style="background:none;border:1px solid var(--border);color:var(--muted);border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">no</button>
`;
btn.replaceWith(wrap);
wrap.querySelector('.del-yes').addEventListener('click', async () => {
const res = await api('api/messages/' + id, { method: 'DELETE' });
if (res?.ok) {
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
loadInbox();
refreshMe();
toast('message deleted', 'ok');
} else {
toast(res?.error || 'delete failed', 'err');
}
});
wrap.querySelector('.del-no').addEventListener('click', () => {
const temp = document.createElement('div');
temp.innerHTML = origHtml;
wrap.replaceWith(temp.firstChild);
});
}
window.deleteMessage = deleteMessage;
async function deleteAllMessages() {
const res = await api('api/messages/inbox');
const messages = res?.messages || [];
if (!messages.length) return;
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
let ok = 0;
for (const m of messages) {
const r = await api('api/messages/' + m.id, { method: 'DELETE' });
if (r?.ok) ok++;
}
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
loadInbox();
refreshMe();
}
window.deleteAllMessages = deleteAllMessages;
/** Lightweight relative-time formatter for the inbox. SQLite gives us
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
* or a full date for older messages. */
function formatRelTime(sqliteUtc) {
if (!sqliteUtc) return '';
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
if (isNaN(t.getTime())) return sqliteUtc;
const diff = (Date.now() - t.getTime()) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
if (diff < 86400) {
const today = new Date();
const sameDay = today.toDateString() === t.toDateString();
if (sameDay) return 'today ' + t.toTimeString().slice(0, 5);
return Math.floor(diff / 3600) + ' h ago';
}
if (diff < 7 * 86400) return Math.floor(diff / 86400) + ' d ago';
return t.toISOString().slice(0, 10);
}
// ── Admin: users ──
async function loadUsers() {
const list = $('users-list');
list.innerHTML = `
<div class="skeleton-row"><div class="skel tall"></div></div>
<div class="skeleton-row"><div class="skel short"></div></div>
<div class="skeleton-row"><div class="skel"></div></div>
`;
const res = await api('api/users');
if (res.error) { toast(res.error, 'err'); return; }
const users = res.users || [];
$('users-count').textContent = users.length;
if (!users.length) {
list.innerHTML = `
<div class="empty-state">
<i class="ph ph-users icon"></i>
<div class="label">no users yet</div>
<div class="hint">Create the first one with the form on the right.</div>
</div>`;
return;
}
list.innerHTML = users.map(u => `
<div class="user-row" data-id="${u.id}">
<i class="ph ph-user-circle user-icon"></i>
<div class="info">
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
<div class="meta">created ${u.created_at}</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
</div>
</div>
`).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 (!await relayConfirm(`Delete user "${username}"? All their messages and attachments will be removed.`, { okLabel: 'Delete user' })) 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 = '<i class="ph ph-eye"></i> 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 = '<i class="ph ph-eye-slash"></i> hide';
} else {
inp.type = 'password';
btn.innerHTML = '<i class="ph ph-eye"></i> 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 (!await relayConfirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.', { okLabel: 'Replace', danger: false })) 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 = '<i class="ph ph-eye-slash"></i> hide';
} else {
setAccountStatus(res.error || 'failed', 'err');
}
}
async function clearAccountSecret() {
if (!await relayConfirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.', { okLabel: 'Clear secret' })) 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 => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[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(); }
}
});