From 668c7241a17df9ba7026d2c2932175a3af33a7c3 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:50:17 -0400 Subject: [PATCH] Settings: General, Connections, Security and Admin tabs (last one remembered); Reminders no longer hangs when the service worker does not start Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + public/reminders.js | 4 +++- public/settings.js | 49 ++++++++++++++++++++++++++++++++------------- public/style.css | 5 +++++ 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index cb48579..29ba85a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke Office files open in Seafile's own OnlyOffice integration. - **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile. - **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume). +- **Settings** is split into General, Connections, Security and (for the admin) Admin tabs; the last one used is remembered on each device. - **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audiobooks, Podcast, Podcast Admin, Mail, Calendar, Tasks, Search, Links; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events, settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users//`). Limits are enforced by the server, not just hidden. diff --git a/public/reminders.js b/public/reminders.js index 5e696a0..27bdd7a 100644 --- a/public/reminders.js +++ b/public/reminders.js @@ -13,7 +13,9 @@ async function remindersInit() { if (!(allowed('tasks') || allowed('calendar'))) return document.getElementById('sec-rem').remove(); if (!remSupported()) { box.innerHTML = `
Not available here${/iPhone|iPad/.test(navigator.userAgent) ? 'On iPhone and iPad, add NE Hub to the Home Screen first (Share → Add to Home Screen), then open it from there.' : 'This browser does not support push notifications.'}
`; return; } let info; try { info = await jfetch('/api/push/key'); } catch (e) { box.innerHTML = `
${esc(e.message)}
`; return; } - const reg = await navigator.serviceWorker.ready, sub = await reg.pushManager.getSubscription(); + const reg = await Promise.race([navigator.serviceWorker.ready, new Promise(ok => setTimeout(ok, 4000))]); // (never ready: the browser refused the service worker) + if (!reg) { box.innerHTML = '
Not available hereThe browser did not start the background service reminders need. Reload the page, or open NE Hub over https in a regular browser.
'; return; } + const sub = await reg.pushManager.getSubscription(); const on = !!sub && Notification.permission === 'granted'; const post = (p, body) => jfetch('/api/push/' + p, { method: 'POST', body: JSON.stringify({ tz: myTz(), ...body }) }); box.innerHTML = ` diff --git a/public/settings.js b/public/settings.js index 5499f01..3ccd182 100644 --- a/public/settings.js +++ b/public/settings.js @@ -67,16 +67,13 @@ function viewSettings() { 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

- ${ME ? `

Signed in as

${esc(ME.name)}${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}
` : ''} - ${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.

` : ''} -

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 ? `

Activity

Loading…
` : ''} -

Connections

+
${[['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)}
@@ -88,10 +85,17 @@ function viewSettings() {
-

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()}
`; +
+

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 @@ -99,6 +103,7 @@ function viewSettings() { 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'; } @@ -190,3 +195,19 @@ function installInit() { 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')); +} diff --git a/public/style.css b/public/style.css index 505b6da..b544853 100644 --- a/public/style.css +++ b/public/style.css @@ -734,3 +734,8 @@ body.nav-mini .ngrp span { display: none; } .homeapps { order: 4; } .homeapps h2:first-child { margin-top: 18px; } } + +#settabs { margin: 12px 0 0; } +.sgroup[hidden] { display: none; } +.sgroup > h2:first-child, .sgroup > div:first-child > h2:first-child { margin-top: 20px; } +@media (max-width: 560px) { #settabs { display: flex; } #settabs a { flex: 1; justify-content: center; padding: 6px 8px; } }