DC-048: multi-user bootstrap + admin invites (opt-in)
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:
+3
-2
@@ -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.
|
- **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
|
### DC-048: Multi-user bootstrap + admin invites
|
||||||
- **status:** todo
|
- **status:** done
|
||||||
- **owner:** unclaimed
|
- **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).
|
- **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.
|
- **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).
|
- **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)
|
### Backlog note (2026-07-20, hermes)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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 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.
|
- **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.
|
- **`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)', () => {
|
describe('No stale PUBLIC_ROUTES entries (the DC-012 failure mode)', () => {
|
||||||
test('every PUBLIC_ROUTES entry matches an actual mounted route', () => {
|
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 = [];
|
const stale = [];
|
||||||
for (const entry of publicRoutes) {
|
for (const entry of publicRoutes) {
|
||||||
if (entry.endsWith('/')) continue; // prefix matches, skip
|
if (entry.endsWith('/')) continue; // prefix matches, skip
|
||||||
|
if (conditionalMounts.has(entry)) continue; // gated by config flag
|
||||||
if (!mountedRoutes.has(entry)) stale.push(entry);
|
if (!mountedRoutes.has(entry)) stale.push(entry);
|
||||||
}
|
}
|
||||||
expect(stale).toEqual([]);
|
expect(stale).toEqual([]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -4,7 +4,9 @@ const initKeys = require('./keys');
|
|||||||
const initSessionHandlers = require('./session-handlers');
|
const initSessionHandlers = require('./session-handlers');
|
||||||
const initSsoGate = require('./sso-gate');
|
const initSsoGate = require('./sso-gate');
|
||||||
const initLogin = require('./login');
|
const initLogin = require('./login');
|
||||||
|
const initAdmin = require('./admin');
|
||||||
const { createAuthProviderRegistry } = require('../../src/auth/providers');
|
const { createAuthProviderRegistry } = require('../../src/auth/providers');
|
||||||
|
const { createUserStore } = require('../../src/security/user-store');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth routes aggregator
|
* Auth routes aggregator
|
||||||
@@ -39,6 +41,32 @@ function _extractEmailConfig(ctx) {
|
|||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const router = express.Router();
|
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
|
// Extract dependencies from context
|
||||||
const deps = {
|
const deps = {
|
||||||
authManager: ctx.authManager,
|
authManager: ctx.authManager,
|
||||||
@@ -62,7 +90,11 @@ module.exports = function(ctx) {
|
|||||||
notificationManager: ctx.notification,
|
notificationManager: ctx.notification,
|
||||||
siteConfig: ctx.siteConfig,
|
siteConfig: ctx.siteConfig,
|
||||||
// DC-047: data-directory resolution for the email-token JSON store.
|
// 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);
|
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
|
||||||
@@ -75,7 +107,7 @@ module.exports = function(ctx) {
|
|||||||
credentialManager: ctx.credentialManager,
|
credentialManager: ctx.credentialManager,
|
||||||
session: ctx.session,
|
session: ctx.session,
|
||||||
saveTotpConfig: ctx.saveTotpConfig,
|
saveTotpConfig: ctx.saveTotpConfig,
|
||||||
config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: true } },
|
config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: false } },
|
||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken,
|
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken,
|
||||||
// DC-047: EmailMagicLinkProvider needs SMTP config + a public URL
|
// DC-047: EmailMagicLinkProvider needs SMTP config + a public URL
|
||||||
@@ -84,6 +116,9 @@ module.exports = function(ctx) {
|
|||||||
emailConfig: _extractEmailConfig(ctx),
|
emailConfig: _extractEmailConfig(ctx),
|
||||||
siteConfig: ctx.siteConfig || {},
|
siteConfig: ctx.siteConfig || {},
|
||||||
platformPaths: deps.platformPaths,
|
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
|
ctx.siteConfig
|
||||||
);
|
);
|
||||||
@@ -106,5 +141,18 @@ module.exports = function(ctx) {
|
|||||||
router.use(initKeys(deps));
|
router.use(initKeys(deps));
|
||||||
router.use(initSsoGate({ ...deps, getAppSession, appSessionCache }));
|
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;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,8 +81,9 @@ class EmailMagicLinkProvider extends AuthProvider {
|
|||||||
this.store.startPruneTimer();
|
this.store.startPruneTimer();
|
||||||
|
|
||||||
this.emailConfig = deps.emailConfig || null;
|
this.emailConfig = deps.emailConfig || null;
|
||||||
// DC-048 will replace this with the real authorized-users check.
|
// DC-048: real authorized-users check via the user store. Falls back
|
||||||
this.authorizedEmails = deps.authorizedEmails || (() => true);
|
// to "allow everyone" if no store is wired (dev/legacy installs).
|
||||||
|
this.userStore = deps.userStore || null;
|
||||||
// Public URL templates — overridable for testing.
|
// Public URL templates — overridable for testing.
|
||||||
this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS;
|
this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS;
|
||||||
this.maxBodyLength = 32_000;
|
this.maxBodyLength = 32_000;
|
||||||
@@ -131,10 +132,12 @@ class EmailMagicLinkProvider extends AuthProvider {
|
|||||||
*/
|
*/
|
||||||
_isProviderEnabled() {
|
_isProviderEnabled() {
|
||||||
const flag = this.deps.config && this.deps.config.enabled;
|
const flag = this.deps.config && this.deps.config.enabled;
|
||||||
// Default to TRUE: dev installs should "just work". Operators who want
|
// Default to FALSE (DC-048 opt-in): operators must explicitly enable
|
||||||
// to disable email login set `siteConfig.authProviders.email.enabled = false`
|
// email auth via `siteConfig.authProviders.email.enabled = true`. Until
|
||||||
// — the same config knob the TOTP provider uses.
|
// then, the email login methods endpoint reports the provider as
|
||||||
if (flag === false) return false;
|
// 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;
|
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');
|
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).
|
// 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', {
|
this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link verified', {
|
||||||
email: record.email,
|
email: record.email,
|
||||||
ip: this._clientIP(req),
|
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.
|
// 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.create(req, this.deps.config && this.deps.config.sessionDuration || '24h');
|
||||||
this.deps.session.setCookie(res, 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')
|
? this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https')
|
||||||
: undefined;
|
: 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, {
|
return ok(res, {
|
||||||
message: 'Authenticated successfully',
|
message: 'Authenticated successfully',
|
||||||
method: 'email',
|
method: 'email',
|
||||||
email: AuthProvider.maskEmail(record.email),
|
email: AuthProvider.maskEmail(record.email),
|
||||||
csrfToken: newCsrf,
|
csrfToken: newCsrf,
|
||||||
|
user: userRecord
|
||||||
|
? {
|
||||||
|
id: userRecord.id,
|
||||||
|
email: userRecord.email,
|
||||||
|
role: userRecord.role,
|
||||||
|
isBootstrap,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ function createAuthProviderRegistry(deps, config) {
|
|||||||
...deps,
|
...deps,
|
||||||
config: deps.config.totp, // the existing totpConfig object from app.js
|
config: deps.config.totp, // the existing totpConfig object from app.js
|
||||||
saveProviderConfig: deps.saveTotpConfig, // existing helper
|
saveProviderConfig: deps.saveTotpConfig, // existing helper
|
||||||
|
// DC-048: user store for bootstrap + audit attribution on TOTP logins.
|
||||||
|
userStore: deps.userStore || null,
|
||||||
});
|
});
|
||||||
providers.set('totp', totpProvider);
|
providers.set('totp', totpProvider);
|
||||||
|
|
||||||
@@ -56,9 +58,9 @@ function createAuthProviderRegistry(deps, config) {
|
|||||||
saveProviderConfig: deps.saveProviderConfig || (async () => {}),
|
saveProviderConfig: deps.saveProviderConfig || (async () => {}),
|
||||||
emailConfig: deps.emailConfig || null,
|
emailConfig: deps.emailConfig || null,
|
||||||
siteConfig: deps.siteConfig || {},
|
siteConfig: deps.siteConfig || {},
|
||||||
// DC-048 hook: today every email is authorized. Once multi-user ships,
|
// DC-048: real authorization check via the user store. Without it,
|
||||||
// this is replaced with a real allowlist check.
|
// every email is allowed (legacy single-user behavior).
|
||||||
authorizedEmails: deps.authorizedEmails || (() => true),
|
userStore: deps.userStore || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Future: OIDC, SAML, passkeys — each gated on config.authProviders
|
// Future: OIDC, SAML, passkeys — each gated on config.authProviders
|
||||||
|
|||||||
@@ -274,6 +274,51 @@ class TotpProvider extends AuthProvider {
|
|||||||
this.deps.session.create(req, this.deps.config.sessionDuration);
|
this.deps.session.create(req, this.deps.config.sessionDuration);
|
||||||
this.deps.session.setCookie(res, 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');
|
const newCsrfToken = this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https');
|
||||||
this.deps.log.debug('auth', 'Session created', { sessions: this.deps.session.ipSessions.size });
|
this.deps.log.debug('auth', 'Session created', { sessions: this.deps.session.ipSessions.size });
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,18 @@ class AuditLogger {
|
|||||||
details.body = safe;
|
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(() => {});
|
this.log({ action, resource, details, outcome, ip }).catch(() => {});
|
||||||
|
|
||||||
return originalJson(data);
|
return originalJson(data);
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ function csrfValidationMiddleware(req, res, next) {
|
|||||||
'/api/v1/auth/login/:provider/verify',
|
'/api/v1/auth/login/:provider/verify',
|
||||||
'/api/v1/auth/login/:provider/initiate',
|
'/api/v1/auth/login/:provider/initiate',
|
||||||
'/api/v1/auth/disable/:provider',
|
'/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',
|
||||||
'/health/live',
|
'/health/live',
|
||||||
'/health/ready',
|
'/health/ready',
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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/:provider/verify', exact: true, method: 'POST' },
|
||||||
{ path: '/api/v1/auth/login/recovery-info', exact: true, method: 'GET' },
|
{ path: '/api/v1/auth/login/recovery-info', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/auth/disable/:provider', exact: true, method: 'POST' },
|
{ 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/services', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/ca/info', exact: true, method: 'GET' },
|
{ path: '/api/v1/ca/info', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
|
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ const bundles = {
|
|||||||
JS('totp-recovery.js'),
|
JS('totp-recovery.js'),
|
||||||
JS('service-credentials.js'),
|
JS('service-credentials.js'),
|
||||||
JS('totp-settings.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', 'credentials.js'),
|
||||||
JS('core', 'grid.js'),
|
JS('core', 'grid.js'),
|
||||||
JS('core', 'dns.js'),
|
JS('core', 'dns.js'),
|
||||||
|
|||||||
Vendored
+127
-93
File diff suppressed because one or more lines are too long
Vendored
+323
-212
File diff suppressed because one or more lines are too long
Vendored
+6
-6
File diff suppressed because one or more lines are too long
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -84,6 +84,17 @@
|
|||||||
if (shouldLoadOnboarding()) {
|
if (shouldLoadOnboarding()) {
|
||||||
loadOnboarding();
|
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
|
// Lazy-load onboarding bundle (52 KB) — only loaded when needed
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-79829761c4';
|
const CACHE = 'dashcaddy-shell-1b7c08184e';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user