'use strict'; // ---- People (admin): who can sign in, which tabs each may use, one-time invite codes let peopleTabs = []; async function loadPeople() { const box = $('#people'); if (!box) return; try { const r = await jfetch('/api/users'); peopleTabs = r.tabs; if (!$('#people')) return; $('#people').innerHTML = r.users.map(u => ``).join(''); $('#people').querySelectorAll('button[data-p]').forEach(b => b.onclick = () => personSheet(r.users.find(u => u.id === b.dataset.p))); } catch (e) { box.innerHTML = `
${esc(e.message)}
`; } } const tabBoxes = (chosen, disabled) => `
${peopleTabs.map(t => ``).join('')}
`; const pickedTabs = el => [...el.querySelectorAll('input[data-tab]:checked')].map(i => i.dataset.tab); function codeSheet(name, code, minutes) { const el = openSheet('Invite code for ' + name, '', `

On ${esc(name)}'s device, open ${esc(location.origin)}, tap New device? Use an invite code on the sign-in page, and enter:

${esc(code)}

It works once and expires in ${minutes} minutes. They will then create their own passkey.

`); el.querySelector('#sx').onclick = () => { closeSheet(); loadPeople(); }; el.querySelector('#cc').onclick = async () => { try { await navigator.clipboard.writeText(code); toast('Code copied'); } catch { toast('Could not copy'); } }; } async function addPersonSheet() { if (!peopleTabs.length) { try { peopleTabs = (await jfetch('/api/users')).tabs; } catch (e) { return toast(e.message); } } const el = openSheet('Add a person', '', `
${tabBoxes(['tasks', 'calendar', 'search'])}

They always get Home and Settings, and their own private tasks, events and settings. You can change this later.

