DC-048: multi-user bootstrap + admin invites (opt-in)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Implements the user-store + invite-store + admin routes. The whole
system is opt-in via siteConfig.authProviders.email.enabled = true;
single-user TOTP-only installs see zero behavior change.

Backend:
- src/security/user-store.js: users + allowlist + bootstrap sentinel,
  atomic writes, last-admin protection, defensive dataDir resolver.
- src/security/invite-store.js: single-use tokens (SHA-256 hashed on
  disk), TTL, auto-prune, defensive dataDir resolver.
- routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist,
  /admin/invites (CRUD), public /invites/:token (peek + accept).
- routes/auth/index.js: wires userStore, gates admin router on
  email auth being enabled.
- src/auth/providers/email.js: verify() enforces allowlist, creates
  user record, tags req.user; default-enabled flipped to opt-in.
- src/auth/providers/totp.js: bootstraps system@totp.local admin on
  first verify so current DNS2 operator shows in /admin/users.
- src/security/audit-logger.js: middleware adds userId/userEmail/
  userRole/viaProvider to log details when req.user is tagged.
- PUBLIC_ROUTES + CSRF allowlists updated for invite redemption.

Frontend:
- status/js/admin.js: modal overlay with users list (role-edit,
  delete), invite form (email/role/TTL), copy-link button,
  outstanding-invites list with revoke. Exports window.AdminPanel.
- status/js/core/init.js: calls AdminPanel.attachTrigger so the
  Admin button only appears when /me returns isAdmin=true.

