Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):
* state.unseenCardIds (Set) tracks card ids that arrived since the
last visit to the "all" view. Diff runs in renderBoard() against a
module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
the API returns). The previous diff treated every card from the
real load as "new" and pulsed the entire board. Now the diff is
skipped when knownCardIds is null or empty — the first non-empty
render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
is drained and every matching board card gets a 3-cycle accent glow
(`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
both when navigating TO all and when a poll arrives while already
on all.
* When the user is on a different bucket: unseenCardIds accumulates
across polls and a red dot (`.bar-unread-dot`) appears on the "all"
tab. Position is top:3px right:3px *inside* the tab to survive
the double overflow clip (`#main-header overflow:hidden` +
`#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
all-tab template on every render so it survives bar re-renders that
aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
of every renderBoard for cases where the diff changed unseenCardIds
but renderBar hasn't run yet.
Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):
* New session-based GET /api/messages/inbox — returns up to 200
messages for the logged-in user, decrypted server-side with the
inter-tenant key (no extra leakage — the relay already sees
plaintext during the routing hop). Does NOT mark messages as
fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
actual inbox items: sender (with user-circle icon + accent2),
optional title (bold), body (pre-wrap, max-height 120px), relative
timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
"3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
/api/messages/{id} (already session-aware), removes the DOM row,
re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
/api/status count) and toggles the has-unread class + dot so the
tap-to-scroll-to-inbox target stays fresh.
Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
tint, focus-ring, and a red dot (::after pseudo) when has-unread.
jumpToInbox() scrolls smoothly to #inbox-section and flashes it
with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.
chore: bump version to 0.9.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
601 lines
22 KiB
JavaScript
601 lines
22 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). */
|
|
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 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 = `
|
|
<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;
|
|
// 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-new">new</span>'}</div>
|
|
${m.title ? `<div class="title">${escapeHtml(m.title)}</div>` : ''}
|
|
<div class="body">${escapeHtml(m.body)}</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) {
|
|
if (!confirm('Delete this message?')) return;
|
|
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
|
if (res?.ok) {
|
|
btn.closest('.inbox-item')?.remove();
|
|
loadInbox();
|
|
refreshMe();
|
|
toast('message deleted', 'ok');
|
|
} else {
|
|
toast(res?.error || 'delete failed', 'err');
|
|
}
|
|
}
|
|
window.deleteMessage = deleteMessage;
|
|
|
|
/** 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 (!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 = '<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 (!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 = '<i class="ph ph-eye-slash"></i> 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(); }
|
|
}
|
|
});
|