Passwords tab: unlock your Vaultwarden vault through the Bitwarden CLI, search, view and copy entries
build-container / build (push) Successful in 5m34s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-29 10:56:09 -04:00
1 parent d9d3743816
commit af939869a7
9 files changed
+223 -9

No files matched your search

+3
View File
@@ -7,6 +7,9 @@ ONLYOFFICE_URL=https://onlyoffice.necloud.us
# Optional: keeps Plex/session cookies valid across restarts. Any long random string. # Optional: keeps Plex/session cookies valid across restarts. Any long random string.
COOKIE_SECRET= COOKIE_SECRET=
AUDIOBOOKS_URL=https://audiobooks.necloud.us AUDIOBOOKS_URL=https://audiobooks.necloud.us
# Passwords tab: your Vaultwarden / Bitwarden server (leave empty to switch the tab off), and minutes of inactivity before a vault locks itself
VAULT_URL=https://key.necloud.us
VAULT_IDLE_MIN=10
# First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists. # First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists.
HUB_SETUP_CODE= HUB_SETUP_CODE=
VERSE_URL=https://versebyversewithnate.us/admin VERSE_URL=https://versebyversewithnate.us/admin
+2
View File
@@ -4,6 +4,8 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY *.js ./ COPY *.js ./
COPY public ./public COPY public ./public
# The Bitwarden command-line tool, used by the Passwords tab to read each person's Vaultwarden vault
RUN npm install -g @bitwarden/cli@2024.9.0 && npm cache clean --force
ENV PORT=8080 DATA_DIR=/data ENV PORT=8080 DATA_DIR=/data
RUN mkdir -p /data && chown node:node /data RUN mkdir -p /data && chown node:node /data
VOLUME /data VOLUME /data
+2
View File
@@ -15,6 +15,8 @@ services:
SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us} SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us}
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us} ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us}
AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us} AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us}
VAULT_URL: ${VAULT_URL:-https://key.necloud.us} # Passwords tab (Vaultwarden through the bundled Bitwarden CLI); leave empty to switch it off
VAULT_IDLE_MIN: ${VAULT_IDLE_MIN:-10}
AUDIOBOOKS_LOGIN_LINK: ${AUDIOBOOKS_LOGIN_LINK:-} AUDIOBOOKS_LOGIN_LINK: ${AUDIOBOOKS_LOGIN_LINK:-}
AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-} AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-}
AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-} AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-}
+7 -6
View File
@@ -109,6 +109,7 @@ const ICONS = {
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>', book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>', mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
plus: '<path d="M12 5v14M5 12h14"/>', plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
link: '<path d="M10 14a4 4 0 005.7 0l3-3a4 4 0 00-5.7-5.7l-1 1M14 10a4 4 0 00-5.7 0l-3 3a4 4 0 005.7 5.7l1-1"/>', link: '<path d="M10 14a4 4 0 005.7 0l3-3a4 4 0 00-5.7-5.7l-1 1M14 10a4 4 0 00-5.7 0l-3 3a4 4 0 005.7 5.7l1-1"/>',
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>', check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>', trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
@@ -173,8 +174,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation // ---- Navigation
const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['vault', 'Passwords'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' }; const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', vault: 'key', planner: 'cal' };
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */ /** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id)); const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
const sgInChat = () => allowed('chat') && allowed('signal'); // with both, Signal conversations are in the Chat list and there is one entry, not two const sgInChat = () => allowed('chat') && allowed('signal'); // with both, Signal conversations are in the Chat list and there is one entry, not two
@@ -186,14 +187,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS)
const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0])); const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0]));
const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1]; const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1];
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */ /** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]]; const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'vault', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
const navGroups = (tabs = navTabs()) => { const navGroups = (tabs = navTabs()) => {
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) })); const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0]))); const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
return out.filter(g => g.tabs.length); return out.filter(g => g.tabs.length);
}; };
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' }; const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', vault: '#1f6fb2', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
// The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More". // The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More".
const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail']; const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail'];
const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); }; const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); };
@@ -265,7 +266,7 @@ function drawSubnav(name) {
const navIdOf = name => name === 'signal' && sgInChat() ? 'chat' : name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name; const navIdOf = name => name === 'signal' && sgInChat() ? 'chat' : name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
let curRoute = ''; let curRoute = '';
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, vault: viewVault, settings: viewSettings, edit: viewEdit });
let editor; let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -347,7 +348,7 @@ function homeApps() {
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'), mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
}; };
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible'); T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]]; const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'vault', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id) && !(id === 'signal' && sgInChat()))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join(''); return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id) && !(id === 'signal' && sgInChat()))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
} }
+1
View File
@@ -83,6 +83,7 @@
<script defer src="/recipes-view.js"></script> <script defer src="/recipes-view.js"></script>
<script defer src="/pantry-view.js"></script> <script defer src="/pantry-view.js"></script>
<script defer src="/contacts-view.js"></script> <script defer src="/contacts-view.js"></script>
<script defer src="/vault-view.js"></script>
<script defer src="/bible-view.js"></script> <script defer src="/bible-view.js"></script>
<script defer src="/devotion-view.js"></script> <script defer src="/devotion-view.js"></script>
<script defer src="/save-to-files.js"></script> <script defer src="/save-to-files.js"></script>
+87
View File
@@ -0,0 +1,87 @@
'use strict';
// ---- Passwords: your Vaultwarden vault. The vault stays end-to-end encrypted; unlocking asks for your master password, which goes to the server's
// Bitwarden tool only to open it for a few idle minutes. Lists never carry passwords: a password, code or note is fetched only when you open that entry.
const vtReq = async (p, body) => {
const r = await fetch('/api/vault' + p, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), j, { status: r.status });
return j;
};
const vtState = { q: '', folder: '', items: null, folders: [] };
const vtType = { 1: 'Login', 2: 'Note', 3: 'Card', 4: 'Identity' };
let vtClip = 0;
/** Copy something private, and clear the clipboard again after half a minute if it is still ours. */
async function vtCopy(text, what) {
try { await navigator.clipboard.writeText(text); toast(what + ' copied (cleared in 30 seconds)'); clearTimeout(vtClip); vtClip = setTimeout(async () => { try { if ((await navigator.clipboard.readText()) === text) await navigator.clipboard.writeText(''); } catch { try { await navigator.clipboard.writeText(''); } catch {} } }, 30000); }
catch { toast('Could not copy'); }
}
const vtHost = u => { try { return new URL(/^[a-z]+:\/\//i.test(u) ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch { return ''; } };
const vtSafeUrl = u => { try { const x = new URL(/^[a-z]+:\/\//i.test(u) ? u : 'https://' + u); return /^https?:$/.test(x.protocol) ? x.href : ''; } catch { return ''; } };
async function viewVault() {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Passwords</h1><p class="empty loading" role="status">Loading…</p></div>';
let st; try { st = await vtReq('/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Passwords</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/vault')) return;
st.unlocked ? vtList(st) : vtUnlock(st);
}
function vtUnlock(st, need2fa) {
const v = $('#view'), host = vtHost((typeof cfg !== 'undefined' && cfg.vault) || st.url);
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Passwords</h1></div>
<form id="vtf" class="card" style="max-width:420px;display:grid;gap:10px" autocomplete="off">
<p class="sub" style="margin:0">Unlock your vault on <b>${esc(host)}</b>. Your master password is only used to open it and is never saved.</p>
${st.loggedIn ? `<p class="sub" style="margin:0">Signed in as <b>${esc(st.email)}</b></p>` : '<label for="vte">Email address</label><input type="email" id="vte" autocomplete="username" autocapitalize="off" required>'}
<label for="vtp">Master password</label><input type="password" id="vtp" autocomplete="current-password" required>
${need2fa ? '<label for="vtc">Authenticator code</label><input type="text" id="vtc" inputmode="numeric" autocomplete="one-time-code" maxlength="8" required>' : ''}
<p class="err" id="vterr" role="alert"></p>
<div class="bar" style="margin:0"><button class="btn" id="vtgo">Unlock</button>${st.loggedIn ? '<button type="button" class="btn ghost" id="vtout">Use a different account</button>' : ''}</div>
<p class="sub" style="margin:0">No account yet? <a href="${esc(vtSafeUrl(st.url) ? st.url.replace(/\/+$/, '') + '/#/signup' : '#')}" target="_blank" rel="noopener" style="color:var(--accent)">Create one on ${esc(host)}</a>, then come back here to unlock it.</p>
</form></div>`;
const f = $('#vtf'); setTimeout(() => (f.querySelector(need2fa ? '#vtc' : st.loggedIn ? '#vtp' : '#vte') || {}).focus?.(), 0);
if ($('#vtout')) $('#vtout').onclick = () => confirmSheet('Use a different account?', 'This signs the hub out of the vault. Nothing is deleted from the vault itself.', 'Sign out of vault', async () => { try { await vtReq('/forget', {}); viewVault(); } catch (e) { toast(e.message); } });
f.onsubmit = async e => {
e.preventDefault(); $('#vterr').textContent = ''; $('#vtgo').disabled = true;
try { const body = { password: $('#vtp').value }; if ($('#vte')) body.email = $('#vte').value; if ($('#vtc')) body.code = $('#vtc').value; const r = await vtReq('/unlock', body); $('#vtp').value = ''; vtState.items = null; vtList(r); }
catch (err) { if (err.need2fa) return vtUnlock({ ...st, email: st.email || ($('#vte') && $('#vte').value) }, true) || void 0; $('#vterr').textContent = err.message; $('#vtgo').disabled = false; }
};
}
async function vtList(st) {
const v = $('#view');
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Passwords</h1><span class="pspace grow"></span><button class="btn ghost" id="vtsync" aria-label="Refresh from the server" title="Refresh">${icon('refresh')}</button><button class="btn ghost" id="vtlock">Lock</button></div>
<input type="search" id="vtq" placeholder="Search your vault" value="${esc(vtState.q)}" autocomplete="off" aria-label="Search your vault" style="width:100%;margin-bottom:8px"><div id="vtfo"></div><div id="vtl"><p class="empty loading" role="status">Loading…</p></div>
<p class="sub">Locks itself after a few idle minutes. <a href="${esc(vtSafeUrl(st.url) || '#')}" target="_blank" rel="noopener" style="color:var(--accent)">Open the full vault</a> to add or change entries.</p></div>`;
const lockedOut = () => { vtState.items = null; vtUnlock({ ...st, unlocked: false }); };
$('#vtlock').onclick = async () => { try { await vtReq('/lock', {}); } catch {} lockedOut(); toast('Vault locked'); };
$('#vtsync').onclick = async () => { try { await vtReq('/sync', {}); await load(); toast('Refreshed'); } catch (e) { e.locked ? lockedOut() : toast(e.message); } };
let timer; $('#vtq').oninput = e => { vtState.q = e.target.value; clearTimeout(timer); timer = setTimeout(load, 250); };
async function load() {
try { const r = await vtReq('/items?q=' + encodeURIComponent(vtState.q.trim())); if (!location.hash.startsWith('#/vault')) return; vtState.items = r.items; vtState.folders = r.folders; draw(); }
catch (e) { if (e.locked) return lockedOut(); $('#vtl').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
function draw() {
const fo = vtState.folders; $('#vtfo').innerHTML = fo.length ? `<div class="seg" role="tablist"><a href="#" data-f="" class="${vtState.folder ? '' : 'on'}">All</a>${fo.map(f => `<a href="#" data-f="${esc(f.id)}" class="${vtState.folder === f.id ? 'on' : ''}">${esc(f.name)}</a>`).join('')}</div>` : '';
$('#vtfo').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); vtState.folder = a.dataset.f; draw(); });
const rows = vtState.items.filter(i => !vtState.folder || i.folderId === vtState.folder);
$('#vtl').innerHTML = rows.length ? `<div class="list">${rows.map(i => `<button class="row" data-id="${esc(i.id)}"><span class="ic" style="background:${i.type === 1 ? '#1f6fb2' : i.type === 3 ? '#b25a1f' : '#6a7280'}">${icon(i.type === 1 ? 'key' : i.type === 3 ? 'store' : 'doc')}</span><span class="grow"><b>${esc(i.name)}</b><small>${esc(i.username || vtHost(i.uri) || vtType[i.type] || '')}</small></span>${i.favorite ? '<span aria-label="Favorite">★</span>' : ''}</button>`).join('')}</div>` : `<p class="empty">${vtState.q ? 'Nothing matches.' : 'Your vault is empty.'}</p>`;
$('#vtl').querySelectorAll('.row').forEach(b => b.onclick = () => vtOpen(b.dataset.id, lockedOut));
}
if (vtState.items) draw(); load();
}
async function vtOpen(id, lockedOut) {
let it; try { it = await vtReq('/item/' + encodeURIComponent(id)); } catch (e) { return e.locked ? lockedOut() : toast(e.message); }
const field = (label, value, key, secret) => value ? `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">${esc(label)}</small><div style="display:flex;gap:8px;align-items:center"><span class="grow vtv" data-k="${key}" style="word-break:break-all;font-family:${secret ? 'ui-monospace,monospace' : 'inherit'}">${secret ? '••••••••••' : esc(value)}</span>${secret ? `<button class="btn ghost vtshow" data-k="${key}">Show</button>` : ''}<button class="btn ghost vtcp" data-k="${key}" data-w="${esc(label)}">Copy</button></div></div>` : '';
const vals = { user: it.username, pass: it.password, totp: it.totp };
const extra = []; if (it.card) { vals.cnum = it.card.number; vals.ccode = it.card.code; }
(it.fields || []).forEach((f, n) => { vals['f' + n] = f.value; });
const links = it.uris.map(vtSafeUrl).filter(Boolean);
const el = openSheet(it.name || 'Entry', vtType[it.type] || '', `${field('Username', it.username, 'user')}${field('Password', it.password, 'pass', true)}${field('One-time code', it.totp, 'totp')}
${it.card ? field('Cardholder', it.card.holder, 'chold') + field('Card number', it.card.number, 'cnum', true) + field('Security code', it.card.code, 'ccode', true) + field('Expires', [it.card.expMonth, it.card.expYear].filter(Boolean).join('/'), 'cexp') : ''}
${(it.fields || []).map((f, n) => field(f.name || 'Field', f.value, 'f' + n, f.hidden)).join('')}
${links.map(u => `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">Website</small><div><a href="${esc(u)}" target="_blank" rel="noopener" style="color:var(--accent);word-break:break-all">${esc(u)}</a></div></div>`).join('')}
${it.notes ? `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">Notes</small><div style="white-space:pre-wrap">${esc(it.notes)}</div></div>` : ''}`);
Object.assign(vals, { chold: it.card && it.card.holder, cexp: it.card && [it.card.expMonth, it.card.expYear].filter(Boolean).join('/') });
el.querySelectorAll('.vtcp').forEach(b => b.onclick = () => vtCopy(vals[b.dataset.k], b.dataset.w));
el.querySelectorAll('.vtshow').forEach(b => b.onclick = () => { const s = el.querySelector(`.vtv[data-k="${b.dataset.k}"]`), on = b.textContent === 'Show'; s.textContent = on ? vals[b.dataset.k] : '••••••••••'; b.textContent = on ? 'Hide' : 'Show'; });
}
+115
View File
@@ -0,0 +1,115 @@
// Passwords (not vault.js, which encrypts stored tokens): each person's Vaultwarden / Bitwarden passwords, through the Bitwarden command-line tool (`bw`).
// The vault is end-to-end encrypted, so the hub only ever holds what a person types in to unlock it: the master password goes to `bw` through its
// environment (never the command line, never disk), and the session key `bw` hands back lives in memory only and is dropped after a few idle minutes.
// Each person gets their own `bw` data folder, so one person's unlocked vault is never reachable from another's session.
const fs = require('fs'), path = require('path'), { execFile } = require('child_process');
const users = require('./users');
const { readBody } = require('./body');
const URL_ = (process.env.VAULT_URL || '').replace(/\/+$/, '');
const BW = process.env.BW_BIN || 'bw';
const IDLE_MS = Math.max(1, +process.env.VAULT_IDLE_MIN || 10) * 60e3;
const enabled = () => !!URL_;
const sessions = new Map(); // user id -> { key, last }
const chains = new Map(); // user id -> the last bw call, so one person's calls run one at a time
const fails = new Map(); // user id -> { n, until }, slows down password guessing
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message, extra) => Object.assign(new Error(message), { code, extra });
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const appdata = uid => { const d = path.join(users.dirOf(uid), 'bw'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };
/** Run `bw` as one person. Secrets travel in the environment only. */
function bw(uid, args, { session, password, code, timeout = 45e3 } = {}) {
const env = { PATH: process.env.PATH, HOME: process.env.HOME || '/tmp', BITWARDENCLI_APPDATA_DIR: appdata(uid), BW_NOINTERACTION: 'true', BW_QUIET: 'true', NODE_NO_WARNINGS: '1' };
if (session) env.BW_SESSION = session;
if (password != null) env.BW_PASSWORD = password;
const run = () => new Promise((resolve, reject) => execFile(BW, args, { env, timeout, maxBuffer: 16e6 }, (err, stdout, stderr) => {
if (err) { const e = new Error(String(stderr || err.message).trim().split('\n').pop() || 'The vault did not answer'); e.stdout = stdout; return reject(e); }
resolve(String(stdout));
}));
const prev = chains.get(uid) || Promise.resolve(), p = prev.then(run, run);
chains.set(uid, p.catch(() => {}));
return p;
}
const parse = s => { try { return JSON.parse(s); } catch { throw fail(502, 'The vault gave an unexpected answer'); } };
const lock = uid => { const s = sessions.get(uid); sessions.delete(uid); if (s) bw(uid, ['lock']).catch(() => {}); };
setInterval(() => { const now = Date.now(); for (const [uid, s] of sessions) if (now - s.last > IDLE_MS) lock(uid); }, 30e3).unref();
const keyOf = uid => { const s = sessions.get(uid); if (!s) throw fail(401, 'The vault is locked', { locked: true }); s.last = Date.now(); return s.key; };
/** A bw failure for something that needs an unlocked vault: if bw says the session is bad, treat it as locked. */
const asLocked = e => { if (!e.code && /session|locked|not logged in|unauthenticated/i.test(e.message)) return fail(401, 'The vault is locked', { locked: true }); return e; };
const brief = i => ({ id: i.id, name: i.name || '', type: i.type, favorite: !!i.favorite, folderId: i.folderId || null,
username: i.login ? i.login.username || '' : '', uri: i.login && i.login.uris && i.login.uris[0] ? i.login.uris[0].uri || '' : '', hasTotp: !!(i.login && i.login.totp) });
async function status(uid) {
let st = { status: 'unauthenticated' }; try { st = parse(await bw(uid, ['status'], { timeout: 15e3 })); } catch {}
const s = sessions.get(uid);
return { enabled: true, url: URL_, unlocked: !!s && st.status === 'unlocked', email: st.userEmail || '', loggedIn: st.status !== 'unauthenticated' };
}
async function unlock(uid, b) {
const f = fails.get(uid); if (f && f.until > Date.now()) throw fail(429, 'Too many tries. Wait a few minutes and try again.');
const password = String(b.password || ''); if (!password) throw fail(400, 'Enter your master password');
const st = await status(uid); let out;
try {
if (!st.loggedIn) {
const email = String(b.email || '').trim(); if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw fail(400, 'Enter the email address you use for the vault');
await bw(uid, ['config', 'server', URL_]);
const args = ['login', email, '--passwordenv', 'BW_PASSWORD', '--raw'];
if (b.code) args.push('--method', '0', '--code', String(b.code).replace(/\s/g, '').slice(0, 12)); // 0 = authenticator app
out = await bw(uid, args, { password });
} else out = await bw(uid, ['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'], { password });
} catch (e) {
if (e.code) throw e;
if (/two-?step|two-?factor|2fa|code/i.test(e.message) && !b.code) throw fail(400, 'Enter the 6-digit code from your authenticator app', { need2fa: true });
const n = ((fails.get(uid) || {}).n || 0) + 1; fails.set(uid, { n, until: n >= 5 ? Date.now() + 10 * 60e3 : 0 }); if (n >= 5) fails.get(uid).n = 0;
throw fail(401, /invalid|incorrect|password|username/i.test(e.message) ? 'That password did not work' : 'Could not unlock: ' + e.message);
}
fails.delete(uid);
const key = out.trim(); if (!key) throw fail(502, 'The vault did not give a session');
sessions.set(uid, { key, last: Date.now() });
await bw(uid, ['sync'], { session: key }).catch(() => {});
return status(uid);
}
async function handle(req, res) {
try {
const u = users.current(); if (!u) throw fail(401, 'Sign in first');
if (!enabled()) throw fail(404, 'The vault is not set up (VAULT_URL)');
const uid = u.id, p = new URL(req.url, 'http://x').pathname.replace(/^\/api\/vault/, '') || '/', q = new URL(req.url, 'http://x').searchParams;
if (p === '/status' && req.method === 'GET') return json(res, 200, await status(uid));
if (p === '/unlock' && req.method === 'POST') return json(res, 200, await unlock(uid, JSON.parse(await readBody(req, 1e4) || '{}')));
if (p === '/lock' && req.method === 'POST') { lock(uid); return json(res, 200, { ok: true }); }
if (p === '/forget' && req.method === 'POST') { lock(uid); await bw(uid, ['logout']).catch(() => {}); return json(res, 200, { ok: true }); } // sign this account out of the hub completely
if (p === '/sync' && req.method === 'POST') { try { await bw(uid, ['sync'], { session: keyOf(uid) }); } catch (e) { throw asLocked(e); } return json(res, 200, { ok: true }); }
if (p === '/items' && req.method === 'GET') {
const key = keyOf(uid), search = String(q.get('q') || '').slice(0, 100).trim(), args = ['list', 'items'];
if (search) args.push('--search=' + search);
try { const items = parse(await bw(uid, args, { session: key })).filter(i => i.type === 1 || i.type === 2 || i.type === 3 || i.type === 4); // login, note, card, identity
let folders = []; try { folders = parse(await bw(uid, ['list', 'folders'], { session: key })).filter(f => f.id).map(f => ({ id: f.id, name: f.name })); } catch {}
return json(res, 200, { items: items.map(brief).sort((a, b) => (b.favorite - a.favorite) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })).slice(0, 1000), folders }); }
catch (e) { throw asLocked(e); }
}
let m;
if ((m = /^\/item\/([^/]+)$/.exec(p)) && req.method === 'GET') { // one entry in full, only when a person opens it
if (!ID.test(m[1])) throw fail(400, 'Bad item');
const key = keyOf(uid);
try {
const i = parse(await bw(uid, ['get', 'item', m[1]], { session: key })), l = i.login || {};
let totp = ''; if (l.totp) { try { totp = (await bw(uid, ['get', 'totp', m[1]], { session: key })).trim(); } catch {} }
const c = i.card || {};
return json(res, 200, { id: i.id, name: i.name || '', type: i.type, notes: i.notes || '', username: l.username || '', password: l.password || '', totp,
uris: (l.uris || []).map(x => x.uri).filter(Boolean),
card: i.type === 3 ? { holder: c.cardholderName || '', brand: c.brand || '', number: c.number || '', code: c.code || '', expMonth: c.expMonth || '', expYear: c.expYear || '' } : null,
fields: (i.fields || []).filter(f => f.type !== 3).map(f => ({ name: f.name || '', value: f.value || '', hidden: f.type === 1 })) });
} catch (e) { throw asLocked(e); }
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message, ...(e.extra || {}) }); }
}
module.exports = { handle, enabled, url: () => URL_ };
+4 -1
View File
@@ -44,6 +44,7 @@ const bible = require('./bible'); // Bible: each person's place, highlights
const savefiles = require('./savefiles'); // "Save to Files": copy books, lessons and audiobooks into the person's Seafile const savefiles = require('./savefiles'); // "Save to Files": copy books, lessons and audiobooks into the person's Seafile
const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies) const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies)
const recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN) const recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN)
const pwvault = require('./pwvault'); // each person's Vaultwarden passwords, through the Bitwarden CLI (VAULT_URL)
const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export) const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export)
const activity = require('./activity'), alertParents = activity.alertParents; const activity = require('./activity'), alertParents = activity.alertParents;
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
@@ -596,6 +597,7 @@ const ACCESS = [
[/^\/api\/store\/my(\/|$)/, ['store']], [/^\/api\/store\/my(\/|$)/, ['store']],
[/^\/api\/recipes(\/|$)/, ['recipes']], [/^\/api\/recipes(\/|$)/, ['recipes']],
[/^\/api\/contacts(\/|$)/, ['contacts']], [/^\/api\/contacts(\/|$)/, ['contacts']],
[/^\/api\/vault(\/|$)/, ['vault']],
[/^\/api\/signal\//, ['signal']], [/^\/api\/signal\//, ['signal']],
[/^\/api\/lyrics$/, ['music', 'radio']], [/^\/api\/lyrics$/, ['music', 'radio']],
[/^\/api\/transcript(\/search)?$/, ['podcast', 'audio']], [/^\/api\/transcript(\/search)?$/, ['podcast', 'audio']],
@@ -663,7 +665,7 @@ async function handleRequest(req, res) {
const c = t => users.can(user, t); const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' }); res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '', return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', vault: c('vault') && pwvault.enabled() ? pwvault.url() : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
} }
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/listening/family') { if (req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').setFamilyGoal(b.goal))); } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').familyGoal())); } if (pathname === '/api/listening/family') { if (req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').setFamilyGoal(b.goal))); } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').familyGoal())); }
@@ -728,6 +730,7 @@ async function handleRequest(req, res) {
if (pathname.startsWith('/api/music/')) return musicdata.handle(req, res); if (pathname.startsWith('/api/music/')) return musicdata.handle(req, res);
if (pathname === '/api/store/my' || pathname.startsWith('/api/store/my/')) return mystore.handle(req, res); if (pathname === '/api/store/my' || pathname.startsWith('/api/store/my/')) return mystore.handle(req, res);
if (pathname === '/api/recipes' || pathname.startsWith('/api/recipes/')) return recipes.handle(req, res); if (pathname === '/api/recipes' || pathname.startsWith('/api/recipes/')) return recipes.handle(req, res);
if (pathname === '/api/vault' || pathname.startsWith('/api/vault/')) return pwvault.handle(req, res);
if (pathname === '/api/contacts' || pathname.startsWith('/api/contacts/')) return contacts.handle(req, res); if (pathname === '/api/contacts' || pathname.startsWith('/api/contacts/')) return contacts.handle(req, res);
if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res); if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res);
if (pathname.startsWith('/api/save/')) return savefiles.handle(req, res); if (pathname.startsWith('/api/save/')) return savefiles.handle(req, res);
+2 -2
View File
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
fs.mkdirSync(DATA_DIR, { recursive: true }); 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). // 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', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'chores', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'chores', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links', 'vault'];
const ALWAYS = ['home', 'family', 'settings']; const ALWAYS = ['home', 'family', 'settings'];
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', chores: 'Chores', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' }; const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', chores: 'Chores', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links', vault: 'Passwords' };
let cache = null; 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 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; };