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).
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 = '
${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 = '