Multiple people: an admin adds users and chooses which tabs each may use
build-container / build (push) Successful in 2m20s

- Accounts: the setup code (or an existing single-user install, migrated automatically) creates the admin;
  the admin adds people in Settings -> People and gives each a one-time invite code to enrol their own passkey
- Each person's tasks, NE Hub events and subscribe link, settings, calendar links, iCloud connection and Plex
  sign-in are private, stored in their own folder (/data/users/<id>/); Plex sign-in moved from browser cookies
  into that folder (encrypted), so it also follows them across devices
- Tab access is enforced by the server (403) and reflected in the app (nav, Home, Settings, search, /api/config
  hides addresses of tabs a person cannot use); browser storage is namespaced per person
- Admin tools: change tabs, invite a device, sign out everywhere (session epoch), turn off, make/remove admin,
  delete a person and their data; the last admin cannot be removed
- Sessions carry the person (uid.epoch.expiry.signature); old single-user cookies still work as the admin
- tools/users-test.js (83 checks: isolation, access limits, invites, sign-out/disable/delete, Plex and iCloud
  per person, migration) now runs in the Gitea workflow before the image is built

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 16:35:14 -04:00
1 parent 489ea0aaf0
commit f18d9dd7a9
16 files changed
+703 -165

No files matched your search

