'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

Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.

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…

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/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')); }