Files
hyprcrd/relay/ui/app.js
T
thandClaude Opus 4.7 d83de30d6d security: v2.0 — AES-GCM crypto, SSRF guards, rate limits, hardening
Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so
existing user secrets, stored messages, and inter-tenant key keep
working. Old clients and relays lose cross-version chat until both
sides are upgraded.

Relay
- Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived
  per-ciphertext salts; keep CBC-V1 fallback on decrypt only.
- On-bootstrap migration re-envelopes tenant secret_encrypted and the
  inter-tenant key to GCM-V2 (idempotent).
- CORS: stop reflecting arbitrary Origin; allow own origin + optional
  RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials.
- Generic error messages replace "no such user: <name>" enumeration.
- Exception messages no longer echoed back; logged server-side.
- Collab-lock max hold capped at 10min so a member can't grief-refresh
  forever.
- Attachment filenames validated against hex.<ext> pattern.
- Passwords min length 10; /api/account/password rate-limited.

App / Electron client
- Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read).
- Chat + collab-buckets plugins delegate to the shared helpers.
- Poll + push + attachments migrated to the dual-format path.
- /api/relay/settings GET returns has_key boolean; secret never echoed.
- Setup endpoint now CSRF-checked.
- SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks
  favicon fetch also blocks private/loopback/link-local.
- Login / password-change rate limiter moved to DB-backed IP keying.
- Passwords min length 10 throughout.
- Zip import enforces per-file (25MB) and total (500MB) caps, extension
  allowlist, no-overwrite.
- Verbose relay-push debug gated behind APP_DEBUG env.

Version bumped to 2.0.0 (protocol-breaking).

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

