build-container / build (push) Successful in 19m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
176 lines
21 KiB
JavaScript
176 lines
21 KiB
JavaScript
'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 => `<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>
|
|
${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>
|
|
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">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.</p>` : ''}
|
|
<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>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">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).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div>
|
|
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
|
<div id="sec-conn"><h2>Connections</h2><div class="list">
|
|
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : '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>' : ''}</div>
|
|
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></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 button on the Audiobooks 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>
|
|
</div><div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
|
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
|
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
|
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
|
|
remindersInit();
|
|
// 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
|
|
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) => `<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) + '"' : '';
|
|
$('#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 = '<div class="row"><small>Could not read storage status</small></div>'; });
|
|
const loadPks = async () => {
|
|
try {
|
|
const pks = await (await fetch('/api/auth/passkeys')).json();
|
|
$('#pks').innerHTML = pks.map(k => `<div class="row"><span class="grow"><b>${esc(k.name)}</b><small>Added ${new Date(k.created).toLocaleDateString()}${k.lastUsed ? ' · used ' + new Date(k.lastUsed).toLocaleDateString() : ''}</small></span>
|
|
<button class="btn danger" data-id="${esc(k.id)}" style="min-height:36px">Remove</button></div>`).join('') || '<div class="row"><small>No passkeys</small></div>';
|
|
$('#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 = `<div class="row"><small class="err">${esc(e.message)}</small></div>`; }
|
|
};
|
|
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.', `<div class="list" style="background:var(--panel2);border:0">${codes.map(c => `<div class="row"><span class="grow"><b style="font-family:ui-monospace,Menlo,monospace;letter-spacing:.08em">${esc(c)}</b></span></div>`).join('')}</div>
|
|
<div class="bar"><button class="btn ghost" id="rcc" style="flex:1;justify-content:center">Copy</button><button class="btn" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
|
|
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 => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
|
|
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
|
|
$('#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:', `<p style="font:700 34px ui-monospace,monospace;letter-spacing:.08em;text-align:center;margin:16px 0">${esc(code)}</p>
|
|
<p class="sub" style="text-align:center">Works once, and expires in ${minutes} minutes.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
|
|
el.querySelector('#sx').onclick = closeSheet;
|
|
} catch (e) { toast(e.message); }
|
|
};
|
|
$('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); };
|
|
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
|
|
}
|