+124 -38
View File
@@ -23,18 +23,26 @@ function flushSettings() {
}
const queueSetting = (k, v) => { if (!SYNCED.has(k)) return; outbox.set(k, v); clearTimeout(outTimer); outTimer = setTimeout(flushSettings, 400); };
addEventListener('pagehide', () => { if (outbox.size) flushSettings(); });
// Several people can use the same browser, so everything kept in the browser is filed under the signed-in person.
let ME = null;
const nk = k => (ME ? ME.id + ':' + k : k);
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} queueSetting(k, v); },
del(k) { try { localStorage.removeItem(k); } catch {} queueSetting(k, null); },
get(k, d) { try { let v = localStorage.getItem(nk(k)); if (v === null && ME && ME.role === 'admin') v = localStorage.getItem(k); /* kept from before accounts existed */ return JSON.parse(v) ?? d; } catch { return d; } },
set(k, v) { try { localStorage.setItem(nk(k), JSON.stringify(v)); } catch {} queueSetting(k, v); },
del(k) { try { localStorage.removeItem(nk(k)); if (ME && ME.role === 'admin') localStorage.removeItem(k); } catch {} queueSetting(k, null); },
};
async function loadMe() {
try { const r = await fetch('/api/me'); if (r.ok) { ME = await r.json(); try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} } }
catch { try { ME = JSON.parse(localStorage.getItem('me_cache')); } catch {} } // offline: the last known access (the server still decides)
}
const allowed = id => !ME || ME.tabs.includes(id);
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
async function loadSettings() {
try {
const r = await fetch('/api/settings'); if (!r.ok) return;
const { values } = await r.json();
for (const k of SYNCED) {
if (k in values) { try { localStorage.setItem(k, JSON.stringify(values[k])); } catch {} }
if (k in values) { try { localStorage.setItem(nk(k), JSON.stringify(values[k])); } catch {} }
else { const local = store.get(k); if (local !== undefined && local !== null) queueSetting(k, local); }
}
} catch {} // offline: keep using what this browser has
@@ -134,17 +142,18 @@ function renderNav(active = activeTab) {
activeTab = active;
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span></a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
const mine = TABS.filter(t => allowed(t[0]));
if (phone.matches) {
const more = !PRIMARY.includes(active);
$('#nav').innerHTML = TABS.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
`<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>`;
$('#more').onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + TABS.map(tab).join('');
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + mine.map(tab).join('');
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0])).map(([id, label]) => `
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => `
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${icon('chev', 'chev')}</a>`).join('')}</div>`);
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
@@ -153,7 +162,8 @@ const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewO
let editor;
function route() {
const [, id = 'home', ...rest] = location.hash.split('/');
const name = routes[id] ? id : 'home';
let name = routes[id] ? id : 'home';
if (name === 'edit' ? !(allowed('files') || allowed('office')) : !allowed(name)) { name = 'home'; if (location.hash !== '#/home') history.replaceState(null, '', '#/home'); } // not one of their tabs
try { editor?.destroyEditor(); } catch {}
editor = null;
document.body.classList.toggle('editing', name === 'edit');
@@ -176,30 +186,31 @@ function viewHome() {
const recents = store.get('recents', []).slice(0, 5);
const t = typeof cur === 'function' ? cur() : null;
$('#view').innerHTML = `<div class="wrap"><h1>${greet}</h1>
<a class="searchbar" href="#/search">${icon('search')}<span>Search files, music, libraries</span></a>
${t ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
${t && allowed('music') ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
<div id="tcard"></div>
<div id="agenda"></div>
${recents.length ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
<h2>Apps</h2><div class="grid">
${tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile')}
${tile('#/music', '#e5a00d', 'music', 'Music', 'Plex')}
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
${tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts')}
${tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true)}
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
${allowed('files') ? tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile') : ''}
${allowed('music') ? tile('#/music', '#e5a00d', 'music', 'Music', 'Plex') : ''}
${allowed('tasks') ? tile('#/tasks', '#4f7cff', 'check', 'Tasks', 'To-do lists') : ''}
${allowed('calendar') ? tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda') : ''}
${allowed('office') ? tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice') : ''}
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks') : ''}
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts') : ''}
</div>
<h2>Mail</h2><div class="grid">
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
</div></div>`;
</div>` : ''}</div>`;
const np = $('#hnp'); if (np) np.onclick = openNow;
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
});
renderTasksCard($('#tcard'));
if (typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
if (allowed('tasks')) renderTasksCard($('#tcard'));
if (allowed('calendar') && typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
}
// Name search by listing each library recursively (cached for the session).
@@ -1104,39 +1115,113 @@ function openNow() {
syncUI();
}
// ---- 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 => `<button class="row" data-p="${esc(u.id)}"><span class="ic" style="background:${u.role === 'admin' ? '#4f7cff' : '#6a7280'}">${esc(u.name.slice(0, 1).toUpperCase())}</span>
<span class="grow"><b>${esc(u.name)}${u.id === ME.id ? ' (you)' : ''}${u.role === 'admin' ? ' · Admin' : ''}${u.disabled ? ' · Off' : ''}</b>
<small style="white-space:normal">${u.passkeys ? u.passkeys + ' passkey' + (u.passkeys === 1 ? '' : 's') : 'No passkey yet (waiting for the invite code)'} · ${u.role === 'admin' ? 'Everything' : u.tabsChosen.map(t => (peopleTabs.find(x => x.id === t) || {}).label || t).join(', ') || 'Home and Settings only'}</small></span>${icon('chev', 'chev')}</button>`).join('');
$('#people').querySelectorAll('button[data-p]').forEach(b => b.onclick = () => personSheet(r.users.find(u => u.id === b.dataset.p)));
} catch (e) { box.innerHTML = `<div class="row"><small class="err">${esc(e.message)}</small></div>`; }
}
const tabBoxes = (chosen, disabled) => `<div class="list" style="background:var(--panel2);border:0">${peopleTabs.map(t => `<label class="row" style="cursor:pointer"><span class="grow"><b>${esc(t.label)}</b></span>
<input type="checkbox" data-tab="${esc(t.id)}" ${chosen.includes(t.id) ? 'checked' : ''} ${disabled ? 'disabled' : ''}></label>`).join('')}</div>`;
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, '', `<p class="sub">On ${esc(name)}'s device, open <b>${esc(location.origin)}</b>, tap <b>New device? Use an invite code</b> on the sign-in page, and enter:</p>
<p style="font:700 34px ui-monospace,monospace;letter-spacing:.08em;text-align:center;margin:14px 0">${esc(code)}</p>
<p class="sub" style="text-align:center">It works once and expires in ${minutes} minutes. They will then create their own passkey.</p>
<div class="bar"><button class="btn ghost" id="cc" style="flex:1;justify-content:center">${icon('copy')}Copy code</button><button class="btn" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
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', '', `<form id="pf"><label for="pn">Name</label><input type="text" id="pn" autocomplete="off" required placeholder="Sam">
<label>What can they use?</label>${tabBoxes(['tasks', 'calendar', 'search'])}
<p class="sub" style="margin:8px 0 0">They always get Home and Settings, and their own private tasks, events and settings. You can change this later.</p>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="pe"></div></form>`);
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', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required>
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ue"></div></form>
<div class="list" style="background:var(--panel2);border:0;margin-top:6px">
<button class="row" id="u-inv">${icon('copy')}<span class="grow"><b>Invite code for a new device</b><small>Also the way back in if they lose their device</small></span></button>
<button class="row" id="u-out">${icon('back')}<span class="grow"><b>Sign out everywhere</b></span></button>
${self ? '' : `<button class="row" id="u-off">${icon('more')}<span class="grow"><b>${u.disabled ? 'Turn back on' : 'Turn off'}</b><small>${u.disabled ? 'Let them sign in again' : 'Signs them out and blocks sign-in; their data is kept'}</small></span></button>`}
<button class="row" id="u-role">${icon('check')}<span class="grow"><b>${admin ? 'Remove admin' : 'Make admin'}</b></span></button>
${self ? '' : `<button class="row dng" id="u-del">${icon('trash')}<span class="grow"><b>Delete person and their data</b></span></button>`}</div>`);
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 = `<div class="wrap"><h1>Settings</h1>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>` : ''}
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Connections</h2><div class="list">
<div class="row"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
<div class="row"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
<div id="sec-conn"><h2>Connections</h2><div class="list">
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
</div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}
<h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.</p>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
<div id="sec-mail"><h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.</p>
<div class="card" style="max-width:none">${MAIL.filter(m => m.id !== 'icloud').map(m => `<label for="h-${m.id}">${esc(m.name)}</label>
<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}">`).join('')}
<p class="sub" style="margin-top:12px">iCloud Mail can't take a hint; use Keychain autofill or stay signed in.</p></div>
<h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label>
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
</div><h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
// only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); 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
document.querySelectorAll('input[data-id]').forEach(i => i.oninput = () => {
const h = mailHints(); const v = i.value.trim(); if (v) h[i.dataset.id] = v; else delete h[i.dataset.id]; store.set('mail_hints', h);
});
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
$('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb;
jfetch('/api/plex/status').then(st => {
if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; }
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 : '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'; });
jfetch('/api/system').then(i => {
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) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
const where = !i.source ? '' : i.source.kind === 'folder' ? ' · host folder ' + esc(i.source.name) : i.source.kind === 'named' ? ' · volume "' + esc(i.source.name) + '"' : '';
@@ -1450,6 +1535,7 @@ function dirSheet(repo, path, name, dir) {
// ---- Boot
(async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
await loadMe();
await loadSettings();
addEventListener('hashchange', route);
restorePlayer();