1182 lines
46 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', 'inbox', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
$('view-' + v)?.classList.add('hidden');
});
$('view-' + name)?.classList.remove('hidden');
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
$('nav-buttons').classList.remove('hidden');
$('nav-compose').classList.toggle('active', name === 'compose');
$('nav-inbox')?.classList.toggle('active', name === 'inbox');
$('nav-chat')?.classList.toggle('active', name === 'chat');
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
$('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());
$('compose-body')?.focus();
}
if (name === 'inbox') {
loadInbox();
loadOutbox();
loadCollabInbox();
startInboxPoll();
} else {
stopInboxPoll();
// Keep the header badge count fresh even outside the inbox
// view — a quiet load updates the count without rendering the
// full list (the DOM elements are in the hidden inbox view).
loadInbox({ quiet: true });
}
if (name === 'chat') {
loadChatThreads();
startChatPoll();
} else {
stopChatPoll();
}
if (name === 'shopping') {
loadShopping();
startShoppingPoll();
} else {
stopShoppingPoll();
}
// Remember the active view so a page reload lands back here
// instead of defaulting to compose every time.
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
try { localStorage.setItem('relay-last-view', name); } catch {}
}
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;
let inboxBusy = false;
function startInboxPoll() {
stopInboxPoll();
inboxPollHandle = setInterval(() => {
if (inboxBusy) return;
if (!$('view-inbox')?.classList.contains('hidden')) {
loadInbox({ quiet: true });
loadOutbox({ quiet: true });
loadCollabInbox({ quiet: true });
}
}, 15000);
}
function stopInboxPoll() {
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
}
// Suppress poll re-renders while the user is typing in any input/textarea
document.addEventListener('focusin', e => {
if (e.target.matches('input, textarea, select')) inboxBusy = true;
});
document.addEventListener('focusout', e => {
if (e.target.matches('input, textarea, select')) inboxBusy = false;
});
/** 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;
showView('inbox');
}
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 < 10) { err.textContent = 'new password must be at least 10 chars'; 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 < 10) { err.textContent = 'password must be at least 10 chars'; 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 {
const last = localStorage.getItem('relay-last-view') || 'compose';
showView(['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(last) ? last : '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');
updateCcSelfVisibility();
});
});
// 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);
updateCcSelfVisibility();
}
let composeFiles = [];
function updateCcSelfVisibility() {
const wrap = $('compose-cc-self-wrap');
if (!wrap) return;
const recipient = $('compose-recipient')?.value || '';
const isSelf = !recipient || (me && recipient === me.username);
wrap.style.display = isSelf ? 'none' : '';
}
function clearCompose() {
$('compose-title').value = '';
$('compose-body').value = '';
$('compose-body').focus();
setComposeStatus('');
composeFiles = [];
renderComposeAttachments();
}
$('compose-attach-btn')?.addEventListener('click', () => $('compose-file')?.click());
$('compose-file')?.addEventListener('change', e => {
for (const f of e.target.files) {
if (composeFiles.some(x => x.name === f.name && x.size === f.size)) continue;
composeFiles.push(f);
}
e.target.value = '';
renderComposeAttachments();
});
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function fileIcon(name) {
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) return 'ph-image';
if (/\.(mp3|ogg|wav|m4a)$/i.test(name)) return 'ph-music-note';
if (/\.pdf$/i.test(name)) return 'ph-file-pdf';
return 'ph-file';
}
function renderComposeAttachments() {
const el = $('compose-attachments');
if (!el) return;
el.innerHTML = composeFiles.map((f, i) => `
<div class="compose-att">
<i class="ph ${fileIcon(f.name)} att-icon"></i>
<span>${escapeHtml(f.name)}</span>
<span class="att-size">${formatSize(f.size)}</span>
<button class="att-remove" data-idx="${i}" aria-label="remove"><i class="ph ph-x"></i></button>
</div>
`).join('');
el.querySelectorAll('.att-remove').forEach(btn => {
btn.addEventListener('click', () => {
composeFiles.splice(parseInt(btn.dataset.idx), 1);
renderComposeAttachments();
});
});
}
let composeStatusTimer = null;
function setComposeStatus(text, kind = '') {
clearTimeout(composeStatusTimer);
const el = $('compose-status');
if (!el) return;
el.textContent = text;
el.className = 'compose-status-bar' + (kind ? ' ' + kind : '');
if (text) {
el.classList.add('visible');
if (kind === 'ok') {
composeStatusTimer = setTimeout(() => { el.classList.remove('visible'); }, 4000);
}
} else {
el.classList.remove('visible');
}
}
function randomHex(n) {
const a = new Uint8Array(n);
crypto.getRandomValues(a);
return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
}
async function sendCompose() {
const title = $('compose-title').value;
let body = $('compose-body').value;
const recipient = $('compose-recipient').value;
if (!body.trim() && !composeFiles.length) { setComposeStatus('body or attachment required', 'err'); return; }
const relayUid = randomHex(16);
const hasFiles = composeFiles.length > 0;
if (hasFiles) {
setComposeStatus('uploading files...');
const hexNames = [];
for (const f of composeFiles) {
const form = new FormData();
form.append('file', f);
form.append('relay_uid', relayUid);
form.append('recipient', recipient || '');
try {
const r = await fetch('api/attachments/upload', { method: 'POST', body: form });
const data = await r.json();
if (data.ok && data.filename) {
const ext = (data.original || f.name).split('.').pop().toLowerCase();
const isImg = /^(png|jpe?g|gif|webp)$/.test(ext);
const label = data.original || f.name;
hexNames.push(isImg
? `![${label}](uploads/${data.filename})`
: `[📄 ${label}](uploads/${data.filename})`);
}
} catch {}
}
if (hexNames.length) {
body = body ? body + '\n\n' + hexNames.join('\n') : hexNames.join('\n');
}
}
setComposeStatus('sending...');
const ccSelf = $('compose-cc-self')?.checked && recipient;
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient, relay_uid: relayUid } });
if (res.ok) {
// Send a copy to self tagged with recipient name
if (ccSelf) {
const ccTag = `#sent/${res.recipient}`;
const ccBody = body ? body + '\n\n' + ccTag : ccTag;
const ccUid = randomHex(16);
await api('api/messages', { method: 'POST', body: { title, body: ccBody, relay_uid: ccUid, tags: `sent/${res.recipient}` } });
// Also copy attachments to the self-message
if (hasFiles) {
for (const f of composeFiles) {
const form = new FormData();
form.append('file', f);
form.append('relay_uid', ccUid);
form.append('recipient', '');
try { await fetch('api/attachments/upload', { method: 'POST', body: form }); } catch {}
}
}
}
setComposeStatus(`sent to ${res.recipient}${ccSelf ? ' + copy to self' : ''}${hasFiles ? ` · ${composeFiles.length} file${composeFiles.length !== 1 ? 's' : ''}` : ''}`, 'ok');
$('compose-title').value = '';
$('compose-body').value = '';
$('compose-cc-self').checked = false;
composeFiles = [];
renderComposeAttachments();
$('compose-body').focus();
loadInbox();
loadOutbox();
refreshMe();
} else {
setComposeStatus(res.error || 'failed', 'err');
}
}
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
let lastInboxJson = '';
async function loadInbox({ quiet = false } = {}) {
const list = $('inbox-list');
if (!quiet) {
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 || [];
// In quiet mode, skip DOM update if nothing changed
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
if (quiet && json === lastInboxJson) return;
lastInboxJson = json;
$('inbox-count').textContent = messages.length;
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
$('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('');
}
let lastOutboxJson = '';
async function loadOutbox({ quiet = false } = {}) {
const list = $('outbox-list');
if (!list) return;
const res = await api('api/messages/sent');
const messages = res?.messages || [];
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
if (quiet && json === lastOutboxJson) return;
lastOutboxJson = json;
$('outbox-count').textContent = messages.length;
if (!messages.length) {
list.innerHTML = `
<div class="empty-state">
<i class="ph ph-paper-plane-tilt icon"></i>
<div class="label">no sent messages</div>
<div class="hint">Messages you send to other tenants will appear 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-arrow-right"></i> to ${escapeHtml(m.recipient)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">pending</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, 'outbox')" aria-label="delete"><i class="ph ph-trash"></i></button>
</div>
`).join('');
}
// ── Shared buckets / collab inbox ──
let lastCollabJson = '';
async function loadCollabInbox({ quiet = false } = {}) {
const list = $('shared-list');
const countEl = $('shared-count');
if (!list) return;
const res = await api('api/collab-inbox');
const items = res?.items || [];
const json = JSON.stringify(items.map(c => c.card_uid + ':' + c.updated_at));
if (quiet && json === lastCollabJson) return;
lastCollabJson = json;
if (countEl) countEl.textContent = items.length;
if (!items.length) {
list.innerHTML = `
<div class="empty-state">
<i class="ph ph-users-three icon"></i>
<div class="label">no shared bucket activity</div>
<div class="hint">Cards from collaborative buckets that you're a member of will appear here.</div>
</div>`;
return;
}
list.innerHTML = items.map(c => `
<div class="inbox-item">
<div class="content">
<div class="from">
<span class="shared-bucket-chip" style="border-left:3px solid ${escapeHtml(c.bucket_color || '#38bdf8')};padding-left:6px;">${escapeHtml(c.bucket_name || c.bucket_uid)}</span>
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
</div>
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
</div>
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
</div>
`).join('');
}
async function deleteMessage(id, btn, source) {
inboxBusy = true;
// 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' });
inboxBusy = false;
if (res?.ok) {
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
if (source === 'outbox') loadOutbox(); else loadInbox();
refreshMe();
toast('message deleted', 'ok');
} else {
toast(res?.error || 'delete failed', 'err');
}
});
wrap.querySelector('.del-no').addEventListener('click', () => {
inboxBusy = false;
const temp = document.createElement('div');
temp.innerHTML = origHtml;
wrap.replaceWith(temp.firstChild);
});
}
window.deleteMessage = deleteMessage;
async function deleteAllMessages() {
inboxBusy = true;
try {
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();
} finally { inboxBusy = false; }
}
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. */
// ── Shopping (phone-side of the hyprcrd → relay list) ──
//
// We don't own the content — hyprcrd pushes the active card's body
// to /api/shopping and we just render the unchecked "- [ ]" lines
// as tappable rows. A tap POSTs to /api/shopping/check which flips
// that exact line to "- [x]" on disk; hyprcrd's next pull sees it.
let shoppingPollHandle = null;
let shoppingBusy = false;
function startShoppingPoll() {
stopShoppingPoll();
// 4 s is responsive enough that edits on the desktop show up
// while you're still holding the phone, and cheap enough that
// an always-on tab costs basically nothing (single indexed
// SELECT per poll).
shoppingPollHandle = setInterval(() => {
if (!document.hidden && !shoppingBusy) loadShopping(true);
}, 4000);
}
function stopShoppingPoll() {
if (shoppingPollHandle) { clearInterval(shoppingPollHandle); shoppingPollHandle = null; }
}
function shoppingErr(msg) {
const el = $('shopping-err');
if (!el) return;
if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
el.textContent = msg;
el.classList.remove('hidden');
}
function shoppingParseLines(body) {
// Two node kinds end up on the phone:
// heading — "# h1" … "###### h6" inside the body, used to
// section the list (Obst / Backwaren / …)
// item — unchecked "- [ ] …" lines (checked ones are hidden)
// Then drop orphan headings (no open items before the next
// heading or end of list) so empty section labels don't sit
// around after everything under them is ticked off.
const nodes = [];
for (const line of String(body || '').split(/\r\n|\n|\r/)) {
const hm = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
if (hm) { nodes.push({ kind: 'heading', level: hm[1].length, text: hm[2] }); continue; }
const im = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(line);
if (!im) continue;
if (im[1].toLowerCase() === 'x') continue;
nodes.push({ kind: 'item', text: im[2], line });
}
const out = [];
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].kind === 'heading') {
let hasItem = false;
for (let j = i + 1; j < nodes.length; j++) {
if (nodes[j].kind === 'heading') break;
if (nodes[j].kind === 'item') { hasItem = true; break; }
}
if (!hasItem) continue;
}
out.push(nodes[i]);
}
return out;
}
async function loadShopping(quiet = false) {
if (!quiet) shoppingErr('');
const data = await api('api/shopping');
const title = $('shopping-title');
const list = $('shopping-items');
if (!list) return;
if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; }
// Dedup by card_uid — belt and suspenders in case the relay DB
// has stale rows from earlier schema migrations.
const seen = new Set();
const lists = (data.lists || []).filter(l => {
if (seen.has(l.card_uid)) return false;
seen.add(l.card_uid);
return true;
});
if (!lists.length) {
if (title) title.textContent = 'Shopping';
list.innerHTML = '<li class="empty">no active shopping lists</li>';
return;
}
if (title) title.textContent = 'Shopping';
list.innerHTML = '';
for (const entry of lists) {
const nodes = shoppingParseLines(entry.body);
const hasItems = nodes.some(n => n.kind === 'item');
// List box wrapper
const box = document.createElement('li');
box.className = 'shopping-list-box';
const hdr = document.createElement('div');
hdr.className = 'shopping-list-header';
hdr.textContent = entry.title || '(untitled)';
box.appendChild(hdr);
if (!hasItems) {
const done = document.createElement('div');
done.className = 'shopping-list-done';
done.textContent = '\u2713 all done';
box.appendChild(done);
} else {
for (const n of nodes) {
if (n.kind === 'heading') {
const h = document.createElement('div');
h.className = 'shopping-heading h' + n.level;
h.textContent = n.text;
box.appendChild(h);
} else {
const row = document.createElement('div');
row.className = 'shopping-item';
row.textContent = n.text;
row.dataset.line = n.line;
row.dataset.uid = entry.card_uid;
row.addEventListener('click', () => shoppingCheck(row));
box.appendChild(row);
}
}
}
list.appendChild(box);
}
}
async function shoppingCheck(el) {
if (shoppingBusy) return;
shoppingBusy = true;
el.classList.add('checked');
try {
const res = await api('api/shopping/check', {
method: 'POST',
body: { card_uid: el.dataset.uid, line: el.dataset.line, checked: true },
});
if (!res?.ok) throw new Error(res?.error || 'check failed');
setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300);
} catch (e) {
shoppingBusy = false;
el.classList.remove('checked');
shoppingErr(e.message);
}
}
// Auto-refresh when the tab comes back into focus — same pattern as
// the standalone page used to have, just without a manual button.
document.addEventListener('visibilitychange', () => {
if (document.hidden) return;
if ($('view-shopping')?.classList.contains('hidden')) return;
if (shoppingBusy) return;
loadShopping(true);
});
// ── Chat ──
let chatActivePeer = null;
let chatPollHandle = null;
function startChatPoll() {
stopChatPoll();
chatPollHandle = setInterval(() => {
loadChatThreads(true);
if (chatActivePeer) loadChatHistory(chatActivePeer, true);
}, 5000);
}
function stopChatPoll() {
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
}
async function loadChatThreads(quiet = false) {
const list = $('chat-thread-list');
if (!list) return;
const threads = await api('api/chat/threads') || [];
if (!Array.isArray(threads)) return;
// If no threads and not quiet, show empty state
if (!threads.length && !quiet) {
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
return;
}
if (!threads.length) return;
list.innerHTML = threads.map(t => `
<div class="chat-thread ${chatActivePeer === t.peer ? 'active' : ''}" data-peer="${escapeHtml(t.peer)}">
<i class="ph ph-user-circle" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div class="peer-name">${escapeHtml(t.peer)}</div>
<div class="thread-time">${formatRelTime(t.last_at)}</div>
</div>
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
</div>
`).join('');
list.querySelectorAll('.chat-thread').forEach(el => {
el.addEventListener('click', () => {
chatActivePeer = el.dataset.peer;
loadChatThreads(true);
loadChatHistory(chatActivePeer);
$('chat-input-bar')?.classList.remove('hidden');
$('chat-peer-name').textContent = chatActivePeer;
$('chat-input')?.focus();
});
});
}
async function loadChatHistory(peer, quiet = false) {
const container = $('chat-messages');
if (!container) return;
const messages = await api('api/chat/history?peer=' + encodeURIComponent(peer)) || [];
if (!Array.isArray(messages)) return;
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
if (!messages.length) {
container.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no messages yet</div></div>`;
return;
}
container.innerHTML = messages.map(m => `
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
<div>${escapeHtml(m.body)}</div>
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
</div>
`).join('');
if (wasAtBottom || !quiet) {
container.scrollTop = container.scrollHeight;
}
// Refresh thread list to clear unread badges
if (!quiet) loadChatThreads(true);
}
async function sendChatMessage() {
const input = $('chat-input');
if (!input || !chatActivePeer) return;
const body = input.value.trim();
if (!body) return;
input.value = '';
await api('api/chat/send', { method: 'POST', body: { body, recipient: chatActivePeer } });
loadChatHistory(chatActivePeer);
loadChatThreads(true);
}
$('chat-send-btn')?.addEventListener('click', sendChatMessage);
$('chat-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
});
function formatChatTime(sqliteUtc) {
if (!sqliteUtc) return '';
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
if (isNaN(t.getTime())) return sqliteUtc;
const now = new Date();
const time = t.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (t.toDateString() === now.toDateString()) return time;
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
if (t.toDateString() === yesterday.toDateString()) return 'yesterday ' + time;
return t.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time;
}
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 => {
const seenText = u.last_seen_at ? 'last seen ' + formatRelTime(u.last_seen_at) : 'never connected';
return `
<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} · ${seenText}</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 10 chars):`);
if (!newPw || newPw.length < 10) { 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(); }
}
});