`); el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet; el.querySelector('#pf').onsubmit = async e => { e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; try { const r = await jfetch('/api/users', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#pn').value, tabs: pickedTabs(el) }) }); codeSheet(r.user.name, r.code, r.minutes); } catch (err) { btn.disabled = false; el.querySelector('#pe').textContent = err.message; } }; } function personSheet(u) { const self = u.id === ME.id, admin = u.role === 'admin'; const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `
${admin ? '

Admins can use everything.

' : `${tabBoxes(u.tabsChosen)}`}
${self ? '' : ``} ${self ? '' : ``}
`); el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet; const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg); closeSheet(); loadPeople(); } catch (err) { toast(err.message); } }; const put = body => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'PUT', body: JSON.stringify(body) }); el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el) }) }), 'Saved'); }; el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } }; el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere'); const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off'); el.querySelector('#u-role').onclick = () => act(() => put({ role: admin ? 'user' : 'admin' }), admin ? 'No longer an admin' : 'Now an admin'); const del = el.querySelector('#u-del'); if (del) del.onclick = () => confirmSheet('Delete ' + u.name + '?', 'Their tasks, events, settings and sign-ins are permanently deleted.', 'Delete', () => act(() => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE' }), 'Deleted')); } function viewSettings() { const signed = !!sf.token(); const isAdmin = ME && ME.role === 'admin'; const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : ''; $('#view').innerHTML = `

Settings

${[['general', 'General'], ['connections', 'Connections'], ['security', 'Security'], ...(isAdmin ? [['admin', 'Admin']] : [])].map(([k, l]) => `${l}`).join('')}
${ME ? `

Signed in as

${esc(ME.name)}${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}
` : ''}

Signal alerts

How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.

${remindersHtml()}

Install

${installHtml()}

Connections

Seafile${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}
PlexChecking…
OnlyOffice${esc(cfg.onlyoffice)}
${signed ? '
' : ''}

Mail

Checking…

Audiobooks sign-in

Your OpenAudible username and password (the server's audiobook library login). Saved on your server (encrypted) and shared with all your devices. The Audiobooks tab uses them to load your library and play books.

Passkeys

Loading…

Recovery codes

Checking…One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).
${isAdmin ? `

People

Loading…

Health

Checking…

Storage

Checking…

A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.

` : ''} ${isAdmin ? `

Activity

Loading…
` : ''}
`; remindersInit(); installInit(); // only show what this person may use const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); }; hide(!((allowed('mail') && cfg.mail) || cfg.mailAdmin), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out'); hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo'); if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person settingsTabs(); if ($('#sga-s')) { const a = sgAlerts(), ns = $('#sga-ns'); $('#sga-s').checked = a.sound; $('#sga-n').checked = a.notify && 'Notification' in window && Notification.permission === 'granted'; if (!('Notification' in window)) { $('#sga-n').disabled = true; ns.textContent = 'This browser does not support notifications'; } else if (Notification.permission === 'denied') { $('#sga-n').disabled = true; ns.textContent = 'Blocked in the browser settings for this site; allow notifications there first'; } $('#sga-s').onchange = e => { sgSetAlerts({ sound: e.target.checked }); if (e.target.checked) sgChime(); }; $('#sga-n').onchange = async e => { if (!e.target.checked) return sgSetAlerts({ notify: false }); let p = Notification.permission; if (p === 'default') p = await Notification.requestPermission(); if (p === 'granted') { sgSetAlerts({ notify: true }); try { new Notification('Signal alerts are on', { body: 'You will see new messages here when this tab is in the background.', icon: '/icons/icon-192.png' }); } catch {} } else { e.target.checked = false; sgSetAlerts({ notify: false }); toast('Notifications are blocked. Allow them for this site in the browser settings.'); } }; $('#sga-t').onclick = () => { sgUnlockAudio(); sgChime(); if (!sgAC || sgAC.state !== 'running') toast('Click anywhere on the page once, then try again (the browser allows sound only after that)'); }; } if ($('#mailst')) mailSettings(); const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value }); if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; } if (signed) sf.req('/api2/account/info/').then(a => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · Signed in' + (a && (a.email || a.name) ? ' as ' + (a.email || a.name) : ''); }) .catch(e => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · ' + (sf.token() ? 'Signed in (could not check: ' + e.message + ')' : 'Signed out'); }); if (allowed('music')) jfetch('/api/plex/status').then(st => { const el = $('#plexst'); if (!el) return; el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server + (st.user ? ' · as ' + st.user.title : '') : 'Signed in · no server chosen') : 'Signed out'; if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; } }).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; }); if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); } if (isAdmin) jfetch('/api/health').then(h => { const row = (t, d, bad) => `
${bad ? '⚠ ' : '✓ '}${t}${d}
`; const gb = n => (n / 1e9).toFixed(1) + ' GB', days = t => Math.floor((Date.now() - t) / 864e5); const up = h.uptime > 86400 ? Math.floor(h.uptime / 86400) + ' days' : h.uptime > 3600 ? Math.floor(h.uptime / 3600) + ' hours' : Math.max(1, Math.floor(h.uptime / 60)) + ' min'; $('#health').innerHTML = (h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false)) + (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false)) + (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '') + row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30) + row('Server', 'Up ' + up + ' · Node ' + esc(h.node) + ' · lyrics ' + (h.lyrics ? 'on' : 'off') + ' · Signal ' + (h.signal ? 'on' : 'off'), false); }).catch(() => { const e = $('#health'); if (e) e.innerHTML = '
Could not read the health report
'; }); if (isAdmin) jfetch('/api/system').then(i => { const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable; const row = (t, d, bad) => `
${t}${d}
`; const where = !i.source ? '' : i.source.kind === 'folder' ? ' · host folder ' + esc(i.source.name) : i.source.kind === 'named' ? ' · volume "' + esc(i.source.name) + '"' : ''; $('#stor').innerHTML = row(ok ? 'Data is saved on a volume' : 'Data is NOT being kept safely', anon ? 'This is a temporary Docker volume (' + esc(i.source.name) + '…), NOT your host folder. It is deleted when the container is recreated, and your folder stays empty. Check that your stack mounts the host folder at ' + esc(i.dir) + '.' : i.mounted === false ? 'The data folder is not a mounted volume, so passkeys, calendars and sign-ins are lost when the container is recreated. Mount a volume or a host folder at ' + esc(i.dir) + '.' : !i.writable ? 'The data folder is not writable by the app.' : esc(i.dir) + ' (mounted' + where + ')', !ok) + row('What is stored', `${i.passkeys} passkey${i.passkeys === 1 ? '' : 's'} · ${i.events} NE Hub event${i.events === 1 ? '' : 's'} · ${i.calendarLinks} calendar link${i.calendarLinks === 1 ? '' : 's'} · ${i.settings || 0} saved setting${i.settings === 1 ? '' : 's'}${i.icloud ? ' · iCloud connected' : ''}`) + row('Session secret', i.secretFromEnv ? 'Set in the stack (sign-ins survive restarts)' : 'Generated and kept in the data folder', false) + row('Running since', new Date(i.startedAt).toLocaleString(), false); }).catch(() => { const e = $('#stor'); if (e) e.innerHTML = '
Could not read storage status
'; }); const loadPks = async () => { try { const pks = await (await fetch('/api/auth/passkeys')).json(); $('#pks').innerHTML = pks.map(k => `
${esc(k.name)}Added ${new Date(k.created).toLocaleDateString()}${k.lastUsed ? ' · used ' + new Date(k.lastUsed).toLocaleDateString() : ''}
`).join('') || '
No passkeys
'; $('#pks').querySelectorAll('button[data-id]').forEach(b => b.onclick = () => confirmSheet('Remove passkey?', pks.length === 1 ? 'This is your only passkey. You would need the server setup code to enrol a new one.' : 'It will no longer sign in to NE Hub.', 'Remove', async () => { await authPost('/api/auth/passkeys', { id: b.dataset.id }, 'DELETE'); loadPks(); })); } catch (e) { $('#pks').innerHTML = `
${esc(e.message)}
`; } }; loadPks(); $('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => { try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); } }); jfetch('/api/auth/recovery').then(r => { $('#rcn').textContent = r.remaining ? r.remaining + ' recovery code' + (r.remaining === 1 ? '' : 's') + ' left' : 'No recovery codes yet'; }).catch(() => { $('#rcn').textContent = 'Recovery codes'; }); $('#rcgen').onclick = () => confirmSheet('Make new recovery codes?', 'Any codes you made before stop working.', 'Make codes', async () => { try { const { codes } = await jfetch('/api/auth/recovery', { method: 'POST', body: '{}' }); const el = openSheet('Your recovery codes', 'Each works once. They are shown only now: save them before closing.', `
${codes.map(c => `
${esc(c)}
`).join('')}
`); el.querySelector('#sx').onclick = () => { closeSheet(); viewSettings(); }; el.querySelector('#rcc').onclick = async () => { try { await navigator.clipboard.writeText(codes.join('\n')); toast('Copied'); } catch { toast('Could not copy; write them down'); } }; } catch (e) { toast(e.message); } }); if ($('#audit')) jfetch('/api/audit').then(({ events }) => { const el = $('#audit'); if (!el) return; el.innerHTML = events.length ? events.slice(0, 40).map(e => `
${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}
`).join('') : '
Nothing yet
'; }).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '
Could not load
'; }); $('#invpk').onclick = async () => { try { const { code, minutes } = await authPost('/api/auth/invite'); const el = openSheet('Invite a device', 'On the new device, open this app, tap "New device? Use an invite code", and enter:', `

${esc(code)}

Works once, and expires in ${minutes} minutes.

`); el.querySelector('#sx').onclick = closeSheet; } catch (e) { toast(e.message); } }; $('#hubout').onclick = async () => { try { mcClear(); for (const k of Object.keys(localStorage)) if (k.startsWith('dc:')) localStorage.removeItem(k); } catch {} await authPost('/api/auth/logout'); location.replace('/login'); }; const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); }; } // ---- Install: the browser's own prompt where it offers one (Chrome, Edge, Android); iPhone/iPad have to use Share let installEvt = null; addEventListener('beforeinstallprompt', e => { e.preventDefault(); installEvt = e; const b = document.getElementById('install-btn'); if (b) installInit(); }); addEventListener('appinstalled', () => { installEvt = null; const b = document.getElementById('install-box'); if (b) b.innerHTML = '

NE Hub is installed on this device.

'; }); const isInstalled = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; function installHtml() { const body = isInstalled() ? '

NE Hub is installed on this device.

' : '

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

'; return '
' + body + '
'; } function installInit() { const b = document.getElementById('install-btn'); if (!b || !installEvt) return; b.hidden = false; b.onclick = async () => { const ev = installEvt; installEvt = null; b.hidden = true; try { await ev.prompt(); } catch {} }; } // ---- Settings sections: one group at a time (General, Connections, Security, Admin), the last one is remembered on this device function settingsTabs() { const tabs = [...document.querySelectorAll('#settabs a')], groups = [...document.querySelectorAll('.sgroup')]; const empty = g => !g.textContent.trim() && !g.querySelector('input, button'); for (const g of groups) if (empty(g)) { g.remove(); const a = tabs.find(x => x.dataset.g === g.dataset.g); if (a) a.remove(); } const left = [...document.querySelectorAll('#settabs a')]; if (left.length < 2) { const b = $('#settabs'); if (b) b.hidden = true; } const show = k => { if (!left.some(a => a.dataset.g === k)) k = left[0] && left[0].dataset.g; left.forEach(a => { a.classList.toggle('on', a.dataset.g === k); a.setAttribute('aria-selected', a.dataset.g === k); }); document.querySelectorAll('.sgroup').forEach(g => { g.hidden = g.dataset.g !== k; }); try { store.set('settings_tab', k); } catch {} }; left.forEach(a => a.onclick = e => { e.preventDefault(); show(a.dataset.g); $('#view').scrollTop = 0; }); show(store.get('settings_tab', 'general')); }