auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
Scope (locked in via planning Q&A):
- Identity: local accounts only (PG users table) + existing bearer
tokens for headless callers.
- Transport: httpOnly cookie session for browser, Bearer for API.
- RBAC: admin / editor / viewer roles, plus an orthogonal
is_client flag for external (agency, talent, customer) accounts.
- Bootstrap: ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD env
seed the first admin on a clean install. Set ADMIN_BOOTSTRAP_RESET
to force-reset the named user (break-glass).
- Rate limit: in-memory, 10 fails per 15min per (IP, username).
- Password policy: \u22658 chars, mixed case, digit, symbol; small
blocklist of common passwords; cannot equal username.
- Self-service: change own display name + password. Everything
else (role, is_client, other-user mgmt) is admin only.
- Audit log: append-only table, indexed by actor + event_type +
created_at, populated by every auth/admin event.
Files added:
- services/mam-api/src/db/migrations/022-auth-rework.sql
users.is_client + last_login_at + failed_attempts; audit_log
table with FK to users (ON DELETE SET NULL).
- services/mam-api/src/middleware/audit.js
Fire-and-forget audit() helper. Caller never awaits, failure
logs but never throws — auditing cannot break the request
that triggered it.
- services/mam-api/src/middleware/passwordPolicy.js
Shared checkPassword(pw, { username }) used by setup, user
create/update, and self-service password change.
- services/mam-api/src/tasks/bootstrapAdmin.js
Runs after migrations. No-ops unless ADMIN_BOOTSTRAP_USER +
ADMIN_BOOTSTRAP_PASSWORD are set AND (users table empty OR
ADMIN_BOOTSTRAP_RESET=true).
- services/mam-api/src/routes/audit.js
Admin-only GET /audit (paginated, filter by event_type /
actor / target / date) and GET /audit/event-types.
- services/web-ui/public/modal-account-settings.jsx
Profile + Password tabs. Triggered by sidebar user button.
Files rewritten:
- services/mam-api/src/routes/auth.js
- POST /login: regenerate(), no manual save(); audit success/
fail/lockout; updates last_login_at + failed_attempts.
- POST /logout: destroys session, audits logout.
- GET /me: returns is_client + last_login_at. Synthetic admin
when AUTH_ENABLED=false.
- GET /setup-status: drives login.html UI state.
- POST /setup: blocked once any user exists; password policy.
- POST /password: self-service. Requires current pw, runs
policy, audits, invalidates other sessions implicitly via
users.js if changed by admin.
- PATCH /me: self-service display_name update.
- services/mam-api/src/routes/users.js
- is_client field in create/update/list/get.
- Guardrails: cannot delete or demote last admin, cannot
delete self, admins cannot be flagged is_client.
- Password change invalidates all sessions for that user
(DELETE FROM sessions WHERE sess->>'userId' = id).
- Audit on every mutation.
- Password policy enforced.
- services/mam-api/src/middleware/auth.js
- requireAuth now exposes req.user.is_client.
- New requireRole(["admin","editor"], { rejectClients: true })
helper. Applied to cluster, sdk, capture routes (infra).
- Synthetic user when AUTH_ENABLED=false has is_client=false.
- services/mam-api/src/index.js
- Loads bootstrap admin after migrations.
- Wires /api/v1/audit.
- Cleans up an earlier comment block.
- services/web-ui/public/login.html
- Password hint added next to setup-mode password field.
- services/web-ui/public/shell.jsx
- Sidebar user footer is a button that opens AccountSettings.
- CLIENT badge next to role when is_client=true.
- Nav filters: clients lose ingest tree + jobs + editor;
viewers lose ingest + editor; only admins see the Admin
section. Power button hidden when synthetic user.
- services/web-ui/public/screens-admin.jsx
- Users table: new Client column with inline toggle.
- InviteUserModal: Client checkbox + password hint, gated
off when role=admin.
- Last login column replaces Created in primary view.
- CSV export includes client + last_login.
- services/web-ui/public/data.jsx
- ZAMPP_DATA.ME carries is_client + display_name.
- services/web-ui/public/index.html
- Loads dist/modal-account-settings.js.
- services/web-ui/public/styles-rest.css
- .user-row grid widened to 6 columns.
- docker-compose.yml
- Plumbs SESSION_COOKIE_SECURE + ADMIN_BOOTSTRAP_* env vars.
Deploy:
cd /opt/wild-dragon
git pull origin main
# In .env:
# AUTH_ENABLED=true
# SESSION_SECRET=<openssl rand -hex 48>
# ADMIN_BOOTSTRAP_USER=admin
# ADMIN_BOOTSTRAP_PASSWORD=<strong>
docker compose build mam-api web-ui
docker compose up -d --force-recreate --no-deps mam-api web-ui
This commit is contained in:
parent
a48e1d9dd7
commit
002e5acb82
23 changed files with 1016 additions and 303 deletions
|
|
@ -55,6 +55,11 @@ services:
|
|||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-}
|
||||
ADMIN_BOOTSTRAP_USER: ${ADMIN_BOOTSTRAP_USER:-}
|
||||
ADMIN_BOOTSTRAP_PASSWORD: ${ADMIN_BOOTSTRAP_PASSWORD:-}
|
||||
ADMIN_BOOTSTRAP_DISPLAY_NAME: ${ADMIN_BOOTSTRAP_DISPLAY_NAME:-}
|
||||
ADMIN_BOOTSTRAP_RESET: ${ADMIN_BOOTSTRAP_RESET:-}
|
||||
DOCKER_NETWORK: wild-dragon_wild-dragon
|
||||
NODE_IP: ${NODE_IP}
|
||||
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
|
||||
|
|
|
|||
38
services/mam-api/src/db/migrations/022-auth-rework.sql
Normal file
38
services/mam-api/src/db/migrations/022-auth-rework.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- Auth rework: client flag + audit log + safety constraints.
|
||||
--
|
||||
-- - users.is_client → orthogonal to role. Editors/viewers tagged as
|
||||
-- clients see a restricted UI (no recorders /
|
||||
-- cluster admin / hi-res original download).
|
||||
-- - users.last_login_at → for the admin Users page "Last seen" column.
|
||||
-- - users.failed_attempts → not authoritative (in-memory rate-limit is),
|
||||
-- but lets admins see who's been hammered on.
|
||||
-- - audit_log → append-only log of auth + admin events.
|
||||
-- Cheap, indexed by user + created_at.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS is_client BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS failed_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS users_is_client_idx ON users (is_client) WHERE is_client = TRUE;
|
||||
|
||||
-- Audit log. `actor_id` is the user performing the action (NULL for failed
|
||||
-- logins where we don't yet know who they're claiming to be). `target_type`
|
||||
-- + `target_id` identify what was changed (user, token, schedule, etc.).
|
||||
-- `meta` is a free-form jsonb with details specific to the event type.
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
event_type TEXT NOT NULL,
|
||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_label TEXT,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS audit_log_created_idx ON audit_log (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS audit_log_type_idx ON audit_log (event_type, created_at DESC);
|
||||
|
|
@ -32,6 +32,7 @@ import metricsRouter from './routes/metrics.js';
|
|||
import commentsRouter from './routes/comments.js';
|
||||
import importsRouter from './routes/imports.js';
|
||||
import storageRouter from './routes/storage.js';
|
||||
import auditRouter from './routes/audit.js';
|
||||
import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js';
|
||||
import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
|
||||
|
||||
|
|
@ -118,6 +119,7 @@ app.use('/api/v1/metrics', metricsRouter);
|
|||
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
|
||||
app.use('/api/v1/imports', importsRouter);
|
||||
app.use('/api/v1/storage', storageRouter);
|
||||
app.use('/api/v1/audit', auditRouter);
|
||||
|
||||
// ── Error handler ─────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
|
@ -184,6 +186,15 @@ await runMigrations();
|
|||
// Load S3 config from DB so any settings saved via the Settings page override env vars
|
||||
await loadS3ConfigFromDb();
|
||||
|
||||
// First-run / break-glass admin bootstrap. Reads ADMIN_BOOTSTRAP_USER
|
||||
// + ADMIN_BOOTSTRAP_PASSWORD; only acts when the users table is empty
|
||||
// (creates first admin) or when ADMIN_BOOTSTRAP_RESET=true (resets the
|
||||
// named user back to admin + new password). Always safe to leave the
|
||||
// env vars set — bootstrap exits early when there's nothing to do.
|
||||
import('./tasks/bootstrapAdmin.js').then(m => m.bootstrapAdmin()).catch(err => {
|
||||
console.error('[bootstrap] admin bootstrap failed:', err);
|
||||
});
|
||||
|
||||
// ── Cluster self-heartbeat ────────────────────────────────────────────────────
|
||||
function getLocalIp() {
|
||||
// Prefer an explicit override — useful when running inside Docker where
|
||||
|
|
|
|||
69
services/mam-api/src/middleware/audit.js
Normal file
69
services/mam-api/src/middleware/audit.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Append-only audit log helper.
|
||||
//
|
||||
// Every auth-relevant operation calls audit() with a short event_type and
|
||||
// any metadata that helps an admin reconstruct the event later. Writes are
|
||||
// fire-and-forget: a failed audit insert must never block the user's
|
||||
// request, so the caller doesn't `await` the result.
|
||||
|
||||
import pool from '../db/pool.js';
|
||||
|
||||
const TRUNCATE_LABEL = 120;
|
||||
|
||||
/**
|
||||
* audit(req, eventType, { targetType, targetId, meta }) → Promise<void>
|
||||
*
|
||||
* - `req` : Express request (used to pull actor + IP + user-agent).
|
||||
* - `eventType` : short dot-separated string. Recommended vocabulary:
|
||||
* auth.login.success auth.login.fail
|
||||
* auth.logout auth.password.change
|
||||
* auth.bootstrap auth.lockout
|
||||
* user.create user.update
|
||||
* user.delete user.role.change
|
||||
* user.client.toggle user.password.reset
|
||||
* token.create token.revoke
|
||||
*
|
||||
* Anything else is allowed — we keep the vocabulary loose so adding new
|
||||
* events doesn't require a code change here.
|
||||
*/
|
||||
export async function audit(req, eventType, opts = {}) {
|
||||
const {
|
||||
targetType = null,
|
||||
targetId = null,
|
||||
meta = {},
|
||||
actorIdOverride = undefined, // for bootstrap / system events
|
||||
actorLabelOverride = undefined,
|
||||
} = opts;
|
||||
|
||||
try {
|
||||
const actorId = actorIdOverride !== undefined
|
||||
? actorIdOverride
|
||||
: (req?.user?.id || req?.session?.userId || null);
|
||||
const actorLabel = actorLabelOverride !== undefined
|
||||
? actorLabelOverride
|
||||
: (req?.user?.username || req?.session?.username || meta?.username || null);
|
||||
|
||||
const ip = req?.ip || req?.socket?.remoteAddress || null;
|
||||
const ua = req?.headers?.['user-agent'] || null;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO audit_log
|
||||
(event_type, actor_id, actor_label, target_type, target_id, ip_address, user_agent, meta)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
|
||||
[
|
||||
eventType,
|
||||
actorId,
|
||||
actorLabel ? String(actorLabel).slice(0, TRUNCATE_LABEL) : null,
|
||||
targetType,
|
||||
targetId ? String(targetId).slice(0, TRUNCATE_LABEL) : null,
|
||||
ip,
|
||||
ua ? ua.slice(0, 500) : null,
|
||||
JSON.stringify(meta || {}),
|
||||
]
|
||||
);
|
||||
} catch (err) {
|
||||
// Never throw — auditing must not break the request that triggered it.
|
||||
console.warn('[audit] insert failed:', err.message, { eventType });
|
||||
}
|
||||
}
|
||||
|
||||
export default audit;
|
||||
|
|
@ -4,45 +4,68 @@
|
|||
* When AUTH_ENABLED=true in the environment, every protected route requires
|
||||
* either:
|
||||
* - An active session (set by POST /api/v1/auth/login), or
|
||||
* - A valid Bearer token in Authorization header (set by POST /api/v1/tokens)
|
||||
* - A valid Bearer token in the Authorization header.
|
||||
*
|
||||
* When AUTH_ENABLED is unset or any other value, all middleware is a no-op so
|
||||
* the stack can be run without user accounts during development.
|
||||
* When AUTH_ENABLED is unset or any other value, the middleware is a no-op
|
||||
* (with a synthetic admin user attached) so the stack can run unprotected.
|
||||
*
|
||||
* Exposed on `req` after success:
|
||||
* req.user { id, username, role, is_client }
|
||||
* req.tokenBoundHostname hostname binding from api_tokens, or null
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import pool from '../db/pool.js';
|
||||
|
||||
export const requireAuth = async (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
||||
const SYNTHETIC_USER = Object.freeze({
|
||||
id: null,
|
||||
username: 'operator',
|
||||
role: 'admin',
|
||||
is_client: false,
|
||||
synthetic: true,
|
||||
});
|
||||
|
||||
// ── Session-based auth ────────────────────────────────────────
|
||||
export const requireAuth = async (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') {
|
||||
req.user = SYNTHETIC_USER;
|
||||
return next();
|
||||
}
|
||||
|
||||
// ── Session-based auth ─────────────────────────────────────────
|
||||
if (req.session?.userId) {
|
||||
req.user = {
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
role: req.session.role,
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
role: req.session.role,
|
||||
is_client: !!req.session.isClient,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// ── Bearer token auth ─────────────────────────────────────────
|
||||
// ── Bearer token auth ──────────────────────────────────────────
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const raw = authHeader.slice(7).trim();
|
||||
const hash = crypto.createHash('sha256').update(raw).digest('hex');
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.user_id AS id, u.username, u.role, t.bound_hostname
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = $1
|
||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
|
||||
`SELECT t.user_id AS id,
|
||||
u.username, u.role, u.is_client,
|
||||
t.bound_hostname
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = $1
|
||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
|
||||
[hash]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
req.user = rows[0];
|
||||
req.tokenBoundHostname = rows[0].bound_hostname || null;
|
||||
// Fire-and-forget last_used_at update
|
||||
const row = rows[0];
|
||||
req.user = {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role,
|
||||
is_client: !!row.is_client,
|
||||
};
|
||||
req.tokenBoundHostname = row.bound_hostname || null;
|
||||
pool.query(
|
||||
'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1',
|
||||
[hash]
|
||||
|
|
@ -62,3 +85,25 @@ export const requireAdmin = (req, res, next) => {
|
|||
if (req.user?.role === 'admin') return next();
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Role gate: `requireRole(['admin','editor'])` rejects with 403 unless the
|
||||
* user's role is in the allow list. Always passes when AUTH_ENABLED=false.
|
||||
* Optionally pass { rejectClients: true } to also block users with
|
||||
* is_client=true regardless of role — used for routes that touch the
|
||||
* recorder / cluster / infra surface.
|
||||
*/
|
||||
export const requireRole = (allowed, opts = {}) => {
|
||||
const set = new Set(Array.isArray(allowed) ? allowed : [allowed]);
|
||||
const { rejectClients = false } = opts;
|
||||
return (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
||||
if (!req.user || !set.has(req.user.role)) {
|
||||
return res.status(403).json({ error: `Requires role: ${[...set].join(' or ')}` });
|
||||
}
|
||||
if (rejectClients && req.user.is_client) {
|
||||
return res.status(403).json({ error: 'This action is not available to client accounts' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
|
|
|||
35
services/mam-api/src/middleware/passwordPolicy.js
Normal file
35
services/mam-api/src/middleware/passwordPolicy.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Password policy — locked-in scope:
|
||||
// - At least 8 characters
|
||||
// - At least one lowercase, one uppercase, one digit, one symbol
|
||||
// - Cannot be in the small built-in blocklist of obvious passwords
|
||||
// - Cannot equal username (case-insensitive)
|
||||
//
|
||||
// Returns null when the password is acceptable, or a short human-readable
|
||||
// reason when it isn't. Callers surface the reason as the 400 body.
|
||||
|
||||
const BLOCKLIST = new Set([
|
||||
'password', 'password1', 'password!', 'passw0rd',
|
||||
'qwerty123', 'qwertyuiop', '12345678', '123456789', '1234567890',
|
||||
'letmein!', 'welcome1', 'welcome!', 'admin1234', 'admin1!', 'admin@123',
|
||||
'dragonflight', 'wilddragon', 'broadcast1',
|
||||
].map(s => s.toLowerCase()));
|
||||
|
||||
export function checkPassword(pw, { username } = {}) {
|
||||
if (typeof pw !== 'string') return 'Password is required';
|
||||
if (pw.length < 8) return 'Password must be at least 8 characters';
|
||||
if (pw.length > 200) return 'Password must be 200 characters or fewer';
|
||||
|
||||
if (!/[a-z]/.test(pw)) return 'Password must contain a lowercase letter';
|
||||
if (!/[A-Z]/.test(pw)) return 'Password must contain an uppercase letter';
|
||||
if (!/[0-9]/.test(pw)) return 'Password must contain a digit';
|
||||
if (!/[^A-Za-z0-9]/.test(pw)) return 'Password must contain a symbol (e.g. ! @ # $ %)';
|
||||
|
||||
if (BLOCKLIST.has(pw.toLowerCase())) return 'Password is too common';
|
||||
if (username && pw.toLowerCase() === String(username).toLowerCase()) {
|
||||
return 'Password cannot match the username';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default checkPassword;
|
||||
78
services/mam-api/src/routes/audit.js
Normal file
78
services/mam-api/src/routes/audit.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Read-only audit log endpoints. Admin-only.
|
||||
//
|
||||
// GET /api/v1/audit paginated list, newest first
|
||||
// GET /api/v1/audit/event-types distinct event types we've seen
|
||||
//
|
||||
// Filters supported on GET /:
|
||||
// event_type exact match (e.g. "auth.login.fail")
|
||||
// actor_id UUID of the actor
|
||||
// target_id string (UUID or other)
|
||||
// from / to ISO timestamps
|
||||
// limit 1..200 (default 50)
|
||||
// offset pagination
|
||||
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.max(1, Math.min(200, parseInt(req.query.limit, 10) || 50));
|
||||
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
||||
|
||||
const where = [];
|
||||
const vals = [];
|
||||
|
||||
if (req.query.event_type) {
|
||||
vals.push(String(req.query.event_type));
|
||||
where.push(`event_type = $${vals.length}`);
|
||||
}
|
||||
if (req.query.actor_id) {
|
||||
vals.push(String(req.query.actor_id));
|
||||
where.push(`actor_id = $${vals.length}::uuid`);
|
||||
}
|
||||
if (req.query.target_id) {
|
||||
vals.push(String(req.query.target_id));
|
||||
where.push(`target_id = $${vals.length}`);
|
||||
}
|
||||
if (req.query.from) {
|
||||
vals.push(req.query.from);
|
||||
where.push(`created_at >= $${vals.length}::timestamptz`);
|
||||
}
|
||||
if (req.query.to) {
|
||||
vals.push(req.query.to);
|
||||
where.push(`created_at <= $${vals.length}::timestamptz`);
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
vals.push(limit, offset);
|
||||
const r = await pool.query(
|
||||
`SELECT id, created_at, event_type, actor_id, actor_label,
|
||||
target_type, target_id, ip_address, user_agent, meta
|
||||
FROM audit_log
|
||||
${whereSql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${vals.length - 1} OFFSET $${vals.length}`,
|
||||
vals
|
||||
);
|
||||
const total = await pool.query(`SELECT COUNT(*)::int AS n FROM audit_log ${whereSql}`, vals.slice(0, -2));
|
||||
res.json({ items: r.rows, total: total.rows[0].n, limit, offset });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get('/event-types', async (_req, res, next) => {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`SELECT event_type, COUNT(*)::int AS n
|
||||
FROM audit_log
|
||||
GROUP BY event_type
|
||||
ORDER BY n DESC, event_type ASC`
|
||||
);
|
||||
res.json(r.rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,37 +1,39 @@
|
|||
/**
|
||||
* Authentication routes
|
||||
*
|
||||
* POST /api/v1/auth/login — exchange username+password for a session cookie
|
||||
* POST /api/v1/auth/logout — destroy the current session
|
||||
* GET /api/v1/auth/me — return the currently authenticated user
|
||||
* POST /api/v1/auth/setup — one-time admin bootstrap (disabled after first user exists)
|
||||
* POST /api/v1/auth/login exchange username+password for a session
|
||||
* POST /api/v1/auth/logout destroy the current session
|
||||
* GET /api/v1/auth/me return the currently authenticated user
|
||||
* GET /api/v1/auth/setup-status tell login.html whether to show setup
|
||||
* POST /api/v1/auth/setup one-time first-admin bootstrap from UI
|
||||
* POST /api/v1/auth/password change current user's password
|
||||
* PATCH /api/v1/auth/me update current user's display_name
|
||||
*
|
||||
* Sessions are stored in PG via connect-pg-simple (see index.js). Bearer
|
||||
* tokens go through middleware/auth.js, not here.
|
||||
*/
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import pool from '../db/pool.js';
|
||||
import bcrypt from 'bcrypt';
|
||||
import pool from '../db/pool.js';
|
||||
import audit from '../middleware/audit.js';
|
||||
import { checkPassword } from '../middleware/passwordPolicy.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BUG FIX #6: In-memory login rate limiter.
|
||||
// In-memory login rate limiter.
|
||||
//
|
||||
// Brute-force protection for POST /login. Tracks failed attempts per
|
||||
// (IP, username) pair; after MAX_ATTEMPTS failures within WINDOW_MS the
|
||||
// endpoint returns 429 for LOCKOUT_MS regardless of the password supplied.
|
||||
//
|
||||
// This is intentionally simple — no Redis dependency, no persistent state
|
||||
// across restarts. For a production deployment behind a load balancer, use
|
||||
// express-rate-limit with a Redis store or a dedicated WAF rule instead.
|
||||
// Tracks failed attempts per (IP, username). After MAX_ATTEMPTS failures
|
||||
// within WINDOW_MS the endpoint returns 429 for LOCKOUT_MS regardless of
|
||||
// the password supplied. Simple by design — no Redis dependency, single
|
||||
// replica deploy. For multi-replica add an external store later.
|
||||
// ---------------------------------------------------------------------------
|
||||
const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS || '10', 10);
|
||||
const WINDOW_MS = parseInt(process.env.LOGIN_WINDOW_MS || String(15 * 60 * 1000), 10); // 15 min
|
||||
const LOCKOUT_MS = parseInt(process.env.LOGIN_LOCKOUT_MS || String(15 * 60 * 1000), 10); // 15 min
|
||||
const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS || '10', 10);
|
||||
const WINDOW_MS = parseInt(process.env.LOGIN_WINDOW_MS || String(15 * 60 * 1000), 10);
|
||||
const LOCKOUT_MS = parseInt(process.env.LOGIN_LOCKOUT_MS || String(15 * 60 * 1000), 10);
|
||||
|
||||
// Map key → { attempts: number, lockedUntil: number | null, firstAttempt: number }
|
||||
const loginAttempts = new Map();
|
||||
|
||||
// Housekeeping: prune expired entries every 10 min so the Map doesn't grow
|
||||
// unboundedly on high-traffic or attack traffic.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of loginAttempts.entries()) {
|
||||
|
|
@ -42,110 +44,104 @@ setInterval(() => {
|
|||
}
|
||||
}, 10 * 60 * 1000).unref();
|
||||
|
||||
function getAttemptKey(req, username) {
|
||||
// Use the real client IP (trust proxy headers set by nginx/load-balancer)
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return `${ip}:${(username || '').trim().toLowerCase()}`;
|
||||
function attemptKey(req, username) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
return `${ip}:${String(username || '').trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function checkRateLimit(req, username) {
|
||||
const key = getAttemptKey(req, username);
|
||||
const now = Date.now();
|
||||
const key = attemptKey(req, username);
|
||||
const entry = loginAttempts.get(key);
|
||||
|
||||
if (entry) {
|
||||
// Still locked out?
|
||||
if (entry.lockedUntil && now < entry.lockedUntil) {
|
||||
const retryAfterSec = Math.ceil((entry.lockedUntil - now) / 1000);
|
||||
return { limited: true, retryAfterSec };
|
||||
}
|
||||
// Window expired — reset
|
||||
if (now - entry.firstAttempt > WINDOW_MS) {
|
||||
loginAttempts.delete(key);
|
||||
}
|
||||
const now = Date.now();
|
||||
if (!entry) return { limited: false };
|
||||
if (entry.lockedUntil && now < entry.lockedUntil) {
|
||||
return { limited: true, retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) };
|
||||
}
|
||||
if (now - entry.firstAttempt > WINDOW_MS) {
|
||||
loginAttempts.delete(key);
|
||||
}
|
||||
return { limited: false };
|
||||
}
|
||||
|
||||
function recordFailedAttempt(req, username) {
|
||||
const key = getAttemptKey(req, username);
|
||||
function recordFail(req, username) {
|
||||
const key = attemptKey(req, username);
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(key) || { attempts: 0, lockedUntil: null, firstAttempt: now };
|
||||
|
||||
// Don't update firstAttempt if there's an existing entry within the window
|
||||
const entry = loginAttempts.get(key) || { attempts: 0, firstAttempt: now, lockedUntil: null };
|
||||
entry.attempts += 1;
|
||||
if (entry.attempts >= MAX_ATTEMPTS) {
|
||||
entry.lockedUntil = now + LOCKOUT_MS;
|
||||
}
|
||||
if (entry.attempts >= MAX_ATTEMPTS) entry.lockedUntil = now + LOCKOUT_MS;
|
||||
loginAttempts.set(key, entry);
|
||||
return entry.attempts;
|
||||
}
|
||||
|
||||
function clearAttempts(req, username) {
|
||||
loginAttempts.delete(getAttemptKey(req, username));
|
||||
loginAttempts.delete(attemptKey(req, username));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /login
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/login', async (req, res, next) => {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const rate = checkRateLimit(req, username);
|
||||
if (rate.limited) {
|
||||
res.set('Retry-After', String(rate.retryAfter));
|
||||
audit(req, 'auth.lockout', { meta: { username, retry_after_sec: rate.retryAfter } });
|
||||
return res.status(429).json({
|
||||
error: `Too many failed attempts. Try again in ${rate.retryAfter} seconds.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
// BUG FIX #6: Check rate limit before hitting the DB or bcrypt.
|
||||
const rateCheck = checkRateLimit(req, username);
|
||||
if (rateCheck.limited) {
|
||||
res.set('Retry-After', String(rateCheck.retryAfterSec));
|
||||
return res.status(429).json({
|
||||
error: `Too many failed login attempts. Try again in ${rateCheck.retryAfterSec} seconds.`,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1',
|
||||
[username.trim().toLowerCase()]
|
||||
'SELECT id, username, password_hash, display_name, role, is_client FROM users WHERE username = $1',
|
||||
[String(username).trim().toLowerCase()]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Timing-safe: still run compare on a dummy hash so response time is constant
|
||||
await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
|
||||
recordFailedAttempt(req, username);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!valid) {
|
||||
recordFailedAttempt(req, username);
|
||||
// Constant-time path: even on missing user, run bcrypt against a dummy
|
||||
// hash so attackers can't enumerate usernames by response time.
|
||||
if (!user) {
|
||||
await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
|
||||
recordFail(req, username);
|
||||
audit(req, 'auth.login.fail', { meta: { username, reason: 'no_such_user' } });
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Successful login — clear any accumulated failed attempts
|
||||
clearAttempts(req, username);
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
const n = recordFail(req, username);
|
||||
// Mirror counter to DB so admins can see hammered accounts. Best-effort.
|
||||
pool.query('UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = $1', [user.id]).catch(() => {});
|
||||
audit(req, 'auth.login.fail', { targetType: 'user', targetId: user.id, meta: { username, attempt: n } });
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Regenerate session ID to prevent fixation attacks, then let
|
||||
// express-session write Set-Cookie on its own res.end() hook.
|
||||
// Do NOT call session.save() manually — that writes the store but
|
||||
// bypasses the middleware's header-writing path, which is why the
|
||||
// Set-Cookie header was missing even though the session row existed.
|
||||
clearAttempts(req, username);
|
||||
pool.query(
|
||||
'UPDATE users SET failed_attempts = 0, last_login_at = NOW() WHERE id = $1',
|
||||
[user.id]
|
||||
).catch(() => {});
|
||||
|
||||
// Regenerate to prevent fixation. Let express-session handle Set-Cookie
|
||||
// and store-write on res.end — DO NOT call session.save() manually.
|
||||
req.session.regenerate((err) => {
|
||||
if (err) {
|
||||
console.error('[auth] session.regenerate failed:', err);
|
||||
return next(err);
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.role = user.role;
|
||||
console.log(`[auth] login ok user=${user.username} sid=${req.sessionID?.slice(0,8) || '?'} secure=${req.secure} proto=${req.protocol}`);
|
||||
if (err) return next(err);
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.role = user.role;
|
||||
req.session.isClient = !!user.is_client;
|
||||
audit(req, 'auth.login.success', { targetType: 'user', targetId: user.id, meta: { username: user.username } });
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
is_client: !!user.is_client,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -157,9 +153,12 @@ router.post('/login', async (req, res, next) => {
|
|||
// POST /logout
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/logout', (req, res, next) => {
|
||||
const userId = req.session?.userId;
|
||||
const username = req.session?.username;
|
||||
req.session.destroy((err) => {
|
||||
if (err) return next(err);
|
||||
res.clearCookie('connect.sid');
|
||||
res.clearCookie('df.sid');
|
||||
if (userId) audit(req, 'auth.logout', { targetType: 'user', targetId: userId, meta: { username } });
|
||||
res.json({ message: 'Logged out' });
|
||||
});
|
||||
});
|
||||
|
|
@ -168,98 +167,159 @@ router.post('/logout', (req, res, next) => {
|
|||
// GET /me
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/me', async (req, res) => {
|
||||
// When auth is disabled return a synthetic user so the frontend auth-guard
|
||||
// never receives a 401. Prefer LOCAL_OPERATOR (explicit) or the OS user
|
||||
// running the server over a generic "Admin" — that label is misleading
|
||||
// because it implies an actual admin account is signed in.
|
||||
// Auth off → synthetic admin so the app loads in dev / unprotected setups.
|
||||
if (process.env.AUTH_ENABLED !== 'true') {
|
||||
const osUser = process.env.LOCAL_OPERATOR
|
||||
|| process.env.USER
|
||||
|| process.env.USERNAME
|
||||
|| 'operator';
|
||||
const osUser = process.env.LOCAL_OPERATOR || process.env.USER || process.env.USERNAME || 'operator';
|
||||
return res.json({
|
||||
id: null,
|
||||
username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''),
|
||||
username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''),
|
||||
display_name: osUser,
|
||||
role: 'admin',
|
||||
synthetic: true,
|
||||
role: 'admin',
|
||||
is_client: false,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.userId) {
|
||||
if (!req.session?.userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, display_name, role FROM users WHERE id = $1',
|
||||
const r = await pool.query(
|
||||
'SELECT id, username, display_name, role, is_client, last_login_at FROM users WHERE id = $1',
|
||||
[req.session.userId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
if (r.rows.length === 0) {
|
||||
// Session points at a user that no longer exists — drop the session.
|
||||
req.session.destroy(() => {});
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) {
|
||||
// Fallback to session data if DB unreachable
|
||||
// DB hiccup — fall back to session data so the UI doesn't blank out.
|
||||
res.json({
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
role: req.session.role,
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
role: req.session.role,
|
||||
is_client: !!req.session.isClient,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /setup-status — does ANY user exist? login.html flips into setup mode
|
||||
// automatically when this returns { needs_setup: true }, instead of forcing
|
||||
// the operator to click "Create admin account".
|
||||
// GET /setup-status — front-end hint for login.html
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/setup-status', async (req, res, next) => {
|
||||
try {
|
||||
const count = await pool.query('SELECT COUNT(*) FROM users');
|
||||
const n = parseInt(count.rows[0].count, 10);
|
||||
const r = await pool.query('SELECT COUNT(*)::int AS n FROM users');
|
||||
const n = r.rows[0].n;
|
||||
res.json({
|
||||
needs_setup: n === 0,
|
||||
user_count: n,
|
||||
auth_enabled: process.env.AUTH_ENABLED === 'true',
|
||||
needs_setup: n === 0,
|
||||
user_count: n,
|
||||
auth_enabled: process.env.AUTH_ENABLED === 'true',
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /setup — one-time first-admin bootstrap
|
||||
// POST /setup — UI-driven first-admin bootstrap. Disabled once any user exists.
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/setup', async (req, res, next) => {
|
||||
try {
|
||||
const { username, password, display_name } = req.body;
|
||||
|
||||
const { username, password, display_name } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
}
|
||||
const policyErr = checkPassword(password, { username });
|
||||
if (policyErr) return res.status(400).json({ error: policyErr });
|
||||
|
||||
// Block if any user already exists
|
||||
const count = await pool.query('SELECT COUNT(*) FROM users');
|
||||
if (parseInt(count.rows[0].count, 10) > 0) {
|
||||
const count = await pool.query('SELECT COUNT(*)::int AS n FROM users');
|
||||
if (count.rows[0].n > 0) {
|
||||
return res.status(403).json({
|
||||
error: 'Setup is already complete. Use an existing admin account to add more users.',
|
||||
error: 'Setup is already complete. Use an existing admin to add more users.',
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role)
|
||||
VALUES ($1, $2, $3, 'admin')
|
||||
RETURNING id, username, display_name, role`,
|
||||
[username.trim().toLowerCase(), hash, display_name || username]
|
||||
const r = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role, is_client)
|
||||
VALUES ($1, $2, $3, 'admin', FALSE)
|
||||
RETURNING id, username, display_name, role, is_client`,
|
||||
[String(username).trim().toLowerCase(), hash, display_name || username]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
const newUser = r.rows[0];
|
||||
audit(req, 'auth.setup', { targetType: 'user', targetId: newUser.id, meta: { username: newUser.username } });
|
||||
res.status(201).json(newUser);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /password — self-service password change. Requires current password.
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/password', async (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true' || !req.session?.userId) {
|
||||
return res.status(401).json({ error: 'Sign in required' });
|
||||
}
|
||||
const { current_password, new_password } = req.body || {};
|
||||
if (!current_password || !new_password) {
|
||||
return res.status(400).json({ error: 'current_password and new_password are required' });
|
||||
}
|
||||
try {
|
||||
const r = await pool.query(
|
||||
'SELECT id, username, password_hash FROM users WHERE id = $1',
|
||||
[req.session.userId]
|
||||
);
|
||||
const u = r.rows[0];
|
||||
if (!u) return res.status(401).json({ error: 'Session user not found' });
|
||||
|
||||
const ok = await bcrypt.compare(current_password, u.password_hash);
|
||||
if (!ok) {
|
||||
audit(req, 'auth.password.change', { targetType: 'user', targetId: u.id, meta: { ok: false, reason: 'wrong_current' } });
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
const policyErr = checkPassword(new_password, { username: u.username });
|
||||
if (policyErr) return res.status(400).json({ error: policyErr });
|
||||
|
||||
if (current_password === new_password) {
|
||||
return res.status(400).json({ error: 'New password must differ from current' });
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(new_password, 12);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
|
||||
[newHash, u.id]
|
||||
);
|
||||
audit(req, 'auth.password.change', { targetType: 'user', targetId: u.id, meta: { ok: true } });
|
||||
res.json({ message: 'Password updated' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /me — self-service display_name change.
|
||||
// ---------------------------------------------------------------------------
|
||||
router.patch('/me', async (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true' || !req.session?.userId) {
|
||||
return res.status(401).json({ error: 'Sign in required' });
|
||||
}
|
||||
const { display_name } = req.body || {};
|
||||
if (typeof display_name !== 'string' || !display_name.trim()) {
|
||||
return res.status(400).json({ error: 'display_name is required' });
|
||||
}
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`UPDATE users
|
||||
SET display_name = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING id, username, display_name, role, is_client`,
|
||||
[display_name.trim().slice(0, 120), req.session.userId]
|
||||
);
|
||||
if (r.rows.length === 0) return res.status(404).json({ error: 'User not found' });
|
||||
audit(req, 'auth.profile.update', { targetType: 'user', targetId: r.rows[0].id, meta: { display_name } });
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import express from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { requireAuth, requireRole } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.use(requireRole(['admin', 'editor'], { rejectClients: true }));
|
||||
|
||||
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import express from 'express';
|
||||
import http from 'http';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { requireAuth, requireRole } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
router.use(requireRole(['admin', 'editor'], { rejectClients: true }));
|
||||
|
||||
// If the agent reported Docker's default bridge IP (172.17.x) but the request
|
||||
// itself came from a real LAN address, prefer the request source IP instead.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// Base port for on-demand SDI sidecar containers on remote worker nodes.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { validateUuid } from '../middleware/errors.js';
|
|||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ import multer from 'multer';
|
|||
import { promises as fs, createWriteStream } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { requireAuth, requireRole } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
router.use(requireRole(['admin', 'editor'], { rejectClients: true }));
|
||||
|
||||
const SDK_ROOT = process.env.SDK_ROOT || '/sdk';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,172 +1,211 @@
|
|||
/**
|
||||
* User management routes (admin-only when AUTH_ENABLED=true)
|
||||
* User management routes — admin only.
|
||||
*
|
||||
* GET /api/v1/users — list all users
|
||||
* POST /api/v1/users — create user
|
||||
* GET /api/v1/users/:id — get user
|
||||
* PATCH /api/v1/users/:id — update user (display_name, role, password)
|
||||
* DELETE /api/v1/users/:id — delete user
|
||||
* GET /api/v1/users list users
|
||||
* POST /api/v1/users create user
|
||||
* GET /api/v1/users/:id get one user
|
||||
* PATCH /api/v1/users/:id update display_name / role / is_client / password
|
||||
* DELETE /api/v1/users/:id delete user
|
||||
*
|
||||
* Guardrails:
|
||||
* - Last admin can never be deleted, demoted, or flagged is_client.
|
||||
* - Admins cannot demote / delete themselves (gentler error than the
|
||||
* "last admin" guard — protects against accidental click).
|
||||
* - Password creates/changes run through the shared policy.
|
||||
* - Password change invalidates all active sessions for that user
|
||||
* (sessions table DELETE WHERE sess->>'userId' = id).
|
||||
*/
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import audit from '../middleware/audit.js';
|
||||
import { checkPassword } from '../middleware/passwordPolicy.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
const VALID_ROLES = ['admin', 'editor', 'viewer'];
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
const SELECT_FIELDS = `
|
||||
u.id, u.username, u.display_name, u.role, u.is_client,
|
||||
u.created_at, u.updated_at, u.last_login_at, u.failed_attempts
|
||||
`;
|
||||
|
||||
async function adminCount() {
|
||||
const r = await pool.query("SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'");
|
||||
return r.rows[0].n;
|
||||
}
|
||||
|
||||
// ── List ────────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.role, u.created_at,
|
||||
COUNT(ug.group_id)::int AS group_count
|
||||
FROM users u
|
||||
LEFT JOIN user_groups ug ON ug.user_id = u.id
|
||||
const { rows } = await pool.query(`
|
||||
SELECT ${SELECT_FIELDS},
|
||||
COUNT(ug.group_id)::int AS group_count
|
||||
FROM users u
|
||||
LEFT JOIN user_groups ug ON ug.user_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at`
|
||||
);
|
||||
ORDER BY u.created_at
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────
|
||||
// ── Create ──────────────────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { username, password, display_name, role = 'editor' } = req.body;
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: 'username and password required' });
|
||||
if (password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
|
||||
if (!VALID_ROLES.includes(role))
|
||||
const { username, password, display_name, role = 'editor', is_client = false } = req.body || {};
|
||||
if (!username || !password) return res.status(400).json({ error: 'username and password required' });
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
|
||||
}
|
||||
if (role === 'admin' && is_client) {
|
||||
return res.status(400).json({ error: 'Admins cannot be flagged as clients' });
|
||||
}
|
||||
const policyErr = checkPassword(password, { username });
|
||||
if (policyErr) return res.status(400).json({ error: policyErr });
|
||||
|
||||
const cleanUsername = String(username).trim().toLowerCase();
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, username, display_name, role, created_at`,
|
||||
[username.trim().toLowerCase(), hash, display_name || username, role]
|
||||
|
||||
const r = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role, is_client)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING ${SELECT_FIELDS}`,
|
||||
[cleanUsername, hash, display_name || username, role, !!is_client]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
|
||||
audit(req, 'user.create', { targetType: 'user', targetId: r.rows[0].id, meta: { username: cleanUsername, role, is_client: !!is_client } });
|
||||
res.status(201).json(r.rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Get ───────────────────────────────────────────────────────
|
||||
// ── Get ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, role, created_at FROM users WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(rows[0]);
|
||||
const r = await pool.query(`SELECT ${SELECT_FIELDS} FROM users u WHERE u.id = $1`, [req.params.id]);
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────
|
||||
// ── Update ──────────────────────────────────────────────────────────────────
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { display_name, role, password } = req.body;
|
||||
const { display_name, role, password, is_client } = req.body || {};
|
||||
const id = req.params.id;
|
||||
|
||||
// Read current row so we can enforce guardrails before mutating.
|
||||
const current = await pool.query(
|
||||
'SELECT id, username, role, is_client FROM users WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
if (!current.rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
const target = current.rows[0];
|
||||
const actorId = req.user?.id || req.session?.userId;
|
||||
const isSelf = actorId && actorId === id;
|
||||
|
||||
if (role !== undefined) {
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
|
||||
}
|
||||
if (target.role === 'admin' && role !== 'admin') {
|
||||
const n = await adminCount();
|
||||
if (n <= 1) return res.status(400).json({ error: 'Cannot demote the last admin' });
|
||||
if (isSelf) return res.status(400).json({ error: 'Use another admin to demote yourself' });
|
||||
}
|
||||
}
|
||||
|
||||
if (is_client === true) {
|
||||
const finalRole = role || target.role;
|
||||
if (finalRole === 'admin') {
|
||||
return res.status(400).json({ error: 'Admins cannot be flagged as clients' });
|
||||
}
|
||||
}
|
||||
|
||||
if (password !== undefined) {
|
||||
const policyErr = checkPassword(password, { username: target.username });
|
||||
if (policyErr) return res.status(400).json({ error: policyErr });
|
||||
}
|
||||
|
||||
// Build the dynamic UPDATE
|
||||
const sets = []; const vals = [];
|
||||
let passwordChanged = false;
|
||||
const auditMeta = { username: target.username };
|
||||
|
||||
if (display_name !== undefined) {
|
||||
sets.push(`display_name = $${sets.length + 1}`);
|
||||
vals.push(display_name);
|
||||
sets.push(`display_name = $${vals.length + 1}`); vals.push(String(display_name).trim().slice(0, 120));
|
||||
auditMeta.display_name = display_name;
|
||||
}
|
||||
if (role !== undefined) {
|
||||
if (!VALID_ROLES.includes(role))
|
||||
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
|
||||
sets.push(`role = $${sets.length + 1}`);
|
||||
vals.push(role);
|
||||
sets.push(`role = $${vals.length + 1}`); vals.push(role);
|
||||
auditMeta.role = role;
|
||||
}
|
||||
if (password) {
|
||||
if (password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
sets.push(`password_hash = $${sets.length + 1}`);
|
||||
vals.push(hashed);
|
||||
if (is_client !== undefined) {
|
||||
sets.push(`is_client = $${vals.length + 1}`); vals.push(!!is_client);
|
||||
auditMeta.is_client = !!is_client;
|
||||
}
|
||||
let passwordChanged = false;
|
||||
if (password !== undefined) {
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
sets.push(`password_hash = $${vals.length + 1}`); vals.push(hash);
|
||||
sets.push(`failed_attempts = 0`);
|
||||
passwordChanged = true;
|
||||
}
|
||||
if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||
|
||||
vals.push(req.params.id);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length}
|
||||
RETURNING id, username, display_name, role, created_at`,
|
||||
sets.push(`updated_at = NOW()`);
|
||||
vals.push(id);
|
||||
|
||||
const r = await pool.query(
|
||||
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING ${SELECT_FIELDS}`,
|
||||
vals
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// BUG FIX #5: Invalidate all active sessions for this user when their
|
||||
// password is changed. Without this, an attacker who has already stolen a
|
||||
// session cookie retains access even after the password is rotated, and a
|
||||
// user who changes their own password doesn't log out other devices.
|
||||
//
|
||||
// Implementation note: express-session stores sessions keyed by session ID,
|
||||
// not by userId. The standard way to invalidate by userId is to query the
|
||||
// session store. We support two common stores:
|
||||
//
|
||||
// 1. connect-pg-simple (Postgres): DELETE FROM sessions WHERE …
|
||||
// 2. connect-redis (Redis): requires iterating keys (expensive).
|
||||
//
|
||||
// We use a best-effort approach: if the session store exposes a `db`
|
||||
// (pg-simple) we DELETE directly. Otherwise we log a warning — operators
|
||||
// should configure a session store that supports this.
|
||||
if (passwordChanged && req.sessionStore) {
|
||||
try {
|
||||
const store = req.sessionStore;
|
||||
if (typeof store.query === 'function') {
|
||||
// connect-pg-simple exposes the pool as store.pool / store.client
|
||||
// The session data is a JSON blob; we match on the userId field.
|
||||
const pgPool = store.pool || store.client;
|
||||
if (pgPool) {
|
||||
await pgPool.query(
|
||||
`DELETE FROM sessions WHERE sess->>'userId' = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
console.log(`[users] Invalidated sessions for user ${req.params.id} after password change`);
|
||||
}
|
||||
} else if (typeof store.client === 'object' && typeof store.client.keys === 'function') {
|
||||
// connect-redis: scan for session keys containing this userId.
|
||||
// This is O(n) over all sessions — acceptable for small deployments.
|
||||
const prefix = store.prefix || 'sess:';
|
||||
const keys = await store.client.keys(`${prefix}*`);
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const raw = await store.client.get(key);
|
||||
if (!raw) continue;
|
||||
const data = JSON.parse(raw);
|
||||
if (String(data.userId) === String(req.params.id)) {
|
||||
await store.client.del(key);
|
||||
}
|
||||
} catch { /* skip malformed sessions */ }
|
||||
}
|
||||
console.log(`[users] Invalidated Redis sessions for user ${req.params.id} after password change`);
|
||||
} else {
|
||||
console.warn('[users] Session store does not support programmatic invalidation — existing sessions for this user remain valid after password change');
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
// Non-fatal: the password is already changed; just log the failure.
|
||||
console.error('[users] Failed to invalidate sessions after password change:', sessionErr.message);
|
||||
}
|
||||
// Password rotation invalidates every session for this user. Session
|
||||
// store is connect-pg-simple, so a direct DELETE on the sessions table
|
||||
// is the fastest path. Best-effort — never block the response.
|
||||
if (passwordChanged) {
|
||||
pool.query(`DELETE FROM sessions WHERE sess->>'userId' = $1`, [id])
|
||||
.then(rs => console.log(`[users] invalidated ${rs.rowCount} session(s) for user ${id} after password change`))
|
||||
.catch(e => console.warn('[users] session invalidation failed:', e.message));
|
||||
}
|
||||
|
||||
res.json(rows[0]);
|
||||
audit(req, passwordChanged ? 'user.password.reset' : 'user.update', {
|
||||
targetType: 'user',
|
||||
targetId: id,
|
||||
meta: auditMeta,
|
||||
});
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
// ── Delete ──────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'User not found' });
|
||||
const id = req.params.id;
|
||||
const actorId = req.user?.id || req.session?.userId;
|
||||
if (actorId && actorId === id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
const target = await pool.query('SELECT id, username, role FROM users WHERE id = $1', [id]);
|
||||
if (!target.rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.rows[0].role === 'admin') {
|
||||
const n = await adminCount();
|
||||
if (n <= 1) return res.status(400).json({ error: 'Cannot delete the last admin' });
|
||||
}
|
||||
|
||||
// Clean up sessions before the row goes away (FK ON DELETE SET NULL on
|
||||
// audit_log keeps history intact).
|
||||
await pool.query(`DELETE FROM sessions WHERE sess->>'userId' = $1`, [id]).catch(() => {});
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [id]);
|
||||
audit(req, 'user.delete', { targetType: 'user', targetId: id, meta: { username: target.rows[0].username, role: target.rows[0].role } });
|
||||
res.json({ message: 'User deleted' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
|
|
|||
93
services/mam-api/src/tasks/bootstrapAdmin.js
vendored
Normal file
93
services/mam-api/src/tasks/bootstrapAdmin.js
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// First-run / break-glass admin bootstrap.
|
||||
//
|
||||
// On startup, if the `users` table has zero rows AND both
|
||||
// ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD
|
||||
// are set in the environment, create the admin account and log it in
|
||||
// the audit trail. If a user with that username already exists, do nothing.
|
||||
//
|
||||
// Set ADMIN_BOOTSTRAP_RESET=true to force a password reset on the named
|
||||
// user even when other users exist — for break-glass recovery from a
|
||||
// container restart.
|
||||
//
|
||||
// Bootstrap deliberately does NOT enforce the password policy from
|
||||
// middleware/passwordPolicy.js: when you're locked out of prod with a
|
||||
// container restart, you want the env-set password to take, not be
|
||||
// rejected for missing a symbol. Operator's responsibility to choose
|
||||
// a strong one.
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import pool from '../db/pool.js';
|
||||
import audit from '../middleware/audit.js';
|
||||
|
||||
export async function bootstrapAdmin() {
|
||||
const username = (process.env.ADMIN_BOOTSTRAP_USER || '').trim().toLowerCase();
|
||||
const password = process.env.ADMIN_BOOTSTRAP_PASSWORD || '';
|
||||
const reset = process.env.ADMIN_BOOTSTRAP_RESET === 'true';
|
||||
const displayName = process.env.ADMIN_BOOTSTRAP_DISPLAY_NAME
|
||||
|| process.env.ADMIN_BOOTSTRAP_USER
|
||||
|| 'Admin';
|
||||
|
||||
if (!username || !password) return; // not configured, skip
|
||||
if (password.length < 8) {
|
||||
console.warn('[bootstrap] ADMIN_BOOTSTRAP_PASSWORD is shorter than 8 characters — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const countRes = await pool.query('SELECT COUNT(*)::int AS n FROM users');
|
||||
const totalUsers = countRes.rows[0].n;
|
||||
|
||||
const existingRes = await pool.query('SELECT id, username, role FROM users WHERE username = $1', [username]);
|
||||
const existing = existingRes.rows[0];
|
||||
|
||||
if (totalUsers > 0 && !existing && !reset) {
|
||||
// Other users exist, named user does not. Don't auto-create — would
|
||||
// surprise the operator. They can create the user via the admin UI.
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
|
||||
if (existing) {
|
||||
if (!reset) {
|
||||
// User already exists and not in reset mode — leave them alone.
|
||||
return;
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE users
|
||||
SET password_hash = $2,
|
||||
role = 'admin',
|
||||
is_client = FALSE,
|
||||
failed_attempts = 0,
|
||||
display_name = COALESCE(NULLIF($3, ''), display_name),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[existing.id, hash, displayName]
|
||||
);
|
||||
console.log(`[bootstrap] reset password for admin "${username}" via ADMIN_BOOTSTRAP_RESET`);
|
||||
audit(null, 'auth.bootstrap', {
|
||||
targetType: 'user',
|
||||
targetId: existing.id,
|
||||
actorIdOverride: null,
|
||||
actorLabelOverride: 'bootstrap',
|
||||
meta: { username, action: 'password_reset' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No users at all — create the first admin.
|
||||
const insertRes = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role, is_client)
|
||||
VALUES ($1, $2, $3, 'admin', FALSE)
|
||||
RETURNING id`,
|
||||
[username, hash, displayName]
|
||||
);
|
||||
const newId = insertRes.rows[0].id;
|
||||
console.log(`[bootstrap] created first admin "${username}" from ADMIN_BOOTSTRAP_USER env`);
|
||||
audit(null, 'auth.bootstrap', {
|
||||
targetType: 'user',
|
||||
targetId: newId,
|
||||
actorIdOverride: null,
|
||||
actorLabelOverride: 'bootstrap',
|
||||
meta: { username, action: 'created' },
|
||||
});
|
||||
}
|
||||
|
|
@ -118,6 +118,14 @@ function App() {
|
|||
// Home (launcher) suppresses the topbar — it's a full-bleed landing page.
|
||||
const hideTopbar = !openAsset && route === 'home';
|
||||
|
||||
// Account-settings modal — opened from sidebar's user button.
|
||||
const [accountOpen, setAccountOpen] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const open = () => setAccountOpen(true);
|
||||
window.addEventListener('df:open-account-settings', open);
|
||||
return () => window.removeEventListener('df:open-account-settings', open);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app" data-density="comfortable" data-grid-size="md" data-sidebar={sidebarCollapsed ? 'collapsed' : 'expanded'}>
|
||||
<Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} me={window.ZAMPP_DATA?.ME} collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
||||
|
|
@ -134,6 +142,9 @@ function App() {
|
|||
{content}
|
||||
</div>
|
||||
{showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}
|
||||
{accountOpen && window.AccountSettingsModal && (
|
||||
<window.AccountSettingsModal onClose={() => setAccountOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,15 +244,17 @@ async function loadData() {
|
|||
const me = meR.value;
|
||||
const label = me.display_name || me.username || 'User';
|
||||
window.ZAMPP_DATA.ME = {
|
||||
id: me.id,
|
||||
username: me.username,
|
||||
name: label,
|
||||
initials: label.slice(0, 2).toUpperCase(),
|
||||
role: me.role || 'viewer',
|
||||
id: me.id,
|
||||
username: me.username,
|
||||
display_name: me.display_name || me.username,
|
||||
name: label,
|
||||
initials: label.slice(0, 2).toUpperCase(),
|
||||
role: me.role || 'viewer',
|
||||
is_client: !!me.is_client,
|
||||
// True when the server returned a synthetic user (AUTH_ENABLED=false).
|
||||
// Surfaced as a small "auth off" hint in the sidebar so the operator
|
||||
// understands why the corner shows the OS user instead of a login.
|
||||
synthetic: !!me.synthetic,
|
||||
synthetic: !!me.synthetic,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
<script src="dist/screens-editor.js"></script>
|
||||
<script src="dist/screens-admin.js"></script>
|
||||
<script src="dist/modal-new-recorder.js"></script>
|
||||
<script src="dist/modal-account-settings.js"></script>
|
||||
<script src="dist/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -182,6 +182,8 @@
|
|||
/* Make the login button full-width (the primitive is inline-flex by default) */
|
||||
.wd-btn--full { width: 100%; margin-top: 10px; }
|
||||
|
||||
.wd-hint { font: 400 11px/1.4 var(--font); color: var(--text-3, #8b92a0); margin-top: 4px; }
|
||||
|
||||
/* Stack form groups vertically with consistent gap */
|
||||
.login-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
</style>
|
||||
|
|
@ -239,8 +241,9 @@
|
|||
<input class="wd-input" id="su-username" name="username" type="text" required placeholder="admin">
|
||||
</div>
|
||||
<div class="wd-form-group">
|
||||
<label class="wd-label" for="su-password">Password (min 8 chars)</label>
|
||||
<label class="wd-label" for="su-password">Password</label>
|
||||
<input class="wd-input" id="su-password" name="password" type="password" autocomplete="new-password" required minlength="8" placeholder="••••••••">
|
||||
<div class="wd-hint">8+ chars, mixed case, digit, symbol</div>
|
||||
</div>
|
||||
<div class="wd-form-group">
|
||||
<label class="wd-label" for="su-display">Display name (optional)</label>
|
||||
|
|
|
|||
149
services/web-ui/public/modal-account-settings.jsx
Normal file
149
services/web-ui/public/modal-account-settings.jsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// Account Settings modal — self-service for the currently-signed-in user.
|
||||
// Change display name + password. Username and role are admin-only.
|
||||
|
||||
function AccountSettingsModal({ onClose }) {
|
||||
const me = window.ZAMPP_DATA?.ME || {};
|
||||
const [tab, setTab] = React.useState('profile');
|
||||
const [displayName, setDisplayName] = React.useState(me.display_name || me.name || '');
|
||||
const [savingName, setSavingName] = React.useState(false);
|
||||
const [nameMsg, setNameMsg] = React.useState(null);
|
||||
|
||||
const [curPw, setCurPw] = React.useState('');
|
||||
const [newPw, setNewPw] = React.useState('');
|
||||
const [newPw2, setNewPw2] = React.useState('');
|
||||
const [savingPw, setSavingPw] = React.useState(false);
|
||||
const [pwMsg, setPwMsg] = React.useState(null);
|
||||
const [showNew, setShowNew] = React.useState(false);
|
||||
|
||||
const saveName = async (e) => {
|
||||
e.preventDefault();
|
||||
setNameMsg(null);
|
||||
if (!displayName.trim()) { setNameMsg({ ok: false, text: 'Display name cannot be empty' }); return; }
|
||||
setSavingName(true);
|
||||
try {
|
||||
const r = await window.ZAMPP_API.fetch('/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ display_name: displayName.trim() }),
|
||||
});
|
||||
// refresh ME cache
|
||||
if (window.ZAMPP_DATA) {
|
||||
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), display_name: r.display_name, name: r.display_name };
|
||||
}
|
||||
setNameMsg({ ok: true, text: 'Display name updated' });
|
||||
} catch (err) {
|
||||
setNameMsg({ ok: false, text: err.message || 'Update failed' });
|
||||
} finally { setSavingName(false); }
|
||||
};
|
||||
|
||||
const savePw = async (e) => {
|
||||
e.preventDefault();
|
||||
setPwMsg(null);
|
||||
if (newPw !== newPw2) { setPwMsg({ ok: false, text: 'New passwords do not match' }); return; }
|
||||
if (newPw.length < 8) { setPwMsg({ ok: false, text: 'Password must be at least 8 characters' }); return; }
|
||||
setSavingPw(true);
|
||||
try {
|
||||
await window.ZAMPP_API.fetch('/auth/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: curPw, new_password: newPw }),
|
||||
});
|
||||
setPwMsg({ ok: true, text: 'Password updated — all other sessions signed out' });
|
||||
setCurPw(''); setNewPw(''); setNewPw2('');
|
||||
} catch (err) {
|
||||
setPwMsg({ ok: false, text: err.message || 'Update failed' });
|
||||
} finally { setSavingPw(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>Account settings</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
|
||||
{me.username || '—'} · {me.role || '—'}{me.is_client ? ' · client' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, padding: '0 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
{[
|
||||
{ id: 'profile', label: 'Profile' },
|
||||
{ id: 'password', label: 'Password' },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="btn ghost sm"
|
||||
onClick={() => setTab(t.id)}
|
||||
style={{
|
||||
background: tab === t.id ? 'var(--accent-soft)' : 'transparent',
|
||||
color: tab === t.id ? 'var(--accent-text)' : 'var(--text-2)',
|
||||
border: 0, borderBottom: '2px solid ' + (tab === t.id ? 'var(--accent)' : 'transparent'),
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
{tab === 'profile' && (
|
||||
<form onSubmit={saveName} autoComplete="off">
|
||||
<div className="field">
|
||||
<label className="field-label">Username</label>
|
||||
<input className="field-input mono" value={me.username || ''} readOnly />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Display name</label>
|
||||
<input className="field-input" value={displayName} onChange={e => setDisplayName(e.target.value)} maxLength={120} />
|
||||
</div>
|
||||
{nameMsg && (
|
||||
<div style={{ fontSize: 12, color: nameMsg.ok ? 'var(--success)' : 'var(--danger)', marginTop: 6 }}>
|
||||
{nameMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
|
||||
<button type="submit" className="btn primary sm" disabled={savingName}>{savingName ? 'Saving…' : 'Save'}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{tab === 'password' && (
|
||||
<form onSubmit={savePw} autoComplete="off">
|
||||
<div className="field">
|
||||
<label className="field-label">Current password</label>
|
||||
<input className="field-input" type="password" value={curPw} onChange={e => setCurPw(e.target.value)} autoComplete="current-password" required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">New password</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input className="field-input" type={showNew ? 'text' : 'password'} value={newPw} onChange={e => setNewPw(e.target.value)} autoComplete="new-password" required minLength={8} style={{ paddingRight: 36 }} />
|
||||
<button type="button" tabIndex={-1} className="icon-btn"
|
||||
aria-label={showNew ? 'Hide password' : 'Show password'}
|
||||
style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
|
||||
onClick={() => setShowNew(s => !s)}>
|
||||
<Icon name={showNew ? 'eye-off' : 'eye'} size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>8+ chars, mixed case, digit, symbol</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Confirm new password</label>
|
||||
<input className="field-input" type="password" value={newPw2} onChange={e => setNewPw2(e.target.value)} autoComplete="new-password" required minLength={8} />
|
||||
</div>
|
||||
{pwMsg && (
|
||||
<div style={{ fontSize: 12, color: pwMsg.ok ? 'var(--success)' : 'var(--danger)', marginTop: 6 }}>
|
||||
{pwMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
|
||||
<button type="submit" className="btn primary sm" disabled={savingPw}>{savingPw ? 'Updating…' : 'Update password'}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.AccountSettingsModal = AccountSettingsModal;
|
||||
|
|
@ -46,14 +46,16 @@ function _normalizeNode(n, x, y) {
|
|||
}
|
||||
|
||||
function InviteUserModal({ onCreated, onClose }) {
|
||||
const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer' });
|
||||
const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer', is_client: false });
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [err, setErr] = React.useState(null);
|
||||
|
||||
const submit = () => {
|
||||
if (!form.username || !form.password) { setErr('Username and password are required'); return; }
|
||||
// Admins cannot be clients — enforce client-side too.
|
||||
const payload = { ...form, is_client: form.role === 'admin' ? false : form.is_client };
|
||||
setSaving(true); setErr(null);
|
||||
window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(form) })
|
||||
window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(payload) })
|
||||
.then(user => { onCreated(user); onClose(); })
|
||||
.catch(e => { setSaving(false); setErr(e.message || 'Failed to create user'); });
|
||||
};
|
||||
|
|
@ -86,17 +88,26 @@ function InviteUserModal({ onCreated, onClose }) {
|
|||
autoComplete="new-password"
|
||||
onChange={e => setForm(p => ({...p, password: e.target.value}))}
|
||||
onKeyDown={onKey} placeholder="Temporary password" />
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>8+ chars, mixed case, digit, symbol</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Role</label>
|
||||
<select className="field-input" value={form.role}
|
||||
onChange={e => setForm(p => ({...p, role: e.target.value}))}
|
||||
onChange={e => setForm(p => ({...p, role: e.target.value, is_client: e.target.value === 'admin' ? false : p.is_client }))}
|
||||
style={{ appearance: 'auto' }}>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, opacity: form.role === 'admin' ? 0.4 : 1 }}>
|
||||
<input type="checkbox" checked={form.is_client} disabled={form.role === 'admin'}
|
||||
onChange={e => setForm(p => ({...p, is_client: e.target.checked}))} />
|
||||
<span style={{ color: 'var(--text-2)' }}>Client account</span>
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 11 }}>· hides recorder / cluster / infra surfaces</span>
|
||||
</label>
|
||||
</div>
|
||||
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
|
|
@ -149,8 +160,8 @@ function Users() {
|
|||
}, [menuFor]);
|
||||
|
||||
const exportCsv = () => {
|
||||
const rows = [['Username', 'Name', 'Role', 'Groups', 'Created']].concat(
|
||||
users.map(u => [u.username || '', u.name || '', u.role || '', u.group_count || 0, u.created_at || ''])
|
||||
const rows = [['Username', 'Name', 'Role', 'Client', 'Groups', 'Last login', 'Created']].concat(
|
||||
users.map(u => [u.username || '', u.name || '', u.role || '', u.is_client ? 'yes' : 'no', u.group_count || 0, u.last_login_at || '', u.created_at || ''])
|
||||
);
|
||||
const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n');
|
||||
const a = document.createElement('a');
|
||||
|
|
@ -173,11 +184,20 @@ function Users() {
|
|||
|
||||
const changeRole = (u, newRole) => {
|
||||
if (u.role === newRole) return;
|
||||
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
|
||||
const body = { role: newRole };
|
||||
if (newRole === 'admin') body.is_client = false;
|
||||
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
.then(refreshUsers)
|
||||
.catch(e => alert('Role change failed: ' + e.message));
|
||||
};
|
||||
|
||||
const toggleClient = (u) => {
|
||||
if (u.role === 'admin') return;
|
||||
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ is_client: !u.is_client }) })
|
||||
.then(refreshUsers)
|
||||
.catch(e => alert('Client toggle failed: ' + e.message));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
|
|
@ -200,8 +220,9 @@ function Users() {
|
|||
<div className="user-row head">
|
||||
<div>User</div>
|
||||
<div>Role</div>
|
||||
<div>Client</div>
|
||||
<div>Groups</div>
|
||||
<div>Created</div>
|
||||
<div>Last login</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{users.length === 0 && (
|
||||
|
|
@ -226,11 +247,19 @@ function Users() {
|
|||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-3)', cursor: u.role === 'admin' ? 'not-allowed' : 'pointer', opacity: u.role === 'admin' ? 0.4 : 1 }}
|
||||
title={u.role === 'admin' ? 'Admins cannot be flagged as clients' : 'External client account'}>
|
||||
<input type="checkbox" checked={!!u.is_client} disabled={u.role === 'admin'} onChange={() => toggleClient(u)} />
|
||||
<span>{u.is_client ? 'yes' : 'no'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
||||
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
||||
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
|
||||
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : (u.created_at ? `created ${new Date(u.created_at).toLocaleDateString()}` : '—')}
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
|
||||
|
|
|
|||
|
|
@ -102,11 +102,32 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
|||
return () => { cancelled = true; clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Apply the live jobs badge to the Jobs nav item.
|
||||
const navTree = React.useMemo(
|
||||
() => NAV_TREE.map(n => n.id === 'jobs' && jobsBadge ? { ...n, badge: jobsBadge } : n),
|
||||
[jobsBadge]
|
||||
);
|
||||
// Apply the live jobs badge to the Jobs nav item, and gate items the
|
||||
// current user shouldn't see. Clients lose the entire ingest tree, jobs,
|
||||
// and the editor. Viewers keep Library + Projects.
|
||||
const isClient = !!me?.is_client;
|
||||
const role = me?.role || 'viewer';
|
||||
const isAdmin = role === 'admin';
|
||||
const isEditor = role === 'editor' || isAdmin;
|
||||
|
||||
const navTree = React.useMemo(() => {
|
||||
const filterChildren = (children) => children.filter(c => {
|
||||
if (isClient && ['recorders', 'capture', 'monitors', 'schedule'].includes(c.id)) return false;
|
||||
return true;
|
||||
});
|
||||
return NAV_TREE
|
||||
.map(n => n.id === 'jobs' && jobsBadge ? { ...n, badge: jobsBadge } : n)
|
||||
.filter(n => {
|
||||
if (isClient && ['ingest', 'jobs', 'editor'].includes(n.id)) return false;
|
||||
if (!isEditor && ['ingest', 'editor'].includes(n.id)) return false;
|
||||
return true;
|
||||
})
|
||||
.map(n => n.children ? { ...n, children: filterChildren(n.children) } : n)
|
||||
.filter(n => !n.children || n.children.length > 0);
|
||||
}, [jobsBadge, isClient, isEditor]);
|
||||
|
||||
// Admin section visible only to admins.
|
||||
const adminTree = React.useMemo(() => isAdmin ? ADMIN_TREE : [], [isAdmin]);
|
||||
const toggleGroup = (id) => {
|
||||
setOpenGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
|
|
@ -157,26 +178,45 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
|||
toggleGroup={toggleGroup}
|
||||
/>
|
||||
))}
|
||||
<div className="nav-section-label">Admin</div>
|
||||
{ADMIN_TREE.map(item => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={active}
|
||||
onSelect={onNavigate}
|
||||
openGroups={openGroups}
|
||||
toggleGroup={toggleGroup}
|
||||
/>
|
||||
))}
|
||||
{adminTree.length > 0 && (
|
||||
<>
|
||||
<div className="nav-section-label">Admin</div>
|
||||
{adminTree.map(item => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={active}
|
||||
onSelect={onNavigate}
|
||||
openGroups={openGroups}
|
||||
toggleGroup={toggleGroup}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-footer">
|
||||
<div className="avatar">{me?.initials || '—'}</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{me?.name || 'Not signed in'}</div>
|
||||
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false — showing the OS user running the server' : ''}>
|
||||
{me?.role || '—'}{me?.synthetic ? ' · auth off' : ''}
|
||||
<button
|
||||
className="sidebar-userbtn"
|
||||
onClick={() => { if (!me?.synthetic) window.dispatchEvent(new CustomEvent('df:open-account-settings')); }}
|
||||
disabled={!!me?.synthetic}
|
||||
title={me?.synthetic ? 'AUTH_ENABLED=false — account settings unavailable' : 'Account settings'}
|
||||
aria-label="Account settings"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0,
|
||||
padding: 0, background: 'none', border: 0, color: 'inherit',
|
||||
cursor: me?.synthetic ? 'default' : 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div className="avatar">{me?.initials || '—'}</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{me?.name || 'Not signed in'}</div>
|
||||
<div className="user-role">
|
||||
{me?.role || '—'}
|
||||
{me?.is_client ? <span className="badge neutral" style={{ marginLeft: 6, fontSize: 9 }}>CLIENT</span> : null}
|
||||
{me?.synthetic ? ' · auth off' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{me?.synthetic ? null : (
|
||||
<button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out"
|
||||
onClick={async () => {
|
||||
|
|
|
|||
|
|
@ -607,7 +607,7 @@
|
|||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
|
||||
.user-row { grid-template-columns: 1.5fr 100px 70px 1.2fr 180px 40px; }
|
||||
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
|
||||
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
|
||||
.schedule-row { grid-template-columns: 1.6fr 1.2fr 1.2fr 90px 110px 110px 150px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue