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

+7 -4
View File
@@ -17,19 +17,22 @@ const targets = [...new Set([...src.matchAll(/return (\w+)\(req, res\)/g)].map(m
const missing = targets.filter(n => !new RegExp(`(async )?function ${n}\\b|const ${n} =`).test(src));
ok('every function the router calls is defined', !missing.length, missing);
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: 'smoke-'.repeat(8), HUB_SETUP_CODE: 'smoke', SEAFILE_URL: 'http://127.0.0.1:9' } });
// Seed one admin account so the test can sign in without a passkey ceremony (the cookie is signed with the test's own secret).
const SECRET = 'smoke-'.repeat(8), UID = 'usmoke';
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] }));
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, HUB_SETUP_CODE: 'smoke', SEAFILE_URL: 'http://127.0.0.1:9' } });
let alive = true, log = ''; child.on('exit', () => { alive = false; }); child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
const wait = ms => new Promise(r => setTimeout(r, ms));
(async () => {
for (let i = 0; i < 50; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const exp = Date.now() + 864e5, sig = crypto.createHmac('sha256', 'smoke-'.repeat(8)).update('s' + exp).digest('base64url');
const cookie = `hub_session=${exp}.${sig}`;
const exp = Date.now() + 864e5, sig = crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url');
const cookie = `hub_session=${UID}.1.${exp}.${sig}`;
const get = async (p, method = 'GET', body) => { try { const r = await fetch(base + p, { method, redirect: 'manual', headers: { cookie, 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); await r.text(); return r.status; } catch { return -1; } };
const pages = ['/', '/login', '/app.js', '/style.css', '/sw.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
const api = ['/api/config', '/api/system', '/api/settings', '/api/auth/state', '/api/auth/passkeys', '/api/tasks/lists', '/api/tasks?list=local:inbox', '/api/tasks/summary',
'/api/cal/list', '/api/cal/icloud', '/api/cal/feed', '/api/search?q=ab', '/api/search?q=a', '/api/ddg?q=einstein', '/api/ddg?q=a', '/api/plex/status',
'/api/me', '/api/users', '/api/cal/list', '/api/cal/icloud', '/api/cal/feed', '/api/search?q=ab', '/api/search?q=a', '/api/ddg?q=einstein', '/api/ddg?q=a', '/api/plex/status',
`/api/cal/events?from=${Date.now()}&to=${Date.now() + 864e5}&fd=2026-01-01&td=2026-01-02`];
const bad = [];
for (const p of [...pages, ...api]) { const st = await get(p); if (st === 500 || st === -1 || !alive) bad.push(`${p} -> ${alive ? st : 'server died'}`); }