Multiple people: an admin adds users and chooses which tabs each may use
build-container / build (push) Successful in 2m20s
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:
1 parent
489ea0aaf0
commit
f18d9dd7a9
16 files changed
+703
-165
No files matched your search
@@ -0,0 +1,93 @@
|
||||
// People who can sign in, what each may use, and a private data folder for each.
|
||||
// The first account (the admin) is created by the setup code or by migrating a single-user install; the admin adds everyone else.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { AsyncLocalStorage } = require('async_hooks');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const USERS_DIR = path.join(DATA_DIR, 'users');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
|
||||
const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'calendar', 'tasks', 'search'];
|
||||
const ALWAYS = ['home', 'settings'];
|
||||
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audio', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' };
|
||||
|
||||
let cache = null;
|
||||
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
|
||||
const save = () => { const tmp = USERS_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 }); fs.renameSync(tmp, USERS_FILE); };
|
||||
|
||||
// ---- The person making the current request, available to any module without passing it around
|
||||
const als = new AsyncLocalStorage();
|
||||
const runAs = (user, fn) => als.run({ user }, fn);
|
||||
const current = () => (als.getStore() || {}).user || null;
|
||||
const dirOf = id => { const d = path.join(USERS_DIR, id); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };
|
||||
const dir = () => { const u = current(); if (!u) throw new Error('No signed-in user'); return dirOf(u.id); };
|
||||
|
||||
const all = () => load().users;
|
||||
const get = id => all().find(u => u.id === id) || null;
|
||||
const firstAdmin = () => all().find(u => u.role === 'admin' && !u.disabled) || null;
|
||||
const adminCount = () => all().filter(u => u.role === 'admin' && !u.disabled).length;
|
||||
const cleanTabs = t => [...new Set((Array.isArray(t) ? t : []).filter(x => TABS.includes(x)))];
|
||||
const cleanName = n => String(n || '').replace(/\s+/g, ' ').trim().slice(0, 40);
|
||||
|
||||
function create({ name, role = 'user', tabs = [], handle }) {
|
||||
const n = cleanName(name); if (!n) throw new Error('Give the person a name');
|
||||
if (all().some(u => u.name.toLowerCase() === n.toLowerCase())) throw new Error('Someone with that name already exists');
|
||||
const u = { id: 'u' + crypto.randomBytes(5).toString('hex'), name: n, role: role === 'admin' ? 'admin' : 'user', tabs: role === 'admin' ? [...TABS] : cleanTabs(tabs),
|
||||
handle: handle || crypto.randomBytes(32).toString('base64url'), epoch: 1, disabled: false, created: Date.now() };
|
||||
all().push(u); save(); dirOf(u.id);
|
||||
return u;
|
||||
}
|
||||
|
||||
function update(id, patch) {
|
||||
const u = get(id); if (!u) throw new Error('Person not found');
|
||||
if (patch.name !== undefined) {
|
||||
const n = cleanName(patch.name); if (!n) throw new Error('Give the person a name');
|
||||
if (all().some(x => x.id !== id && x.name.toLowerCase() === n.toLowerCase())) throw new Error('Someone with that name already exists');
|
||||
u.name = n;
|
||||
}
|
||||
if (patch.tabs !== undefined) u.tabs = cleanTabs(patch.tabs);
|
||||
if (patch.role !== undefined) {
|
||||
const role = patch.role === 'admin' ? 'admin' : 'user';
|
||||
if (u.role === 'admin' && role !== 'admin' && adminCount() <= 1) throw new Error('There must be at least one admin');
|
||||
u.role = role; if (role === 'admin') u.tabs = [...TABS];
|
||||
}
|
||||
if (patch.disabled !== undefined) {
|
||||
const d = !!patch.disabled;
|
||||
if (d && u.role === 'admin' && adminCount() <= 1) throw new Error('There must be at least one admin');
|
||||
u.disabled = d; u.epoch++; // disabling also signs them out everywhere
|
||||
}
|
||||
if (patch.signOutEverywhere) u.epoch++;
|
||||
save(); return u;
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const u = get(id); if (!u) throw new Error('Person not found');
|
||||
if (u.role === 'admin' && adminCount() <= 1) throw new Error('There must be at least one admin');
|
||||
cache.users = all().filter(x => x.id !== id); save();
|
||||
fs.rmSync(path.join(USERS_DIR, id), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab));
|
||||
const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))];
|
||||
const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created });
|
||||
|
||||
// ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings,
|
||||
// iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.)
|
||||
const LEGACY = ['settings.json', 'tasks.json', 'events.json', 'feed.json', 'calendars.json', 'icloud.json'];
|
||||
function migrateLegacy() {
|
||||
if (fs.existsSync(USERS_FILE)) return;
|
||||
let legacy = null; try { legacy = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'passkeys.json'), 'utf8')); } catch {}
|
||||
const hasFiles = LEGACY.some(f => fs.existsSync(path.join(DATA_DIR, f)));
|
||||
if (!(legacy && legacy.creds && legacy.creds.length) && !hasFiles) return; // fresh install: the setup code creates the first admin
|
||||
const admin = create({ name: 'Admin', role: 'admin', handle: legacy && legacy.userId });
|
||||
const d = dirOf(admin.id);
|
||||
for (const f of LEGACY) { const src = path.join(DATA_DIR, f); if (fs.existsSync(src) && !fs.existsSync(path.join(d, f))) fs.copyFileSync(src, path.join(d, f)); }
|
||||
console.log(`[users] Migrated the existing single-user data to the admin account (${admin.id}). The original files in ${DATA_DIR} were left in place as a backup.`);
|
||||
}
|
||||
migrateLegacy();
|
||||
|
||||
module.exports = { TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };
|
||||
Reference in new issue
Block a user