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).
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) => `
`;
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 = '
${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.', `
';
}).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 = '