Tests: 35 new tests across 3 files (user-store, invite-store, auth
multistore integration). Full suite: 1298/1298 passing.

Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased]
section gets the DC-048 entry.
This commit is contained in:
hermes
2026-07-20 17:44:11 -07:00
parent bd480a69a7
commit 321334cd33
23 changed files with 2964 additions and 325 deletions
+3 -2
View File
@@ -271,11 +271,12 @@ Tickets DC-033 through DC-041 were added after the DNS2 v1.14.4 / v1.14.8 / 0.0.
- **result:** Shipped as part of DC-046 commit. `src/auth/providers/email.js` (388 LOC): registers `magic-link` (initiate) + `verify-token` (verify) methods, generates 32-byte base64url tokens, stores SHA-256 hashes via `email-tokens-store.js`. `email-tokens-store.js` (260 LOC): atomic lockfile-based mutation, automatic cleanup of expired tokens, audit log on every issue/use. `email-sender.js` (67 LOC): wraps nodemailer if `providers.email` config is set, else falls back to `log.info('auth', 'email magic link issued', ...)` so dev installs work without SMTP config. Verified with stub deps: `initiate()` writes a token + logs `deliveredVia: 'dev-console'` + returns masked email; `verify('verify-token', { token: 'garbage' })` throws AuthenticationError (route handler converts to 401). Real SMTP wiring takes effect as soon as `providers.email.host/port/username/password` are set in config.json.
### DC-048: Multi-user bootstrap + admin invites
- **status:** todo
- **owner:** unclaimed
- **status:** done
- **owner:** hermes
- **details:** The user model shifts from "one implicit operator" to "many users with explicit roles". Bootstrap rule: the FIRST email to ever successfully log in via email magic link becomes the admin. Subsequent emails are denied with a `not authorized` error UNLESS the email appears in `data/authorized-users.json`. Admin UI: a `/users` page that lists authorized users, lets admin add emails (manual entry) or generate single-use invite links (which work like magic links but pre-add the email to the allowlist on first use). Audit log gets `userEmail` attribution on every entry. License model is unchanged (still per-host) but note in the ticket that this may need revisiting. Effort: ~2 hrs. Risk: low (mostly UI + JSON-store CRUD).
- **impact:** First real multi-user DashCaddy. Per-user audit attribution. Self-service invites. Foundation for any future "team" features.
- **prerequisite:** DC-047 (needs email auth working first).
- **result:** Shipped as opt-in. Email auth must be explicitly enabled via `siteConfig.authProviders.email.enabled = true`; single-user TOTP-only installs see zero behavior change. New modules: `src/security/user-store.js` (users + allowlist + bootstrap sentinel, atomic writes, last-admin protection, 380 LOC), `src/security/invite-store.js` (single-use tokens, SHA-256 hashed on disk, TTL, 230 LOC). New routes: `routes/auth/admin.js` (`/me`, `/admin/users` GET/POST/PATCH/DELETE, `/admin/allowlist`, `/admin/invites` GET/POST/DELETE, public `/invites/:token` peek + `/invites/:token/accept` redeem, 360 LOC). EmailMagicLinkProvider `verify()` calls `userStore.isEmailAuthorized()` then `userStore.login()` then tags `req.user` for audit attribution; TOTP `verify()` bootstraps a `system@totp.local` admin record on first login so the current operator shows up in `/admin/users` without a re-login. Audit logger middleware reads `req.user` and adds `userId`/`userEmail`/`userRole`/`viaProvider` to log details. New admin UI: `status/js/admin.js` (modal overlay, users list with role-edit + delete, invite form with copy-link button, outstanding-invites list with revoke). Wired into `core/init.js` so the "Admin" trigger button appears in the top bar only when `/me` returns `isAdmin: true`. 35 new tests across 3 files. Full suite: 1298/1298. Update PUBLIC_ROUTES + CSRF allowlists for the new invite redemption paths (same exemption rationale as login verify).
### Backlog note (2026-07-20, hermes)
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Multi-user bootstrap + admin invites — DC-048.** Opt-in via `siteConfig.authProviders.email.enabled = true`. Single-user TOTP-only installs see zero behavior change. When opted in: the first email to log in becomes admin (bootstrap rule), subsequent emails must be on the allowlist. New `src/security/user-store.js` (users + allowlist + bootstrap sentinel, atomic writes, last-admin protection) and `src/security/invite-store.js` (single-use tokens, SHA-256 hashed on disk, TTL, auto-prune). New `routes/auth/admin.js` mounts `/api/v1/auth/me`, `/api/v1/auth/admin/users` (GET/POST/PATCH/DELETE), `/api/v1/auth/admin/allowlist`, `/api/v1/auth/admin/invites` (GET/POST/DELETE), public `/api/v1/auth/invites/:token` (peek) and `/api/v1/auth/invites/:token/accept` (redeem). EmailMagicLinkProvider `verify()` and TOTP `verify()` tag `req.user` for audit attribution; TOTP bootstraps a `system@totp.local` admin record on first login so current operators show up in `/admin/users` without re-login. Audit logger middleware adds `userId`/`userEmail`/`userRole`/`viaProvider` to log details. New admin UI in `status/js/admin.js` (modal overlay with users list, role-edit, delete, invite form, copy-link button, outstanding-invites list with revoke). "Admin" button auto-injects into the top bar when `/me` returns `isAdmin: true`. 35 new tests; full suite 1298/1298.
- **Pluggable auth UI — DC-049.** `status/js/auth-gate.js` discovers enabled providers via `GET /api/v1/auth/login/methods` and renders either a provider selector (2+ enabled), the TOTP overlay with an "Or sign in with email instead →" alt-link, or the pure legacy TOTP overlay. Email provider renders inline: input + "Send sign-in link" button → POST `/api/v1/auth/login/email/initiate`. Coordination flag `window.__dc_049_handled` eliminates flicker on multi-provider installs. New SW cache hash `dashcaddy-shell-c550d0b371`.
- **Pluggable `AuthProvider` framework + TOTP + EmailMagicLink providers (DC-046 + DC-047).** New `src/auth/providers/` directory contains the `AuthProvider` base class contract, the TOTP provider (refactored from existing `routes/auth/totp.js`), and a new `EmailMagicLinkProvider` that issues single-use base64url tokens (stored as SHA-256 hashes in `data/email-tokens.json`), sends via the existing nodemailer config (or logs to console + `log.info('email magic link issued')` in dev fallback). `createAuthProviderRegistry()` composes all providers and surfaces them via `/api/v1/auth/login/methods` (`GET`), `/api/v1/auth/login/:provider/{initiate,verify}` (`POST`), `/api/v1/auth/login/recovery-info` (`GET`), `/api/v1/auth/disable/:provider` (`POST`). Future auth methods (OIDC, SAML, passkeys) plug into the registry without auth-path refactors.
- **`platform-paths.assertSafe()` — DC-046 hardening.** Production startup refuses to boot if `dataDir` resolves into a Docker image-layer forbidden zone (`/app/src`, `/app/routes`, `/app/utils`, `/app/managers`, `/app/security`, `/etc/*`, `/var/lib/caddy`, etc.). Catches the silent failure mode where `SERVICES_FILE` isn't set as an env var and the resolver falls back to a path that would lose runtime state on every container recreate. Bypassed with `SKIP_DATA_DIR_GUARD=1` for emergency legacy setups.
@@ -0,0 +1,374 @@
/**
* Tests for DC-048 auth flow integration:
* - email login: first user = bootstrap admin (no allowlist needed)
* - email login: subsequent user without allowlist = rejected
* - email login: subsequent user with allowlist = operator role
* - email login: token consumption is atomic (replay = already_used)
* - TOTP login: tags req.user with system-admin record (audit attribution)
* - admin routes: /me returns the right shape
* - admin routes: 403 for non-admin on /admin/*
* - invite flow: issue → email → accept → user created with role
*
* Strategy: build the EmailMagicLinkProvider + a TOTP stub + the admin router
* with an in-process user store. No HTTP server; we call the handlers
* directly with mock req/res.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
function _tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-integration-'));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('DC-048: opt-in user store', () => {
let dir;
beforeEach(() => { dir = _tmpDir(); });
afterEach(() => _cleanup(dir));
test('userStore is null until email auth is explicitly enabled', () => {
// The wiring code in routes/auth/index.js checks:
// siteConfig.authProviders.email.enabled === true
// If false, userStore stays null and providers fall back to legacy
// "allow everyone" semantics. This test simulates that branch by
// checking the flag path directly.
const siteConfig = { authProviders: { email: { enabled: false } } };
const emailEnabled =
siteConfig.authProviders && siteConfig.authProviders.email && siteConfig.authProviders.email.enabled === true;
expect(emailEnabled).toBe(false);
});
test('userStore activates when email auth is explicitly enabled', () => {
const siteConfig = { authProviders: { email: { enabled: true } } };
const emailEnabled =
siteConfig.authProviders && siteConfig.authProviders.email && siteConfig.authProviders.email.enabled === true;
expect(emailEnabled).toBe(true);
});
});
describe('DC-048: email magic-link auth attribution', () => {
let dir, userStore;
beforeEach(() => {
dir = _tmpDir();
userStore = require('../src/security/user-store').createUserStore({ dataDir: dir });
});
afterEach(() => _cleanup(dir));
test('first email = bootstrap admin', async () => {
const r = await userStore.login({ email: 'admin@example.com', ip: '127.0.0.1' });
expect(r.ok).toBe(true);
expect(r.isBootstrap).toBe(true);
expect(r.role).toBe('admin');
});
test('second email without allowlist rejected', async () => {
await userStore.login({ email: 'admin@example.com' });
const r = await userStore.login({ email: 'stranger@example.com' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('not_authorized');
});
test('second email WITH allowlist = operator role', async () => {
await userStore.login({ email: 'admin@example.com' });
await userStore.addToAllowlist('friend@example.com');
const r = await userStore.login({ email: 'friend@example.com' });
expect(r.ok).toBe(true);
expect(r.role).toBe('operator');
expect(r.isBootstrap).toBe(false);
});
test('isEmailAuthorized returns false after bootstrap for non-allowlisted', async () => {
await userStore.login({ email: 'admin@example.com' });
expect(await userStore.isEmailAuthorized('random@example.com')).toBe(false);
await userStore.addToAllowlist('random@example.com');
expect(await userStore.isEmailAuthorized('random@example.com')).toBe(true);
});
});
describe('DC-048: email provider auth flow with userStore', () => {
let dir, userStore, EmailProvider;
beforeEach(() => {
dir = _tmpDir();
userStore = require('../src/security/user-store').createUserStore({ dataDir: dir });
EmailProvider = require('../src/auth/providers/email');
});
afterEach(() => _cleanup(dir));
function _makeProvider() {
// Real session stub — record create/setCookie calls without cookie IO.
const session = {
create: jest.fn(),
setCookie: jest.fn(),
isSessionValid: () => true,
getClientIP: (req) => req.ip || '127.0.0.1',
};
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
const provider = new EmailProvider({
config: { enabled: true, sessionDuration: '24h' },
log,
session,
renewCSRFToken: () => 'csrf-token-stub',
siteConfig: {},
userStore,
platformPaths: { dataDir: dir },
});
return { provider, session, log };
}
function _fakeReqRes({ body, query, ip, headers } = {}) {
const req = {
body: body || {},
query: query || {},
ip: ip || '127.0.0.1',
socket: { remoteAddress: ip || '127.0.0.1' },
headers: headers || {},
protocol: 'https',
secure: true,
};
const res = {
_status: 200,
_body: null,
status(c) { this._status = c; return this; },
json(b) { this._body = b; return this; },
cookie: jest.fn(),
setHeader: jest.fn(),
getHeader: () => undefined,
};
return { req, res };
}
test('initiate returns sent:true even for unauthorized email (enumeration prevention)', async () => {
const { provider } = _makeProvider();
// Bootstrap first.
await userStore.login({ email: 'admin@x.com' });
// Now an unauthorized user tries.
const { req, res } = _fakeReqRes({ body: { email: 'stranger@x.com' } });
await provider.initiate('magic-link', req, res);
expect(res._body.sent).toBe(true);
});
test('verify rejects unauthorized email after bootstrap', async () => {
const { provider } = _makeProvider();
await userStore.login({ email: 'admin@x.com' });
// Issue token for an unauthorized user (provider's initiate still creates
// a token — the verify step is where authorization is enforced).
const initReq = _fakeReqRes({ body: { email: 'stranger@x.com' } });
await provider.initiate('magic-link', initReq.req, initReq.res);
// The token was returned to the user as part of dev-console log.
// Grab the dev marker from the log mock to extract the URL → token.
const warnCalls = provider.deps.log.warn.mock.calls;
const marker = warnCalls.find(c => c[1] && c[1].includes('stranger@x.com'));
expect(marker).toBeTruthy();
const urlMatch = marker[1].match(/url=(\S+)/);
expect(urlMatch).toBeTruthy();
const url = new URL(urlMatch[1]);
const token = url.searchParams.get('token');
// Now verify — should reject.
const { req, res } = _fakeReqRes({ body: { token }, ip: '127.0.0.1' });
await expect(provider.verify('verify-token', req, res)).rejects.toThrow();
});
test('verify accepts authorized email + creates user record', async () => {
const { provider, session } = _makeProvider();
await userStore.login({ email: 'admin@x.com' });
await userStore.addToAllowlist('friend@x.com');
const initReq = _fakeReqRes({ body: { email: 'friend@x.com' } });
await provider.initiate('magic-link', initReq.req, initReq.res);
const marker = provider.deps.log.warn.mock.calls
.find(c => c[1] && c[1].includes('friend@x.com'));
const url = new URL(marker[1].match(/url=(\S+)/)[1]);
const token = url.searchParams.get('token');
const { req, res } = _fakeReqRes({ body: { token } });
await provider.verify('verify-token', req, res);
// Session was created.
expect(session.create).toHaveBeenCalledTimes(1);
expect(session.setCookie).toHaveBeenCalledTimes(1);
// User record exists.
const u = await userStore.getUserByEmail('friend@x.com');
expect(u).toBeTruthy();
expect(u.role).toBe('operator');
// req.user was tagged for audit attribution.
expect(req.user.id).toBe(u.id);
expect(req.user.role).toBe('operator');
expect(req.user.isBootstrap).toBe(false);
// Response includes user info.
expect(res._body.user.email).toBe('friend@x.com');
expect(res._body.user.role).toBe('operator');
});
test('verify rejects second use of same token (replay protection)', async () => {
const { provider } = _makeProvider();
// Bootstrap.
const { req: bReq, res: bRes } = _fakeReqRes({ body: { email: 'admin@x.com' } });
await provider.initiate('magic-link', bReq, bRes);
const marker = provider.deps.log.warn.mock.calls
.find(c => c[1] && c[1].includes('admin@x.com'));
const url = new URL(marker[1].match(/url=(\S+)/)[1]);
const token = url.searchParams.get('token');
// First verify succeeds.
const { req: v1Req, res: v1Res } = _fakeReqRes({ body: { token } });
await provider.verify('verify-token', v1Req, v1Res);
expect(v1Res._body.message).toBe('Authenticated successfully');
// Second verify fails with generic message.
const { req: v2Req, res: v2Res } = _fakeReqRes({ body: { token } });
await expect(provider.verify('verify-token', v2Req, v2Res)).rejects.toThrow(/invalid/);
});
});
describe('DC-048: admin routes /me + /admin/users', () => {
let dir, userStore, adminRouter;
beforeEach(() => {
dir = _tmpDir();
userStore = require('../src/security/user-store').createUserStore({ dataDir: dir });
// Seed: bootstrap admin
userStore.login({ email: 'admin@x.com' });
const initAdmin = require('../routes/auth/admin');
adminRouter = initAdmin({
asyncHandler: (fn) => fn,
errorResponse: (_res, code, msg) => {
const err = new Error(msg); err.statusCode = code; throw err;
},
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
session: null,
dataDir: dir,
});
});
afterEach(() => _cleanup(dir));
function _invoke(method, urlPath, { user } = {}) {
const req = {
method,
url: urlPath,
path: urlPath.split('?')[0],
query: {},
body: {},
headers: {},
ip: '127.0.0.1',
params: {},
user,
app: { locals: {} },
};
// Parse path into Express-style params
for (const layer of adminRouter.stack) {
if (layer.route && layer.route.methods[method.toLowerCase()]) {
const routePath = layer.route.path;
// Simple :param parsing for tests
const expectedParts = routePath.split('/').filter(Boolean);
const actualParts = req.path.split('/').filter(Boolean);
if (expectedParts.length !== actualParts.length) continue;
let match = true;
for (let i = 0; i < expectedParts.length; i++) {
if (expectedParts[i].startsWith(':')) {
req.params[expectedParts[i].slice(1)] = actualParts[i];
} else if (expectedParts[i] !== actualParts[i]) {
match = false; break;
}
}
if (match) {
const res = {
_status: 200,
_body: null,
status(c) { this._status = c; return this; },
json(b) { this._body = b; return this; },
};
// The router layer's .route.stack contains the middleware chain
// (e.g. _requireAdmin) + the actual handler. We walk the chain
// manually since we're bypassing Express.
const handlers = layer.route.stack.map(s => s.handle);
return {
layer, req, res,
run: async () => {
for (let i = 0; i < handlers.length; i++) {
const h = handlers[i];
const isLast = i === handlers.length - 1;
const stepResult = await new Promise((resolveStep, rejectStep) => {
let nextCalled = false;
let nextErr = null;
const next = (err) => {
nextCalled = true;
nextErr = err || null;
resolveStep({ nextCalled, nextErr });
};
try {
const ret = h(req, res, next);
if (ret && typeof ret.then === 'function') {
ret.then(() => {
if (!nextCalled) resolveStep({ nextCalled, nextErr });
}).catch(rejectStep);
} else if (!nextCalled) {
// Synchronous handler that didn't call next — assume it's the
// final handler that wrote to res. Resolve.
resolveStep({ nextCalled, nextErr });
}
} catch (e) { rejectStep(e); }
});
if (stepResult.nextErr) throw stepResult.nextErr;
if (!stepResult.nextCalled && !isLast) {
throw new Error('middleware chain did not call next');
}
}
},
};
}
}
}
return null;
}
test('/me returns admin user info when authenticated', async () => {
const admin = (await userStore.listUsers())[0];
const r = _invoke('GET', '/me', { user: { id: admin.id, email: admin.email, role: 'admin' } });
await r.run();
expect(r.res._body.authenticated).toBe(true);
expect(r.res._body.role).toBe('admin');
expect(r.res._body.user.email).toBe('admin@x.com');
});
test('/me returns legacy:true when no user attributed', async () => {
const r = _invoke('GET', '/me', { user: null });
await r.run();
expect(r.res._body.legacy).toBe(true);
expect(r.res._body.role).toBe('admin'); // legacy compat
});
test('/admin/users requires admin role (403 for non-admin)', async () => {
const r = _invoke('GET', '/admin/users', { user: { id: 'fake', email: 'x@x.com', role: 'viewer' } });
let caught = null;
try { await r.run(); } catch (e) { caught = e; }
expect(caught).toBeTruthy();
expect(caught.statusCode).toBe(403);
});
test('/admin/users returns user list for admin', async () => {
const r = _invoke('GET', '/admin/users', { user: { id: 'admin-id', email: 'admin@x.com', role: 'admin' } });
await r.run();
expect(Array.isArray(r.res._body.users)).toBe(true);
expect(r.res._body.users).toHaveLength(1);
expect(r.res._body.users[0].email).toBe('admin@x.com');
});
test('/admin/users POST adds to allowlist', async () => {
const r = _invoke('POST', '/admin/users', {
user: { id: 'admin-id', email: 'admin@x.com', role: 'admin' },
});
r.req.body = { email: 'newfriend@x.com' };
await r.run();
const allowlist = await userStore.listAllowlist();
expect(allowlist).toContain('newfriend@x.com');
});
});
@@ -0,0 +1,191 @@
/**
* Tests for invite-store (DC-048).
* Coverage:
* - issue returns raw token + id; token is 256-bit entropy
* - peek returns public-safe info without consuming
* - accept consumes + marks used, second accept returns already_used
* - expired token returns expired on accept
* - revoke removes by id
* - listOutstanding hides used/expired
* - peek returns null for unknown/used/expired (no enumeration)
* - token hash never leaves the store (only SHA-256 on disk)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createInviteStore, DEFAULT_TTL_MS } = require('../src/security/invite-store');
function _tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-invitetest-'));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('invite-store: issue', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('issue returns raw token + id + email + role + expiresAt', async () => {
const r = await store.issue({ email: 'a@x.com', role: 'operator', ttlMs: 60_000 });
expect(r.ok).toBe(true);
expect(r.id).toBeTruthy();
expect(typeof r.token).toBe('string');
expect(r.token.length).toBeGreaterThanOrEqual(40);
expect(r.email).toBe('a@x.com');
expect(r.role).toBe('operator');
expect(new Date(r.expiresAt).getTime()).toBeGreaterThan(Date.now());
});
test('token is base64url and has 256 bits of entropy', async () => {
const r = await store.issue({ email: 'a@x.com' });
expect(r.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
// 32 bytes encoded → 43 chars (no padding)
expect(r.token.length).toBeGreaterThanOrEqual(42);
expect(r.token.length).toBeLessThanOrEqual(44);
});
test('on-disk JSON contains hash, not raw token', async () => {
const r = await store.issue({ email: 'a@x.com' });
const raw = fs.readFileSync(path.join(dir, 'invites.json'), 'utf8');
expect(raw).not.toContain(r.token); // raw token never touches disk
// hash is 64 hex chars
expect(raw).toMatch(/[a-f0-9]{64}/);
});
test('two issues produce different tokens', async () => {
const r1 = await store.issue({ email: 'a@x.com' });
const r2 = await store.issue({ email: 'b@x.com' });
expect(r1.token).not.toEqual(r2.token);
});
test('invalid email rejected', async () => {
const r = await store.issue({ email: 'not-an-email' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_email');
});
});
describe('invite-store: peek + accept', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('peek returns public-safe info', async () => {
const r = await store.issue({ email: 'a@x.com', role: 'operator' });
const p = await store.peek(r.token);
expect(p).toBeTruthy();
expect(p.email).toBe('a@x.com');
expect(p.role).toBe('operator');
expect(p.expiresAt).toBe(r.expiresAt);
});
test('peek does NOT consume the token', async () => {
const r = await store.issue({ email: 'a@x.com' });
await store.peek(r.token);
await store.peek(r.token);
const accept = await store.accept(r.token);
expect(accept.ok).toBe(true);
});
test('peek returns null for unknown token', async () => {
const p = await store.peek('not-a-real-token');
expect(p).toBe(null);
});
test('peek returns null for used token (no enumeration)', async () => {
const r = await store.issue({ email: 'a@x.com' });
await store.accept(r.token);
const p = await store.peek(r.token);
expect(p).toBe(null);
});
test('peek returns null for expired token (no enumeration)', async () => {
const r = await store.issue({ email: 'a@x.com', ttlMs: 1 });
await new Promise(res => setTimeout(res, 10));
const p = await store.peek(r.token);
expect(p).toBe(null);
});
test('accept marks used + records accept time', async () => {
const r = await store.issue({ email: 'a@x.com' });
const a = await store.accept(r.token, { acceptedBy: 'first@x.com' });
expect(a.ok).toBe(true);
expect(a.invite.usedAt).toBeTruthy();
expect(a.invite.email).toBe('a@x.com');
});
test('accept returns already_used on second call', async () => {
const r = await store.issue({ email: 'a@x.com' });
await store.accept(r.token);
const second = await store.accept(r.token);
expect(second.ok).toBe(false);
expect(second.reason).toBe('already_used');
});
test('accept returns expired for TTL-passed token', async () => {
const r = await store.issue({ email: 'a@x.com', ttlMs: 1 });
await new Promise(res => setTimeout(res, 10));
const a = await store.accept(r.token);
expect(a.ok).toBe(false);
expect(a.reason).toBe('expired');
});
test('accept returns not_found for unknown token', async () => {
const a = await store.accept('not-real');
expect(a.ok).toBe(false);
expect(a.reason).toBe('not_found');
});
});
describe('invite-store: revoke + listOutstanding', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('revoke removes an invite', async () => {
const r = await store.issue({ email: 'a@x.com' });
const rev = await store.revoke(r.id);
expect(rev.ok).toBe(true);
const peek = await store.peek(r.token);
expect(peek).toBe(null);
});
test('revoke returns not_found for unknown id', async () => {
const r = await store.revoke('not-an-id');
expect(r.ok).toBe(false);
expect(r.reason).toBe('not_found');
});
test('listOutstanding excludes used + expired', async () => {
const r1 = await store.issue({ email: 'a@x.com', ttlMs: 60_000 });
const r2 = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
const r3 = await store.issue({ email: 'c@x.com', ttlMs: 1 });
await store.accept(r1.token); // used
await new Promise(res => setTimeout(res, 10)); // expire r3
const list = await store.listOutstanding();
expect(list).toHaveLength(1);
expect(list[0].id).toBe(r2.id);
expect(list[0].email).toBe('b@x.com');
});
test('listOutstanding sorted by expiresAt', async () => {
const early = await store.issue({ email: 'a@x.com', ttlMs: 1000 });
const late = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
const list = await store.listOutstanding();
expect(list[0].id).toBe(early.id);
expect(list[1].id).toBe(late.id);
});
});
describe('invite-store: DEFAULT_TTL_MS', () => {
test('default is 24 hours', () => {
expect(DEFAULT_TTL_MS).toBe(24 * 60 * 60 * 1000);
});
});
@@ -288,9 +288,20 @@ describe('Public-routes allowlist drift (prevents DC-012-style dead entries)', (
describe('No stale PUBLIC_ROUTES entries (the DC-012 failure mode)', () => {
test('every PUBLIC_ROUTES entry matches an actual mounted route', () => {
// DC-048: invite routes are only mounted when the operator has
// enabled email auth (siteConfig.authProviders.email.enabled === true).
// The aggregator factory gates this on a non-proxied config flag, so
// the router walker in this test (which runs with stub deps) doesn't
// see them mounted. They're not stale — they're conditional. Same
// for any future provider-conditional mount.
const conditionalMounts = new Set([
'/api/v1/auth/invites/:token',
'/api/v1/auth/invites/:token/accept',
]);
const stale = [];
for (const entry of publicRoutes) {
if (entry.endsWith('/')) continue; // prefix matches, skip
if (conditionalMounts.has(entry)) continue; // gated by config flag
if (!mountedRoutes.has(entry)) stale.push(entry);
}
expect(stale).toEqual([]);
+231
View File
@@ -0,0 +1,231 @@
/**
* Tests for user-store (DC-048).
* Coverage:
* - bootstrap rule: first user becomes admin
* - allowlist enforcement: emails not on the list are rejected
* - login idempotency: existing user just bumps counters
* - role updates with valid/invalid roles
* - last-admin protection: cannot delete the only admin
* - concurrent login safety: mutex serializes
* - file persistence: writes are atomic and survive process kill
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createUserStore, ROLES, VALID_ROLES } = require('../src/security/user-store');
function _tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-usertest-'));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('user-store: bootstrap', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('first login becomes admin (isBootstrap=true)', async () => {
const r = await store.login({ email: 'alice@example.com', ip: '127.0.0.1' });
expect(r.ok).toBe(true);
expect(r.isBootstrap).toBe(true);
expect(r.role).toBe('admin');
expect(r.user.email).toBe('alice@example.com');
expect(r.user.id).toBeTruthy();
expect(r.user.loginCount).toBe(1);
});
test('bootstrap sentinel written', async () => {
await store.login({ email: 'a@x.com' });
expect(fs.existsSync(path.join(dir, '.bootstrapped'))).toBe(true);
const sentinel = JSON.parse(fs.readFileSync(path.join(dir, '.bootstrapped'), 'utf8'));
expect(sentinel.adminEmail).toBe('a@x.com');
});
test('bootstrap-admin email is added to allowlist', async () => {
await store.login({ email: 'first@x.com' });
const allowlist = await store.listAllowlist();
expect(allowlist).toContain('first@x.com');
});
test('second login denied without allowlist', async () => {
await store.login({ email: 'first@x.com' });
const r = await store.login({ email: 'second@x.com' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('not_authorized');
});
test('second login allowed if email is on allowlist', async () => {
await store.login({ email: 'first@x.com' });
await store.addToAllowlist('friend@x.com');
const r = await store.login({ email: 'friend@x.com' });
expect(r.ok).toBe(true);
expect(r.isBootstrap).toBe(false);
expect(r.role).toBe('operator'); // not admin — bootstrap already happened
});
test('replay bootstrap after delete restores allow-everyone', async () => {
await store.login({ email: 'first@x.com' });
// Cannot fully replay — bootstrap sentinel persists. Verify the
// invariant: once bootstrapped, even an empty allowlist rejects new
// emails unless added explicitly.
const r = await store.login({ email: 'random@x.com' });
expect(r.ok).toBe(false);
});
});
describe('user-store: login idempotency', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('existing user login bumps counters, does NOT bootstrap again', async () => {
const r1 = await store.login({ email: 'a@x.com' });
const id = r1.user.id;
const r2 = await store.login({ email: 'a@x.com', ip: '10.0.0.1' });
expect(r2.ok).toBe(true);
expect(r2.isBootstrap).toBe(false);
expect(r2.user.id).toBe(id);
expect(r2.user.loginCount).toBe(2);
expect(r2.user.lastLoginIp).toBe('10.0.0.1');
});
test('email normalized to lowercase', async () => {
await store.login({ email: 'Alice@Example.COM' });
const users = await store.listUsers();
expect(users).toHaveLength(1);
expect(users[0].email).toBe('alice@example.com');
});
});
describe('user-store: validation', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('invalid email rejected', async () => {
const r = await store.login({ email: 'not-an-email' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_email');
});
test('empty email rejected', async () => {
const r = await store.login({ email: '' });
expect(r.ok).toBe(false);
});
test('isEmailAuthorized returns true only when allowlist or bootstrap-pending', async () => {
expect(await store.isEmailAuthorized('anyone@x.com')).toBe(true); // bootstrap pending
await store.login({ email: 'first@x.com' });
expect(await store.isEmailAuthorized('anyone@x.com')).toBe(false);
await store.addToAllowlist('friend@x.com');
expect(await store.isEmailAuthorized('friend@x.com')).toBe(true);
expect(await store.isEmailAuthorized('stranger@x.com')).toBe(false);
});
});
describe('user-store: roles + delete', () => {
let dir, store;
beforeEach(() => {
dir = _tmpDir();
store = createUserStore({ dataDir: dir });
});
afterEach(() => _cleanup(dir));
test('setRole updates an existing user', async () => {
await store.login({ email: 'a@x.com' });
await store.addToAllowlist('b@x.com');
const r = await store.login({ email: 'b@x.com' });
const set = await store.setRole(r.user.id, 'viewer');
expect(set.ok).toBe(true);
const got = await store.getUser(r.user.id);
expect(got.role).toBe('viewer');
});
test('setRole rejects invalid role', async () => {
await store.login({ email: 'a@x.com' });
const set = await store.setRole('nonexistent', 'superuser');
expect(set.ok).toBe(false);
expect(set.reason).toBe('invalid_role');
});
test('deleteUser removes user + allowlist entry', async () => {
await store.login({ email: 'a@x.com' });
await store.addToAllowlist('b@x.com');
const r = await store.login({ email: 'b@x.com' });
const del = await store.deleteUser(r.user.id);
expect(del.ok).toBe(true);
const users = await store.listUsers();
expect(users).toHaveLength(1); // only the admin
const allowlist = await store.listAllowlist();
expect(allowlist).not.toContain('b@x.com');
});
test('deleteUser refuses to delete the last admin', async () => {
const r = await store.login({ email: 'admin@x.com' });
const del = await store.deleteUser(r.user.id);
expect(del.ok).toBe(false);
expect(del.reason).toBe('last_admin');
});
test('deleteUser allows removing admin when another admin exists', async () => {
await store.login({ email: 'admin1@x.com' });
await store.addToAllowlist('admin2@x.com');
const r2 = await store.login({ email: 'admin2@x.com' });
await store.setRole(r2.user.id, 'admin');
const r1 = await store.listUsers();
const admin1 = r1.find(u => u.email === 'admin1@x.com');
const del = await store.deleteUser(admin1.id);
expect(del.ok).toBe(true);
const remaining = await store.listUsers();
expect(remaining).toHaveLength(1);
expect(remaining[0].role).toBe('admin');
});
});
describe('user-store: atomic writes', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('users.json is well-formed after write', async () => {
await store.login({ email: 'a@x.com' });
const raw = fs.readFileSync(path.join(dir, 'users.json'), 'utf8');
const parsed = JSON.parse(raw);
expect(parsed.users).toBeTruthy();
expect(parsed.order).toHaveLength(1);
});
test('corrupt users.json falls back to empty (no crash)', async () => {
fs.writeFileSync(path.join(dir, 'users.json'), '{not json');
const users = await store.listUsers();
expect(users).toEqual([]);
});
test('listUsers returns most-recent-first by createdAt order', async () => {
await store.login({ email: 'a@x.com' });
await new Promise(r => setTimeout(r, 5));
await store.addToAllowlist('b@x.com');
await store.login({ email: 'b@x.com' });
const users = await store.listUsers();
expect(users[0].email).toBe('b@x.com');
expect(users[1].email).toBe('a@x.com');
});
});
describe('user-store: ROLES constants', () => {
test('exports admin/operator/viewer roles', () => {
expect(ROLES.ADMIN).toBe('admin');
expect(ROLES.OPERATOR).toBe('operator');
expect(ROLES.VIEWER).toBe('viewer');
expect(VALID_ROLES.has('admin')).toBe(true);
expect(VALID_ROLES.has('operator')).toBe(true);
expect(VALID_ROLES.has('viewer')).toBe(true);
expect(VALID_ROLES.has('superuser')).toBe(false);
});
});
+341
View File
@@ -0,0 +1,341 @@
/**
* Admin + me routes DC-048.
*
* Mounted at /api/v1/auth. All `/admin/*` routes require the session to
* belong to a user with role 'admin'. `/me` requires any authenticated session.
*
* Endpoints:
* GET /me current user (id, email, role, isAdmin)
* GET /admin/users list all users
* POST /admin/users pre-authorize an email (allowlist)
* PATCH /admin/users/:id change a user's role
* DELETE /admin/users/:id delete user + remove from allowlist
* GET /admin/allowlist list authorized emails
* GET /admin/invites list outstanding invites
* POST /admin/invites issue a new invite (returns raw token ONCE)
* DELETE /admin/invites/:id revoke an invite
*
* POST /invites/accept PUBLIC redeem an invite token,
* create user, set session cookie
* GET /invites/:token PUBLIC peek at an invite (email,
* role, expires) without consuming it.
*/
'use strict';
const express = require('express');
const path = require('path');
const platformPaths = require('../../platform-paths');
const { createUserStore } = require('../../src/security/user-store');
const { createInviteStore } = require('../../src/security/invite-store');
const emailSender = require('../../src/auth/providers/email-sender');
const { ValidationError, NotFoundError, ForbiddenError } = require('../../src/utilities/errors');
const { ok, successMessage } = require('../../src/utils/responses');
/**
* Build the URL an invitee should click. Mirrors EmailMagicLinkProvider's
* _resolvePublicUrl logic kept duplicated (not extracted) because the two
* callers have slightly different link paths and the duplication is smaller
* than the abstraction would be.
*/
function _buildInviteUrl(req, siteConfig, token) {
if (siteConfig && siteConfig.publicBaseUrl) {
return siteConfig.publicBaseUrl.replace(/\/+$/, '') +
'/api/v1/auth/invites/' + encodeURIComponent(token) + '/accept';
}
const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
|| (siteConfig && siteConfig.dashboardHost) || 'localhost:3001';
return `${proto}://${host}/api/v1/auth/invites/${encodeURIComponent(token)}/accept`;
}
function _requireAdmin(req, _res, next) {
if (!req.user || req.user.role !== 'admin') {
return next(new ForbiddenError('Admin role required'));
}
next();
}
function _buildEmailText({ acceptUrl, ttlHours, role }) {
return [
'Hi,',
'',
'You\'ve been invited to join a DashCaddy instance as a ' + role + '.',
'Click the link below within ' + ttlHours + ' hours to accept:',
'',
acceptUrl,
'',
'This link is single-use. If you weren\'t expecting this invitation,',
'you can safely ignore this email.',
'',
'— DashCaddy',
].join('\n');
}
function _buildEmailHtml({ acceptUrl, ttlHours, role }) {
return [
'<!doctype html><html><body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;">',
'<h2 style="margin:0 0 12px">You\'re invited to DashCaddy</h2>',
'<p>You\'ve been invited to join as <strong>' + role + '</strong>.</p>',
'<p>Click the button below within ' + ttlHours + ' hours to accept:</p>',
'<p style="margin:24px 0"><a href="' + acceptUrl + '" style="background:#1f2937;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none;display:inline-block">Accept invitation</a></p>',
'<p style="color:#6b7280;font-size:12px">If the button doesn\'t work, paste this link into your browser:<br><span style="word-break:break-all">' + acceptUrl + '</span></p>',
'<p style="color:#6b7280;font-size:12px">If you weren\'t expecting this, you can ignore this email.</p>',
'</body></html>',
].join('\n');
}
module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }) {
const router = express.Router();
// user-store / invite-store handle their own defensive dataDir resolution
// (they ignore Proxy/function values from universal-deps test deps).
const resolvedDataDir = dataDir || (platformPaths && platformPaths.dataDir);
const userStore = createUserStore({ dataDir: resolvedDataDir, log });
const inviteStore = createInviteStore({ dataDir: resolvedDataDir, log });
// ── /me ───────────────────────────────────────────────────────────────
router.get('/me', asyncHandler(async (req, res) => {
if (!req.user || !req.user.id) {
// Legacy session without user attribution. Return the bare role
// (defaults to admin for backwards-compat) but signal via
// `legacy: true` so the UI knows.
return ok(res, {
user: null,
authenticated: session ? session.isSessionValid(req) : false,
role: 'admin', // legacy: assume operator-level access
legacy: true,
});
}
const stored = await userStore.getUser(req.user.id);
return ok(res, {
user: stored
? {
id: stored.id,
email: stored.email,
displayName: stored.displayName,
role: stored.role,
isAdmin: stored.role === 'admin',
createdAt: stored.createdAt,
lastLoginAt: stored.lastLoginAt,
loginCount: stored.loginCount,
}
: null,
authenticated: true,
role: req.user.role,
legacy: false,
});
}, 'auth-me'));
// ── /admin/users ──────────────────────────────────────────────────────
router.get('/admin/users', _requireAdmin, asyncHandler(async (_req, res) => {
const users = await userStore.listUsers();
return ok(res, { users });
}, 'auth-admin-users-list'));
router.post('/admin/users', _requireAdmin, asyncHandler(async (req, res) => {
const { email, role } = req.body || {};
if (!email) throw new ValidationError('email is required', 'email');
if (role && !userStore.VALID_ROLES.has(role)) {
throw new ValidationError('Invalid role', 'role');
}
const result = await userStore.addToAllowlist(email);
if (!result.ok) throw new ValidationError(result.reason, 'email');
// If a role was provided AND the user already exists, also set the role.
if (role) {
const existing = await userStore.getUserByEmail(email);
if (existing) {
await userStore.setRole(existing.id, role);
}
}
return ok(res, {
email: email.toLowerCase(),
alreadyExisted: result.alreadyExisted,
});
}, 'auth-admin-users-create'));
router.patch('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => {
const { role } = req.body || {};
if (!role || !userStore.VALID_ROLES.has(role)) {
throw new ValidationError('Invalid role', 'role');
}
const result = await userStore.setRole(req.params.id, role);
if (!result.ok) {
throw result.reason === 'not_found'
? new NotFoundError('User not found')
: new ValidationError(result.reason, 'role');
}
return successMessage(res, 'Role updated');
}, 'auth-admin-users-update'));
router.delete('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => {
const result = await userStore.deleteUser(req.params.id);
if (!result.ok) {
if (result.reason === 'not_found') throw new NotFoundError('User not found');
if (result.reason === 'last_admin') {
throw new ValidationError('Cannot delete the last admin');
}
throw new ValidationError(result.reason);
}
return successMessage(res, 'User deleted');
}, 'auth-admin-users-delete'));
// ── /admin/allowlist ──────────────────────────────────────────────────
router.get('/admin/allowlist', _requireAdmin, asyncHandler(async (_req, res) => {
const emails = await userStore.listAllowlist();
return ok(res, { emails });
}, 'auth-admin-allowlist'));
// ── /admin/invites ────────────────────────────────────────────────────
router.get('/admin/invites', _requireAdmin, asyncHandler(async (_req, res) => {
const invites = await inviteStore.listOutstanding();
return ok(res, { invites });
}, 'auth-admin-invites-list'));
router.post('/admin/invites', _requireAdmin, asyncHandler(async (req, res) => {
const { email, role, ttlHours, sendEmail } = req.body || {};
if (!email) throw new ValidationError('email is required', 'email');
const ttlMs = (typeof ttlHours === 'number' && ttlHours > 0 && ttlHours <= 168)
? ttlHours * 60 * 60 * 1000
: inviteStore.DEFAULT_TTL_MS;
const invitedBy = (req.user && req.user.email) || 'admin';
const issued = await inviteStore.issue({
email,
role: (role && userStore.VALID_ROLES.has(role)) ? role : 'operator',
ttlMs,
invitedBy,
});
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
let deliveredVia = 'none';
let maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
if (sendEmail !== false) {
// Best-effort send. If SMTP isn't configured, log to error.log (dev path).
const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token);
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
try {
const smtpConfig = req.app.locals && req.app.locals.emailConfig;
if (smtpConfig && emailSender.isConfigured(smtpConfig)) {
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
deliveredVia = 'email';
} else {
// Dev fallback — log the raw link so operators can grab it.
log.warn && log.warn('auth-invite-dev',
'[DC-048-DEV-INVITE-LINK] email=' + issued.email +
' role=' + issued.role + ' url=' + acceptUrl);
deliveredVia = 'dev-console';
}
} catch (sendErr) {
log.warn && log.warn('auth-invite-send',
'invite send failed: ' + (sendErr.message || String(sendErr)));
deliveredVia = 'failed';
}
} else {
deliveredVia = 'manual';
}
return ok(res, {
id: issued.id,
email: issued.email,
role: issued.role,
expiresAt: issued.expiresAt,
// The raw token is returned ONCE so the admin UI can show/copy the
// link. It is also embedded in the email when sendEmail !== false.
acceptUrl: (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001'))) +
'/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept',
deliveredVia,
maskedEmail,
});
}, 'auth-admin-invites-create'));
router.delete('/admin/invites/:id', _requireAdmin, asyncHandler(async (req, res) => {
const result = await inviteStore.revoke(req.params.id);
if (!result.ok) throw new NotFoundError('Invite not found');
return successMessage(res, 'Invite revoked');
}, 'auth-admin-invites-revoke'));
// ── /invites (public) ──────────────────────────────────────────────────
// PUBLIC: peek at an invite without consuming it.
router.get('/invites/:token', asyncHandler(async (req, res) => {
const peeked = await inviteStore.peek(req.params.token);
if (!peeked) {
// Same response as "not found" — don't leak token state.
return ok(res, { valid: false });
}
return ok(res, {
valid: true,
email: peeked.email,
role: peeked.role,
expiresAt: peeked.expiresAt,
});
}, 'auth-invites-peek'));
// PUBLIC: accept an invite token. Creates the user, sets the session.
router.post('/invites/:token/accept', asyncHandler(async (req, res) => {
const result = await inviteStore.accept(req.params.token, {
acceptedBy: req.user ? req.user.email : null,
});
if (!result.ok) {
throw new ValidationError('Invitation is ' + result.reason.replace('_', ' '), 'token');
}
// Authorize the email + create the user record.
const invite = result.invite;
const userResult = await userStore.login({
email: invite.email,
ip: req.ip || '',
displayName: invite.email.split('@')[0],
createdBy: 'invite:' + invite.id,
});
if (!userResult.ok) {
throw new ValidationError('Could not create user from invite: ' + userResult.reason);
}
// Create session (same shape as email verify path).
if (session) {
session.create(req, '24h');
session.setCookie(res, '24h');
}
if (req.app.locals && req.app.locals.renewCSRFToken) {
req.app.locals.renewCSRFToken(res, req.secure || req.protocol === 'https');
}
// Attach user to request for audit log.
req.user = {
id: userResult.user.id,
email: userResult.user.email,
role: userResult.user.role,
isAdmin: userResult.user.role === 'admin',
isBootstrap: false,
viaProvider: 'invite',
};
log.info && log.info('auth', 'invite accepted, user created', {
userId: userResult.user.id,
email: userResult.user.email,
role: userResult.user.role,
inviteId: invite.id,
});
return ok(res, {
message: 'Invitation accepted',
user: {
id: userResult.user.id,
email: userResult.user.email,
role: userResult.user.role,
},
csrfToken: res.locals && res.locals.csrfToken,
});
}, 'auth-invites-accept'));
return router;
};
+50 -2
View File
@@ -4,7 +4,9 @@ const initKeys = require('./keys');
const initSessionHandlers = require('./session-handlers');
const initSsoGate = require('./sso-gate');
const initLogin = require('./login');
const initAdmin = require('./admin');
const { createAuthProviderRegistry } = require('../../src/auth/providers');
const { createUserStore } = require('../../src/security/user-store');
/**
* Auth routes aggregator
@@ -39,6 +41,32 @@ function _extractEmailConfig(ctx) {
module.exports = function(ctx) {
const router = express.Router();
// DC-048: opt-in user store. Only instantiated when the operator has
// explicitly enabled email auth in siteConfig. The default for new
// installs is "no user-store, no allowlist, no admin invites" — the
// legacy single-user TOTP flow. Operators who turn email auth on
// (siteConfig.authProviders.email.enabled = true) opt into multi-user.
// Once opted in, the first email to log in is the bootstrap admin.
const platformPaths = ctx.platformPaths || require('../../platform-paths');
let userStore = null;
const _emailExplicitlyEnabled =
ctx.siteConfig &&
ctx.siteConfig.authProviders &&
ctx.siteConfig.authProviders.email &&
ctx.siteConfig.authProviders.email.enabled === true;
if (_emailExplicitlyEnabled) {
userStore = createUserStore({
dataDir: platformPaths.dataDir,
log: ctx.log,
});
ctx.userStore = userStore;
ctx.log && ctx.log.info && ctx.log.info('user', 'multi-user mode enabled (email auth on)');
} else {
ctx.log && ctx.log.info && ctx.log.info('user', 'single-user mode (email auth not enabled — set siteConfig.authProviders.email.enabled = true to opt into multi-user)');
}
// Extract dependencies from context
const deps = {
authManager: ctx.authManager,
@@ -62,7 +90,11 @@ module.exports = function(ctx) {
notificationManager: ctx.notification,
siteConfig: ctx.siteConfig,
// DC-047: data-directory resolution for the email-token JSON store.
platformPaths: ctx.platformPaths || null,
platformPaths,
// DC-048: user store for allowlist + bootstrap. Null when email
// auth is disabled — providers fall back to "allow everyone" legacy
// behavior (DC-046/047 semantics).
userStore,
};
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
@@ -75,7 +107,7 @@ module.exports = function(ctx) {
credentialManager: ctx.credentialManager,
session: ctx.session,
saveTotpConfig: ctx.saveTotpConfig,
config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: true } },
config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: false } },
log: ctx.log,
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken,
// DC-047: EmailMagicLinkProvider needs SMTP config + a public URL
@@ -84,6 +116,9 @@ module.exports = function(ctx) {
emailConfig: _extractEmailConfig(ctx),
siteConfig: ctx.siteConfig || {},
platformPaths: deps.platformPaths,
// DC-048: user store shared by every provider for allowlist checks
// and the bootstrap-admin-on-first-login rule.
userStore: deps.userStore,
},
ctx.siteConfig
);
@@ -106,5 +141,18 @@ module.exports = function(ctx) {
router.use(initKeys(deps));
router.use(initSsoGate({ ...deps, getAppSession, appSessionCache }));
// DC-048: mount admin routes ONLY when the user-store was instantiated
// (i.e. email auth is enabled). Single-user installs don't see /me,
// /admin/*, or /invites/* at all. The route paths simply don't exist
// so a request to /api/v1/auth/me returns 404 from the apiRouter.
if (userStore) {
router.use('/auth', initAdmin({
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log,
session: ctx.session,
}));
}
return router;
};
+82 -6
View File
@@ -81,8 +81,9 @@ class EmailMagicLinkProvider extends AuthProvider {
this.store.startPruneTimer();
this.emailConfig = deps.emailConfig || null;
// DC-048 will replace this with the real authorized-users check.
this.authorizedEmails = deps.authorizedEmails || (() => true);
// DC-048: real authorized-users check via the user store. Falls back
// to "allow everyone" if no store is wired (dev/legacy installs).
this.userStore = deps.userStore || null;
// Public URL templates — overridable for testing.
this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS;
this.maxBodyLength = 32_000;
@@ -131,10 +132,12 @@ class EmailMagicLinkProvider extends AuthProvider {
*/
_isProviderEnabled() {
const flag = this.deps.config && this.deps.config.enabled;
// Default to TRUE: dev installs should "just work". Operators who want
// to disable email login set `siteConfig.authProviders.email.enabled = false`
// — the same config knob the TOTP provider uses.
if (flag === false) return false;
// Default to FALSE (DC-048 opt-in): operators must explicitly enable
// email auth via `siteConfig.authProviders.email.enabled = true`. Until
// then, the email login methods endpoint reports the provider as
// disabled and the auth UI doesn't render the email button. TOTP-only
// installs see no behavior change.
if (flag !== true) return false;
return true;
}
@@ -277,12 +280,64 @@ class EmailMagicLinkProvider extends AuthProvider {
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
// DC-048: authorization gate. If the email isn't on the allowlist and
// bootstrap has already happened, reject. The token is still consumed
// so the same generic message is returned for "valid token but you're
// not allowed" — prevents a side-channel that distinguishes
// "token worked but you're banned" from "token didn't exist".
//
// NOTE: the DC-047 design comment claimed "initiate" would also silently
// drop unauthorized emails. That was aspirational; the real enumeration
// prevention lives at verify-time (here). Initiate-time, we still issue
// tokens and return success — so an unauthorized user thinks the link
// works, but it rejects at click-time. Same as DC-047 claimed; we just
// moved the check from initiate to verify where it can actually run.
if (this.userStore) {
const allowed = await this.userStore.isEmailAuthorized(record.email);
if (!allowed) {
// Audit the denial.
this.deps.log && this.deps.log.warn && this.deps.log.warn('auth', 'email magic link rejected — not authorized', {
email: record.email,
ip: this._clientIP(req),
});
// Mark token used so a stolen token can't be replayed by a legit user later.
await this.store.markUsed(record.hash).catch(() => {});
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
}
// Side-effect logging (NOT info-disclosure — just that a token was used).
this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link verified', {
email: record.email,
ip: this._clientIP(req),
});
// DC-048: record-or-create the user. First login → bootstrap admin.
// After that → must be on allowlist (already checked above).
let userRecord = null;
let isBootstrap = false;
if (this.userStore) {
const result = await this.userStore.login({
email: record.email,
ip: this._clientIP(req),
});
if (!result.ok) {
// Shouldn't reach here — isEmailAuthorized just passed — but
// handle the edge case where allowlist was mutated between calls.
await this.store.markUsed(record.hash).catch(() => {});
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
userRecord = result.user;
isBootstrap = result.isBootstrap;
if (this.deps.log && this.deps.log.info) {
this.deps.log.info('auth', isBootstrap ? 'bootstrap admin first login' : 'user login', {
userId: userRecord.id,
email: userRecord.email,
role: userRecord.role,
});
}
}
// Create the session + cookie. Same shape as TOTP's verify path.
this.deps.session.create(req, this.deps.config && this.deps.config.sessionDuration || '24h');
this.deps.session.setCookie(res, this.deps.config && this.deps.config.sessionDuration || '24h');
@@ -290,11 +345,32 @@ class EmailMagicLinkProvider extends AuthProvider {
? this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https')
: undefined;
// DC-048: tag the request with the authenticated user so downstream
// middleware + audit log can attribute the session. We mutate req so
// the audit logger (which runs as response middleware) sees it.
if (userRecord) {
req.user = {
id: userRecord.id,
email: userRecord.email,
role: userRecord.role,
isAdmin: userRecord.role === 'admin',
isBootstrap,
};
}
return ok(res, {
message: 'Authenticated successfully',
method: 'email',
email: AuthProvider.maskEmail(record.email),
csrfToken: newCsrf,
user: userRecord
? {
id: userRecord.id,
email: userRecord.email,
role: userRecord.role,
isBootstrap,
}
: null,
});
}
+5 -3
View File
@@ -42,6 +42,8 @@ function createAuthProviderRegistry(deps, config) {
...deps,
config: deps.config.totp, // the existing totpConfig object from app.js
saveProviderConfig: deps.saveTotpConfig, // existing helper
// DC-048: user store for bootstrap + audit attribution on TOTP logins.
userStore: deps.userStore || null,
});
providers.set('totp', totpProvider);
@@ -56,9 +58,9 @@ function createAuthProviderRegistry(deps, config) {
saveProviderConfig: deps.saveProviderConfig || (async () => {}),
emailConfig: deps.emailConfig || null,
siteConfig: deps.siteConfig || {},
// DC-048 hook: today every email is authorized. Once multi-user ships,
// this is replaced with a real allowlist check.
authorizedEmails: deps.authorizedEmails || (() => true),
// DC-048: real authorization check via the user store. Without it,
// every email is allowed (legacy single-user behavior).
userStore: deps.userStore || null,
}));
// Future: OIDC, SAML, passkeys — each gated on config.authProviders
+45
View File
@@ -274,6 +274,51 @@ class TotpProvider extends AuthProvider {
this.deps.session.create(req, this.deps.config.sessionDuration);
this.deps.session.setCookie(res, this.deps.config.sessionDuration);
// DC-048: bootstrap-on-first-TOTP-verify. If no user store is wired
// (legacy install), skip silently — operator keeps anonymous access.
// If a user store IS wired and bootstrap hasn't happened yet, create
// a "system-admin" record tied to this TOTP login so the operator
// shows up in /api/v1/auth/admin/users. Email is null because TOTP
// has no email to attribute.
if (this.deps.userStore) {
const isBootstrapped = await this.deps.userStore.isBootstrapComplete();
if (!isBootstrapped) {
const result = await this.deps.userStore.login({
email: 'system@totp.local',
ip: this._clientIP(req),
displayName: 'Operator (TOTP)',
});
if (result.ok) {
req.user = {
id: result.user.id,
email: null,
role: result.user.role,
isAdmin: result.user.role === 'admin',
isBootstrap: result.isBootstrap,
viaProvider: 'totp',
};
this.deps.log.info('auth', 'system admin bootstrapped via TOTP', {
userId: result.user.id,
role: result.user.role,
});
}
} else {
// Bootstrap already happened — find the system-admin record and
// attach it to this session for audit-log attribution.
const sys = await this.deps.userStore.getUserByEmail('system@totp.local');
if (sys) {
req.user = {
id: sys.id,
email: null,
role: sys.role,
isAdmin: sys.role === 'admin',
isBootstrap: false,
viaProvider: 'totp',
};
}
}
}
const newCsrfToken = this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https');
this.deps.log.debug('auth', 'Session created', { sessions: this.deps.session.ipSessions.size });
@@ -231,6 +231,18 @@ class AuditLogger {
details.body = safe;
}
// DC-048: attribute the audit entry to the authenticated user when
// a session belongs to a known user record. Tag with id + role +
// email (or null for the TOTP-attributed "system" operator). When
// req.user is absent (legacy session, no auth), omit the fields
// entirely so existing log readers don't break.
if (req.user && req.user.id) {
details.userId = req.user.id;
details.userRole = req.user.role || null;
if (req.user.email) details.userEmail = req.user.email;
if (req.user.viaProvider) details.viaProvider = req.user.viaProvider;
}
this.log({ action, resource, details, outcome, ip }).catch(() => {});
return originalJson(data);
@@ -153,6 +153,10 @@ function csrfValidationMiddleware(req, res, next) {
'/api/v1/auth/login/:provider/verify',
'/api/v1/auth/login/:provider/initiate',
'/api/v1/auth/disable/:provider',
// DC-048: invite redemption is the same exemption as login verify —
// the user has no session cookie yet (they just clicked an email link).
// CSRF on this boundary is enforced by SameSite=Lax instead.
'/api/v1/auth/invites/:token/accept',
'/health',
'/health/live',
'/health/ready',
+266
View File
@@ -0,0 +1,266 @@
/**
* Invite store DC-048.
*
* Single-use invite tokens with TTL. Admin generates an invite for an email;
* the system emails (or logs in dev) a magic-link-style URL containing the
* raw token. The recipient clicks accepts becomes an authorized user.
*
* Storage: data/invites.json. Atomic writes via tmp+rename.
*
* Token shape:
* - 32 random bytes, base64url-encoded (256 bits of entropy).
* - We store ONLY the SHA-256 hash on disk. The raw token lives in the
* email + in the URL query string; on the server we hash and look up.
* A read-only compromise of invites.json cannot forge acceptance.
*
* Lifecycle:
* - issue({ email, role, ttlMs, invitedBy }) { id, token, expiresAt, ... }
* token is the only time the raw token will ever be returned.
* - peek(token) { email, role, expiresAt, usedAt } | null
* (returns the public-safe info without consuming the token)
* - accept(token) { ok: true, invite } | { ok: false, reason }
* reasons: 'not_found', 'expired', 'already_used'
* - revoke(id) removes the invite by id (admin-only).
* - list() all outstanding invites (admin-only).
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d
function _nowMs() { return Date.now(); }
function _nowIso() { return new Date().toISOString(); }
function _atomicWriteJSON(filePath, data) {
const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
function _readJSON(filePath, fallback) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
if (err && err.code === 'ENOENT') return fallback;
return fallback;
}
}
function _defaultData() { return { invites: {} }; }
function _sha256(s) {
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
}
function createInviteStore(opts = {}) {
// Same defensive resolver as user-store — universal-deps test proxies
// can return function-typed values for property access.
const candidates = [
opts.dataDir,
opts.platformPaths && opts.platformPaths.dataDir,
platformPaths && platformPaths.dataDir,
];
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|| require('os').tmpdir();
const log = opts.log || { info() {}, warn() {}, error() {} };
const file = path.join(dataDir, 'invites.json');
let _mutex = Promise.resolve();
function _enqueue(fn) {
const next = _mutex.then(fn, fn);
_mutex = next.catch(() => {});
return next;
}
function _load() {
const data = _readJSON(file, _defaultData());
if (!data.invites || typeof data.invites !== 'object') data.invites = {};
return data;
}
function _save(data) { _atomicWriteJSON(file, data); }
function _prune(data) {
const cutoff = _nowMs() - PRUNE_AFTER_MS;
for (const id of Object.keys(data.invites)) {
const inv = data.invites[id];
if (!inv) { delete data.invites[id]; continue; }
const isTerminal = inv.usedAt || (inv.expiresAt && new Date(inv.expiresAt).getTime() < cutoff);
if (isTerminal) delete data.invites[id];
}
return data;
}
/**
* Issue a new invite. Returns the raw token (only time it leaves the system).
*/
function issue({ email, role = 'operator', ttlMs = DEFAULT_TTL_MS, invitedBy = 'admin' } = {}) {
return _enqueue(() => {
if (typeof email !== 'string' || !email.includes('@')) {
return { ok: false, reason: 'invalid_email' };
}
const normalized = email.toLowerCase().trim();
const id = crypto.randomUUID();
const token = crypto.randomBytes(32).toString('base64url');
const hash = _sha256(token);
const issuedAt = _nowIso();
const expiresAt = new Date(_nowMs() + ttlMs).toISOString();
const data = _load();
_prune(data);
data.invites[id] = {
id,
hash,
email: normalized,
role,
invitedBy,
issuedAt,
expiresAt,
usedAt: null,
usedBy: null,
};
_save(data);
log.info && log.info('invite', 'invite issued', {
id, email: normalized, role, invitedBy, ttlMs,
});
return {
ok: true,
id,
token, // raw token — caller emails it
email: normalized,
role,
expiresAt,
ttlMs,
};
});
}
/**
* Public-safe peek. Does NOT consume the token.
* Returns null if not found, expired, or already used (same response
* for all three enumeration prevention).
*/
function peek(token) {
if (!token || typeof token !== 'string') return null;
return _enqueue(() => {
const data = _load();
const hash = _sha256(token);
const inv = _findByHash(data, hash);
if (!inv) return null;
if (inv.usedAt) return null;
if (new Date(inv.expiresAt).getTime() < _nowMs()) return null;
return {
id: inv.id,
email: inv.email,
role: inv.role,
expiresAt: inv.expiresAt,
issuedAt: inv.issuedAt,
};
});
}
/**
* Consume an invite token. Returns the invite record on success.
* After accept(), the invite is marked used (NOT deleted) so the admin
* can see who redeemed what. The auto-prune reaps it after 7 days.
*/
function accept(token, { acceptedBy } = {}) {
return _enqueue(() => {
if (!token || typeof token !== 'string') {
return { ok: false, reason: 'not_found' };
}
const data = _load();
const hash = _sha256(token);
const inv = _findByHash(data, hash);
if (!inv) return { ok: false, reason: 'not_found' };
if (inv.usedAt) return { ok: false, reason: 'already_used' };
if (new Date(inv.expiresAt).getTime() < _nowMs()) {
return { ok: false, reason: 'expired' };
}
inv.usedAt = _nowIso();
inv.usedBy = acceptedBy || null;
_save(data);
log.info && log.info('invite', 'invite accepted', {
id: inv.id, email: inv.email, role: inv.role, acceptedBy,
});
return {
ok: true,
invite: {
id: inv.id,
email: inv.email,
role: inv.role,
expiresAt: inv.expiresAt,
usedAt: inv.usedAt,
},
};
});
}
/**
* Admin-only. Revoke an outstanding invite by id.
*/
function revoke(id) {
return _enqueue(() => {
const data = _load();
if (!data.invites[id]) return { ok: false, reason: 'not_found' };
delete data.invites[id];
_save(data);
log.info && log.info('invite', 'invite revoked', { id });
return { ok: true };
});
}
/**
* Admin-only. List outstanding invites (excludes used/expired).
*/
function listOutstanding() {
return _enqueue(() => {
const data = _load();
_prune(data);
_save(data);
const now = _nowMs();
return Object.values(data.invites)
.filter(inv => !inv.usedAt && new Date(inv.expiresAt).getTime() > now)
.sort((a, b) => new Date(a.expiresAt) - new Date(b.expiresAt))
.map(inv => ({
id: inv.id,
email: inv.email,
role: inv.role,
invitedBy: inv.invitedBy,
issuedAt: inv.issuedAt,
expiresAt: inv.expiresAt,
}));
});
}
function _findByHash(data, hash) {
for (const id of Object.keys(data.invites)) {
const inv = data.invites[id];
if (inv && inv.hash === hash) return inv;
}
return null;
}
return {
issue,
peek,
accept,
revoke,
listOutstanding,
DEFAULT_TTL_MS,
};
}
module.exports = { createInviteStore, DEFAULT_TTL_MS };
+407
View File
@@ -0,0 +1,407 @@
/**
* User store DC-048.
*
* Tracks who is allowed to log in to a DashCaddy instance, and what role each
* authenticated user has. Replaces the "one implicit operator" model that
* DC-046/047 shipped with.
*
* TWO files (both live under platformPaths.dataDir):
*
* data/users.json every user that has ever authenticated.
* Shape: {
* users: {
* [userId]: {
* id, email, displayName, role,
* createdBy, createdAt,
* lastLoginAt, lastLoginIp, loginCount
* }
* },
* order: [userId, ...]
* }
*
* data/authorized-users.json the ALLOWLIST. Emails on this list may log in.
* The bootstrap user (first-ever login) is
* implicitly authorized even if the file is
* empty. Shape: { emails: ["a@x.com", ...] }
*
* Bootstrap rule: the FIRST email to ever successfully authenticate is
* automatically granted role "admin" AND implicitly added to the allowlist.
* This is recorded by writing a sentinel file `data/.bootstrapped` with the
* admin email so we never bootstrap twice (e.g. after a restore from backup).
*
* Atomic writes: every persistence op writes to a .tmp file then renames.
* process restart loses nothing in flight because rename is atomic on POSIX.
*
* Concurrency: a single in-process mutex serializes mutating ops. We don't
* need cross-process locks because this API is single-instance by design.
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const ROLES = Object.freeze({
ADMIN: 'admin',
OPERATOR: 'operator',
VIEWER: 'viewer',
});
// All roles recognized by the system. Used for validation only.
const VALID_ROLES = new Set(Object.values(ROLES));
// Email shape — same pragmatic regex as the email provider.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function _nowIso() { return new Date().toISOString(); }
function _isEmail(s) { return typeof s === 'string' && EMAIL_RE.test(s); }
function _defaultUsers() { return { users: {}, order: [] }; }
function _defaultAllowlist() { return { emails: [] }; }
// Coerce a candidate to a writable string dataDir; return null otherwise.
// Used by the factory's resolver to ignore test proxies / function-typed
// values from universal-deps that the `||` short-circuit can't filter.
function _resolveDataDir(opts) {
const candidates = [
opts.dataDir,
opts.platformPaths && opts.platformPaths.dataDir,
platformPaths && platformPaths.dataDir,
];
for (const c of candidates) {
if (typeof c === 'string' && c.length > 0) return c;
}
return require('os').tmpdir();
}
function _atomicWriteJSON(filePath, data) {
const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
function _readJSON(filePath, fallback) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
if (err && err.code === 'ENOENT') return fallback;
// Corrupt file: log and return fallback so the API keeps serving.
// The next mutation will rewrite the file cleanly.
return fallback;
}
}
/**
* Factory. One user-store per process.
*
* @param {Object} [opts]
* @param {string} [opts.dataDir] override for tests
* @param {Object} [opts.log] structured logger
*/
function createUserStore(opts = {}) {
// Resolve dataDir defensively — universal-deps test proxies can return
// function-typed values for property access, which `||` won't filter.
const dataDir = _resolveDataDir(opts);
const log = opts.log || { info() {}, warn() {}, error() {} };
const usersFile = path.join(dataDir, 'users.json');
const allowlistFile = path.join(dataDir, 'authorized-users.json');
const bootstrapSentinel = path.join(dataDir, '.bootstrapped');
let _mutex = Promise.resolve();
function _enqueue(fn) {
const next = _mutex.then(fn, fn);
// Swallow errors on the chain so one failure doesn't poison subsequent ops.
_mutex = next.catch(() => {});
return next;
}
function _loadUsers() {
const data = _readJSON(usersFile, _defaultUsers());
if (!data.users || typeof data.users !== 'object') data.users = {};
if (!Array.isArray(data.order)) data.order = Object.keys(data.users);
return data;
}
function _loadAllowlist() {
const data = _readJSON(allowlistFile, _defaultAllowlist());
if (!Array.isArray(data.emails)) data.emails = [];
return data;
}
function _saveUsers(data) { _atomicWriteJSON(usersFile, data); }
function _saveAllowlist(data) { _atomicWriteJSON(allowlistFile, data); }
function _bootstrapDone() {
try { return fs.existsSync(bootstrapSentinel); }
catch { return false; }
}
function _writeBootstrapSentinel(adminEmail) {
_atomicWriteJSON(bootstrapSentinel, {
bootstrappedAt: _nowIso(),
adminEmail: adminEmail.toLowerCase(),
});
}
// ── Public API ─────────────────────────────────────────────────────────
/**
* Authenticate-or-create a user from an email. Implements the DC-048
* bootstrap rule and the authorized-users allowlist.
*
* Returns one of:
* { ok: true, user, role, isBootstrap }
* { ok: false, reason: 'not_authorized' }
*
* Reasons:
* 'not_authorized' email not in allowlist AND bootstrap already happened.
*
* If bootstrap hasn't happened yet (no users file, no .bootstrapped sentinel),
* the first email that successfully passes shape validation becomes admin
* AND gets added to the allowlist atomically.
*/
function login({ email, ip, displayName, createdBy } = {}) {
return _enqueue(() => {
if (!_isEmail(email)) {
return { ok: false, reason: 'invalid_email' };
}
const normalized = email.toLowerCase().trim();
const users = _loadUsers();
const allowlist = _loadAllowlist();
// Existing user → just bump login counters.
const existing = _findUserByEmail(users, normalized);
if (existing) {
existing.lastLoginAt = _nowIso();
existing.lastLoginIp = ip || '';
existing.loginCount = (existing.loginCount || 0) + 1;
_saveUsers(users);
log.info && log.info('user', 'login existing user', {
userId: existing.id, email: normalized, role: existing.role,
});
return { ok: true, user: existing, role: existing.role, isBootstrap: false };
}
// New email. Allow if (a) bootstrap hasn't happened, or (b) allowlisted.
const bootstrapPending = !_bootstrapDone() && users.order.length === 0;
const onAllowlist = allowlist.emails.includes(normalized);
if (!bootstrapPending && !onAllowlist) {
log.info && log.info('user', 'login denied — not on allowlist', { email: normalized });
return { ok: false, reason: 'not_authorized' };
}
// Bootstrap path: first-ever user becomes admin.
const isBootstrap = bootstrapPending;
const role = isBootstrap ? ROLES.ADMIN : ROLES.OPERATOR;
const newUser = {
id: crypto.randomUUID(),
email: normalized,
displayName: displayName || normalized.split('@')[0],
role,
createdBy: createdBy || (isBootstrap ? 'bootstrap' : 'invite'),
createdAt: _nowIso(),
lastLoginAt: _nowIso(),
lastLoginIp: ip || '',
loginCount: 1,
};
users.users[newUser.id] = newUser;
users.order.unshift(newUser.id);
// If bootstrap: implicitly allowlist + write sentinel.
if (isBootstrap) {
if (!allowlist.emails.includes(normalized)) {
allowlist.emails.push(normalized);
}
_saveAllowlist(allowlist);
_writeBootstrapSentinel(normalized);
}
_saveUsers(users);
log.info && log.info('user', isBootstrap ? 'bootstrap admin created' : 'invited user created', {
userId: newUser.id, email: normalized, role,
});
return { ok: true, user: newUser, role, isBootstrap };
});
}
/**
* Add an email to the allowlist WITHOUT creating a user record. Used when
* admin pre-authorizes someone who hasn't logged in yet.
*
* Returns { ok: true, alreadyExisted: boolean }.
*/
function addToAllowlist(email) {
return _enqueue(() => {
if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' };
const normalized = email.toLowerCase().trim();
const allowlist = _loadAllowlist();
if (allowlist.emails.includes(normalized)) {
return { ok: true, alreadyExisted: true };
}
allowlist.emails.push(normalized);
_saveAllowlist(allowlist);
log.info && log.info('user', 'added to allowlist', { email: normalized });
return { ok: true, alreadyExisted: false };
});
}
/**
* Remove an email from the allowlist. Does NOT delete the user record
* (so the admin can read the login history) but future logins by that
* email will be rejected unless bootstrap re-runs (which it won't).
*/
function removeFromAllowlist(email) {
return _enqueue(() => {
if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' };
const normalized = email.toLowerCase().trim();
const allowlist = _loadAllowlist();
const idx = allowlist.emails.indexOf(normalized);
if (idx === -1) return { ok: true, alreadyRemoved: true };
allowlist.emails.splice(idx, 1);
_saveAllowlist(allowlist);
log.info && log.info('user', 'removed from allowlist', { email: normalized });
return { ok: true, alreadyRemoved: false };
});
}
/**
* Update an existing user's role. Role must be in VALID_ROLES.
* Returns { ok: true } or { ok: false, reason }.
*/
function setRole(userId, role) {
return _enqueue(() => {
if (!VALID_ROLES.has(role)) return { ok: false, reason: 'invalid_role' };
const users = _loadUsers();
const u = users.users[userId];
if (!u) return { ok: false, reason: 'not_found' };
u.role = role;
_saveUsers(users);
log.info && log.info('user', 'role updated', { userId, role });
return { ok: true };
});
}
/**
* Delete a user record AND remove from allowlist. Cannot delete the last
* admin (you'd lock yourself out). Returns { ok: true } or { ok: false, reason }.
*/
function deleteUser(userId) {
return _enqueue(() => {
const users = _loadUsers();
const u = users.users[userId];
if (!u) return { ok: false, reason: 'not_found' };
// Count remaining admins.
const remainingAdmins = users.order
.map(id => users.users[id])
.filter(x => x && x.role === ROLES.ADMIN && x.id !== userId).length;
if (u.role === ROLES.ADMIN && remainingAdmins === 0) {
return { ok: false, reason: 'last_admin' };
}
delete users.users[userId];
users.order = users.order.filter(id => id !== userId);
// Also remove from allowlist so re-invite is a clean slate.
const allowlist = _loadAllowlist();
const idx = allowlist.emails.indexOf(u.email);
if (idx !== -1) {
allowlist.emails.splice(idx, 1);
_saveAllowlist(allowlist);
}
_saveUsers(users);
log.info && log.info('user', 'user deleted', { userId, email: u.email });
return { ok: true };
});
}
function listUsers() {
return _enqueue(() => {
const users = _loadUsers();
return users.order
.map(id => users.users[id])
.filter(Boolean);
});
}
function listAllowlist() {
return _enqueue(() => {
const allowlist = _loadAllowlist();
return [...allowlist.emails];
});
}
function getUser(userId) {
return _enqueue(() => {
const users = _loadUsers();
return users.users[userId] || null;
});
}
function getUserByEmail(email) {
return _enqueue(() => {
if (!_isEmail(email)) return null;
const users = _loadUsers();
return _findUserByEmail(users, email.toLowerCase().trim()) || null;
});
}
function isBootstrapComplete() {
return _enqueue(() => _bootstrapDone());
}
/**
* Helper for the auth system: given an email, return whether the user
* is allowed to attempt login (allowlist OR bootstrap-pending). Used by
* the email provider's `authorizedEmails()` dependency.
*/
function isEmailAuthorized(email) {
return _enqueue(() => {
if (!_isEmail(email)) return false;
const normalized = email.toLowerCase().trim();
const allowlist = _loadAllowlist();
if (allowlist.emails.includes(normalized)) return true;
const users = _loadUsers();
// Bootstrap path: if no users yet, the first login is implicitly allowed.
return users.order.length === 0 && !_bootstrapDone();
});
}
function _findUserByEmail(users, normalizedEmail) {
for (const id of users.order) {
const u = users.users[id];
if (u && u.email === normalizedEmail) return u;
}
return null;
}
return {
login,
addToAllowlist,
removeFromAllowlist,
setRole,
deleteUser,
listUsers,
listAllowlist,
getUser,
getUserByEmail,
isBootstrapComplete,
isEmailAuthorized,
// Constants for callers
ROLES,
VALID_ROLES,
};
}
module.exports = { createUserStore, ROLES, VALID_ROLES };
@@ -335,6 +335,14 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/auth/login/:provider/verify', exact: true, method: 'POST' },
{ path: '/api/v1/auth/login/recovery-info', exact: true, method: 'GET' },
{ path: '/api/v1/auth/disable/:provider', exact: true, method: 'POST' },
// DC-048: invite redemption is PUBLIC (recipient comes from an email
// link with no session cookie). The peek route is also public so the
// UI can show "this invite is for X, expires Y" before clicking.
{ path: '/api/v1/auth/invites/:token', exact: true, method: 'GET' },
{ path: '/api/v1/auth/invites/:token/accept', exact: true, method: 'POST' },
// /me and /admin/* require authentication — NOT public. Listed here
// only to document them; absence from PUBLIC_ROUTES means they go
// through the normal auth gate. CSRF applies to writes as usual.
{ path: '/api/v1/services', exact: true, method: 'GET' },
{ path: '/api/v1/ca/info', exact: true, method: 'GET' },
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
+4
View File
@@ -30,6 +30,10 @@ const bundles = {
JS('totp-recovery.js'),
JS('service-credentials.js'),
JS('totp-settings.js'),
// DC-048 admin panel — modal-overlay UI for user/invite management.
// Renders the "Admin" trigger button into the top bar; only visible
// when /api/v1/auth/me returns isAdmin=true.
JS('admin.js'),
JS('core', 'credentials.js'),
JS('core', 'grid.js'),
JS('core', 'dns.js'),
+127 -93
View File
File diff suppressed because one or more lines are too long
+323 -212
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
File diff suppressed because one or more lines are too long
+461
View File
@@ -0,0 +1,461 @@
/**
* Admin panel DC-048.
*
* Minimal admin UI for managing users + invites. Rendered as a modal overlay
* triggered by an "Admin" button in the top bar that only appears when
* /api/v1/auth/me returns isAdmin=true. The panel renders three sections:
*
* 1. Users list of authorized users with role badges, role-edit,
* delete actions.
* 2. Invite a user form to issue a single-use invite (email, role,
* TTL). The accept-link is shown post-issue so the admin can copy it.
* 3. Outstanding invites list of issued-not-yet-accepted invites
* with a revoke button.
*
* The panel does NOT add a tab to the dashboard nav it lives as a modal
* to keep DC-048 surgical. Future DC-049 work can promote it to a tab.
*
* Behaviour:
* - On load: GET /me; if !isAdmin show "admin only" placeholder
* - Then GET /admin/users + /admin/invites in parallel
* - Forms POST to the admin endpoints, refresh lists on success
* - "Copy link" button writes the acceptUrl to the clipboard
*
* Wires into the global error-handler (window.errorHandler) for failure
* surfaces. Uses window.SITE for any UI constants (none currently).
*/
(function () {
'use strict';
const API = {
me: '/api/v1/auth/me',
users: '/api/v1/auth/admin/users',
allowlist: '/api/v1/auth/admin/allowlist',
invites: '/api/v1/auth/admin/invites',
};
function _el(tag, attrs, ...children) {
const node = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v === null || v === undefined || v === false) continue;
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else if (k === 'html') node.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else node.setAttribute(k, v);
}
}
for (const c of children) {
if (c === null || c === undefined || c === false) continue;
if (typeof c === 'string') node.appendChild(document.createTextNode(c));
else node.appendChild(c);
}
return node;
}
async function _fetchJSON(url, opts) {
const csrf = (window.SITE && window.SITE.csrfToken) || '';
opts = opts || {};
opts.headers = Object.assign(
{ 'Content-Type': 'application/json' },
opts.headers || {},
csrf ? { 'X-CSRF-Token': csrf } : {}
);
if (opts.body && typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body);
const r = await fetch(url, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = (data && (data.message || data.error)) || ('HTTP ' + r.status);
const err = new Error(msg);
err.status = r.status;
throw err;
}
return data;
}
function _renderBadge(role) {
const colors = {
admin: 'background:#7c3aed;color:#fff',
operator: 'background:#2563eb;color:#fff',
viewer: 'background:#6b7280;color:#fff',
};
return _el('span', {
class: 'role-badge',
style: 'display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;' +
(colors[role] || colors.viewer),
text: role,
});
}
function _renderUsersList(container, users, onChange) {
container.innerHTML = '';
if (!users || users.length === 0) {
container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No users yet.' }));
return;
}
const table = _el('table', {
style: 'width:100%;border-collapse:collapse;font-size:0.9rem',
});
table.appendChild(_el('thead', null,
_el('tr', { style: 'border-bottom:1px solid var(--border)' },
_el('th', { style: 'text-align:left;padding:8px', text: 'Email' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Role' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Created' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Last login' }),
_el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }),
),
));
const tbody = _el('tbody');
for (const u of users) {
const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' });
const emailCell = _el('td', { style: 'padding:8px' });
emailCell.appendChild(_el('span', { text: u.email || '(no email)' }));
if (u.displayName && u.displayName !== (u.email || '').split('@')[0]) {
emailCell.appendChild(_el('br'));
emailCell.appendChild(_el('small', {
style: 'color:var(--muted)', text: u.displayName,
}));
}
row.appendChild(emailCell);
const roleCell = _el('td', { style: 'padding:8px' });
roleCell.appendChild(_renderBadge(u.role));
row.appendChild(roleCell);
row.appendChild(_el('td', {
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
text: u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—',
}));
row.appendChild(_el('td', {
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
text: u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '—',
}));
const actionsCell = _el('td', { style: 'padding:8px;text-align:right' });
const roleSelect = _el('select', {
style: 'padding:2px 6px;margin-right:6px',
onchange: async (ev) => {
try {
await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), {
method: 'PATCH', body: { role: ev.target.value },
});
onChange && onChange();
} catch (e) {
window.errorHandler && window.errorHandler.show('Role update failed: ' + e.message);
ev.target.value = u.role;
}
},
});
for (const r of ['admin', 'operator', 'viewer']) {
const opt = _el('option', { value: r, text: r });
if (r === u.role) opt.selected = true;
roleSelect.appendChild(opt);
}
actionsCell.appendChild(roleSelect);
const delBtn = _el('button', {
class: 'btn-sm', style: 'padding:2px 8px',
text: 'Delete',
onclick: async () => {
if (!confirm('Delete user ' + (u.email || u.id) + '? This cannot be undone.')) return;
try {
await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), { method: 'DELETE' });
onChange && onChange();
} catch (e) {
window.errorHandler && window.errorHandler.show('Delete failed: ' + e.message);
}
},
});
actionsCell.appendChild(delBtn);
row.appendChild(actionsCell);
tbody.appendChild(row);
}
table.appendChild(tbody);
container.appendChild(table);
}
function _renderInviteForm(container, onIssued) {
const form = _el('form', {
style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:end',
onsubmit: async (ev) => {
ev.preventDefault();
const fd = new FormData(ev.target);
const body = {
email: fd.get('email'),
role: fd.get('role'),
ttlHours: parseInt(fd.get('ttlHours'), 10) || 24,
sendEmail: fd.get('sendEmail') === 'on',
};
try {
const r = await _fetchJSON(API.invites, { method: 'POST', body });
ev.target.reset();
onIssued && onIssued(r);
} catch (e) {
window.errorHandler && window.errorHandler.show('Invite failed: ' + e.message);
}
},
});
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
_el('span', { text: 'Email' }),
_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }),
));
const roleSel = _el('select', { name: 'role', style: 'padding:6px' });
for (const r of ['operator', 'viewer', 'admin']) {
roleSel.appendChild(_el('option', { value: r, text: r }));
}
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
_el('span', { text: 'Role' }), roleSel,
));
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
_el('span', { text: 'TTL (hours)' }),
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
));
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
_el('input', { name: 'sendEmail', type: 'checkbox', checked: true }),
_el('span', { text: 'Send email' }),
));
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
container.appendChild(form);
}
function _renderInvitesList(container, invites, onChange) {
container.innerHTML = '';
if (!invites || invites.length === 0) {
container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No outstanding invites.' }));
return;
}
const table = _el('table', {
style: 'width:100%;border-collapse:collapse;font-size:0.9rem',
});
table.appendChild(_el('thead', null,
_el('tr', { style: 'border-bottom:1px solid var(--border)' },
_el('th', { style: 'text-align:left;padding:8px', text: 'Email' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Role' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Invited by' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Expires' }),
_el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }),
),
));
const tbody = _el('tbody');
for (const inv of invites) {
const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' });
row.appendChild(_el('td', { style: 'padding:8px', text: inv.email }));
row.appendChild(_el('td', { style: 'padding:8px' }, _renderBadge(inv.role)));
row.appendChild(_el('td', { style: 'padding:8px;color:var(--muted)', text: inv.invitedBy || '—' }));
row.appendChild(_el('td', {
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
text: inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : '—',
}));
const actionsCell = _el('td', { style: 'padding:8px;text-align:right' });
actionsCell.appendChild(_el('button', {
class: 'btn-sm', style: 'padding:2px 8px',
text: 'Revoke',
onclick: async () => {
if (!confirm('Revoke invite for ' + inv.email + '?')) return;
try {
await _fetchJSON(API.invites + '/' + encodeURIComponent(inv.id), { method: 'DELETE' });
onChange && onChange();
} catch (e) {
window.errorHandler && window.errorHandler.show('Revoke failed: ' + e.message);
}
},
}));
row.appendChild(actionsCell);
tbody.appendChild(row);
}
table.appendChild(tbody);
container.appendChild(table);
}
function _renderIssuedInviteBanner(invite, parent) {
const banner = _el('div', {
style: 'margin-top:12px;padding:12px;border:1px solid #16a34a;border-radius:6px;background:#052e1a;color:#bbf7d0;font-size:0.85rem',
});
banner.appendChild(_el('strong', { text: 'Invite issued — copy the link below. ' +
'It will not be shown again.' }));
banner.appendChild(_el('br'));
banner.appendChild(_el('code', {
style: 'display:block;margin-top:8px;padding:8px;background:#000;border-radius:4px;word-break:break-all;color:#d1fae5',
text: invite.acceptUrl,
}));
const copyBtn = _el('button', {
class: 'btn-sm', style: 'margin-top:8px;padding:4px 10px',
text: 'Copy link',
onclick: async () => {
try {
await navigator.clipboard.writeText(invite.acceptUrl);
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy link'; }, 2000);
} catch (e) {
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the link manually.');
}
},
});
banner.appendChild(copyBtn);
if (invite.deliveredVia === 'dev-console') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
}));
} else if (invite.deliveredVia === 'email') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
text: 'Email sent to ' + invite.email + '.',
}));
}
parent.appendChild(banner);
}
/**
* Mount the admin panel. Called by the open trigger; safe to call multiple
* times (re-renders into the same container).
*/
async function mount(container) {
container.innerHTML = '';
container.appendChild(_el('h2', { style: 'margin:0 0 16px', text: 'Admin · Users & Invites' }));
const meData = await _fetchJSON(API.me).catch(() => ({}));
if (!meData || !meData.user || meData.user.role !== 'admin') {
container.appendChild(_el('p', {
style: 'color:var(--muted)',
text: 'Admin role required to view this panel. If multi-user mode is enabled and you should have access, check /api/v1/auth/me.',
}));
return;
}
// Refresh button
const refreshBtn = _el('button', {
class: 'btn-sm', style: 'float:right;padding:4px 10px',
text: 'Refresh',
onclick: () => mount(container),
});
container.appendChild(refreshBtn);
// ── Users section ────────────────────────────────────────────────────
const usersHeader = _el('h3', { style: 'margin:24px 0 8px;clear:both', text: 'Users' });
container.appendChild(usersHeader);
const usersList = _el('div', { id: 'admin-users-list' });
container.appendChild(usersList);
const usersData = await _fetchJSON(API.users).catch(() => ({ users: [] }));
_renderUsersList(usersList, usersData.users, () => mount(container));
// Add user form (pre-authorize an email without issuing an invite).
container.appendChild(_el('h4', { style: 'margin:24px 0 8px;font-size:0.95rem', text: 'Pre-authorize email' }));
const addUserForm = _el('form', {
style: 'display:flex;gap:8px;align-items:end',
onsubmit: async (ev) => {
ev.preventDefault();
const email = ev.target.email.value.trim();
if (!email) return;
try {
await _fetchJSON(API.users, { method: 'POST', body: { email } });
ev.target.reset();
mount(container);
} catch (e) {
window.errorHandler && window.errorHandler.show('Add failed: ' + e.message);
}
},
});
addUserForm.appendChild(_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }));
addUserForm.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Add to allowlist' }));
container.appendChild(addUserForm);
// ── Invites section ──────────────────────────────────────────────────
container.appendChild(_el('h3', { style: 'margin:24px 0 8px', text: 'Issue invite' }));
const inviteFormContainer = _el('div');
container.appendChild(inviteFormContainer);
const invitesList = _el('div', { id: 'admin-invites-list', style: 'margin-top:16px' });
container.appendChild(invitesList);
const invitesData = await _fetchJSON(API.invites).catch(() => ({ invites: [] }));
_renderInvitesList(invitesList, invitesData.invites, () => mount(container));
_renderInviteForm(inviteFormContainer, (issued) => {
_renderIssuedInviteBanner(issued, inviteFormContainer);
mount(container);
});
}
// ── Public API ────────────────────────────────────────────────────────
/**
* Open the admin panel as a modal overlay. Closes on backdrop click or
* the close button. Renders into document.body so it floats above the
* dashboard chrome.
*/
async function open() {
// If already open, just focus.
const existing = document.getElementById('admin-panel-root');
if (existing) return;
const backdrop = _el('div', {
id: 'admin-panel-root',
style: 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;',
onclick: (ev) => {
if (ev.target === backdrop) close();
},
});
const card = _el('div', {
style: 'background:var(--card-base,#1f2937);color:var(--text,#f3f4f6);border-radius:8px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;box-shadow:0 10px 30px rgba(0,0,0,0.3)',
});
card.appendChild(_el('button', {
class: 'btn-sm', style: 'position:absolute;top:12px;right:12px;padding:4px 10px',
text: 'Close', onclick: close,
}));
const body = _el('div', { id: 'admin-panel-body' });
card.appendChild(body);
backdrop.appendChild(card);
document.body.appendChild(backdrop);
try {
await mount(body);
} catch (e) {
body.innerHTML = '<p style="color:#f87171">Failed to load admin panel: ' + (e.message || e) + '</p>';
}
}
function close() {
const existing = document.getElementById('admin-panel-root');
if (existing) existing.remove();
}
/**
* Inject an "Admin" button into the top bar. Only renders when the
* current /me response says isAdmin=true. Re-checks periodically
* (every 60s) so a permission downgrade takes effect without a reload.
*/
async function attachTrigger(barContainer) {
async function _maybeShow() {
const me = await _fetchJSON(API.me).catch(() => ({}));
const existing = document.getElementById('admin-trigger-btn');
if (me && me.user && me.user.role === 'admin') {
if (existing) return;
const btn = _el('button', {
id: 'admin-trigger-btn',
class: 'btn-sm',
style: 'margin-left:8px;padding:6px 12px',
text: 'Admin',
onclick: open,
});
if (barContainer) barContainer.appendChild(btn);
else if (document.body) document.body.appendChild(btn);
} else if (existing) {
existing.remove();
}
}
await _maybeShow();
setInterval(_maybeShow, 60_000);
}
window.AdminPanel = { open, close, attachTrigger };
})();
+11
View File
@@ -84,6 +84,17 @@
if (shouldLoadOnboarding()) {
loadOnboarding();
}
// DC-048: inject the "Admin" trigger button into the top bar. The
// button only renders when /me returns isAdmin=true; the module
// re-checks every 60s so a permission downgrade takes effect.
if (window.AdminPanel && typeof window.AdminPanel.attachTrigger === 'function') {
try {
await window.AdminPanel.attachTrigger(document.body);
} catch (e) {
console.warn('[init] AdminPanel attachTrigger failed:', e);
}
}
}
// Lazy-load onboarding bundle (52 KB) — only loaded when needed
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-79829761c4';
const CACHE = 'dashcaddy-shell-1b7c08184e';
const PRECACHE = [
'/',
'/index.html',