Revert "auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap"

This reverts commit 002e5acb82.
This commit is contained in:
opencode 2026-05-27 03:28:05 +00:00
parent 002e5acb82
commit 9726dbb2df
23 changed files with 297 additions and 1010 deletions

View file

@ -55,11 +55,6 @@ services:
S3_REGION: ${S3_REGION:-us-east-1} S3_REGION: ${S3_REGION:-us-east-1}
SESSION_SECRET: ${SESSION_SECRET} SESSION_SECRET: ${SESSION_SECRET}
AUTH_ENABLED: ${AUTH_ENABLED:-false} 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 DOCKER_NETWORK: wild-dragon_wild-dragon
NODE_IP: ${NODE_IP} NODE_IP: ${NODE_IP}
NODE_HOSTNAME: ${NODE_HOSTNAME:-} NODE_HOSTNAME: ${NODE_HOSTNAME:-}

View file

@ -1,38 +0,0 @@
-- 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);

View file

@ -32,7 +32,6 @@ import metricsRouter from './routes/metrics.js';
import commentsRouter from './routes/comments.js'; import commentsRouter from './routes/comments.js';
import importsRouter from './routes/imports.js'; import importsRouter from './routes/imports.js';
import storageRouter from './routes/storage.js'; import storageRouter from './routes/storage.js';
import auditRouter from './routes/audit.js';
import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js'; import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js';
import { startCleanupLoop } from './tasks/cleanupTempSegments.js'; import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
@ -119,7 +118,6 @@ app.use('/api/v1/metrics', metricsRouter);
app.use('/api/v1/assets/:assetId/comments', commentsRouter); app.use('/api/v1/assets/:assetId/comments', commentsRouter);
app.use('/api/v1/imports', importsRouter); app.use('/api/v1/imports', importsRouter);
app.use('/api/v1/storage', storageRouter); app.use('/api/v1/storage', storageRouter);
app.use('/api/v1/audit', auditRouter);
// ── Error handler ───────────────────────────────────────────────────────────── // ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler); app.use(errorHandler);
@ -186,15 +184,6 @@ await runMigrations();
// Load S3 config from DB so any settings saved via the Settings page override env vars // Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb(); 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 ──────────────────────────────────────────────────── // ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() { function getLocalIp() {
// Prefer an explicit override — useful when running inside Docker where // Prefer an explicit override — useful when running inside Docker where

View file

@ -1,69 +0,0 @@
// 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;

View file

@ -4,68 +4,45 @@
* When AUTH_ENABLED=true in the environment, every protected route requires * When AUTH_ENABLED=true in the environment, every protected route requires
* either: * either:
* - An active session (set by POST /api/v1/auth/login), or * - An active session (set by POST /api/v1/auth/login), or
* - A valid Bearer token in the Authorization header. * - A valid Bearer token in Authorization header (set by POST /api/v1/tokens)
* *
* When AUTH_ENABLED is unset or any other value, the middleware is a no-op * When AUTH_ENABLED is unset or any other value, all middleware is a no-op so
* (with a synthetic admin user attached) so the stack can run unprotected. * the stack can be run without user accounts during development.
*
* 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 crypto from 'crypto';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
const SYNTHETIC_USER = Object.freeze({
id: null,
username: 'operator',
role: 'admin',
is_client: false,
synthetic: true,
});
export const requireAuth = async (req, res, next) => { export const requireAuth = async (req, res, next) => {
if (process.env.AUTH_ENABLED !== 'true') { if (process.env.AUTH_ENABLED !== 'true') return next();
req.user = SYNTHETIC_USER;
return next();
}
// ── Session-based auth ──────────────────────────────────────── // ── Session-based auth ────────────────────────────────────────
if (req.session?.userId) { if (req.session?.userId) {
req.user = { req.user = {
id: req.session.userId, id: req.session.userId,
username: req.session.username, username: req.session.username,
role: req.session.role, role: req.session.role,
is_client: !!req.session.isClient,
}; };
return next(); return next();
} }
// ── Bearer token auth ───────────────────────────────────────── // ── Bearer token auth ─────────────────────────────────────────
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) { if (authHeader?.startsWith('Bearer ')) {
const raw = authHeader.slice(7).trim(); const raw = authHeader.slice(7).trim();
const hash = crypto.createHash('sha256').update(raw).digest('hex'); const hash = crypto.createHash('sha256').update(raw).digest('hex');
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT t.user_id AS id, `SELECT t.user_id AS id, u.username, u.role, t.bound_hostname
u.username, u.role, u.is_client, FROM api_tokens t
t.bound_hostname JOIN users u ON u.id = t.user_id
FROM api_tokens t WHERE t.token_hash = $1
JOIN users u ON u.id = t.user_id AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
WHERE t.token_hash = $1
AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
[hash] [hash]
); );
if (rows.length > 0) { if (rows.length > 0) {
const row = rows[0]; req.user = rows[0];
req.user = { req.tokenBoundHostname = rows[0].bound_hostname || null;
id: row.id, // Fire-and-forget last_used_at update
username: row.username,
role: row.role,
is_client: !!row.is_client,
};
req.tokenBoundHostname = row.bound_hostname || null;
pool.query( pool.query(
'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1', 'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1',
[hash] [hash]
@ -85,25 +62,3 @@ export const requireAdmin = (req, res, next) => {
if (req.user?.role === 'admin') return next(); if (req.user?.role === 'admin') return next();
return res.status(403).json({ error: 'Admin access required' }); 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();
};
};

View file

@ -1,35 +0,0 @@
// 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;

View file

@ -1,78 +0,0 @@
// 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;

View file

@ -1,39 +1,37 @@
/** /**
* Authentication routes * Authentication routes
* *
* POST /api/v1/auth/login exchange username+password for a session * POST /api/v1/auth/login exchange username+password for a session cookie
* POST /api/v1/auth/logout destroy the current session * POST /api/v1/auth/logout destroy the current session
* GET /api/v1/auth/me return the currently authenticated user * 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 admin bootstrap (disabled after first user exists)
* 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 express from 'express';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import audit from '../middleware/audit.js';
import { checkPassword } from '../middleware/passwordPolicy.js';
const router = express.Router(); const router = express.Router();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// In-memory login rate limiter. // BUG FIX #6: In-memory login rate limiter.
// //
// Tracks failed attempts per (IP, username). After MAX_ATTEMPTS failures // Brute-force protection for POST /login. Tracks failed attempts per
// within WINDOW_MS the endpoint returns 429 for LOCKOUT_MS regardless of // (IP, username) pair; after MAX_ATTEMPTS failures within WINDOW_MS the
// the password supplied. Simple by design — no Redis dependency, single // endpoint returns 429 for LOCKOUT_MS regardless of the password supplied.
// replica deploy. For multi-replica add an external store later. //
// 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.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS || '10', 10); 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 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); const LOCKOUT_MS = parseInt(process.env.LOGIN_LOCKOUT_MS || String(15 * 60 * 1000), 10); // 15 min
// Map key → { attempts: number, lockedUntil: number | null, firstAttempt: number }
const loginAttempts = new Map(); 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(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [key, entry] of loginAttempts.entries()) { for (const [key, entry] of loginAttempts.entries()) {
@ -44,104 +42,110 @@ setInterval(() => {
} }
}, 10 * 60 * 1000).unref(); }, 10 * 60 * 1000).unref();
function attemptKey(req, username) { function getAttemptKey(req, username) {
const ip = req.ip || req.socket?.remoteAddress || 'unknown'; // Use the real client IP (trust proxy headers set by nginx/load-balancer)
return `${ip}:${String(username || '').trim().toLowerCase()}`; const ip = req.ip || req.socket.remoteAddress || 'unknown';
return `${ip}:${(username || '').trim().toLowerCase()}`;
} }
function checkRateLimit(req, username) { function checkRateLimit(req, username) {
const key = attemptKey(req, username); const key = getAttemptKey(req, username);
const entry = loginAttempts.get(key);
const now = Date.now(); const now = Date.now();
if (!entry) return { limited: false }; const entry = loginAttempts.get(key);
if (entry.lockedUntil && now < entry.lockedUntil) {
return { limited: true, retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) }; if (entry) {
} // Still locked out?
if (now - entry.firstAttempt > WINDOW_MS) { if (entry.lockedUntil && now < entry.lockedUntil) {
loginAttempts.delete(key); const retryAfterSec = Math.ceil((entry.lockedUntil - now) / 1000);
return { limited: true, retryAfterSec };
}
// Window expired — reset
if (now - entry.firstAttempt > WINDOW_MS) {
loginAttempts.delete(key);
}
} }
return { limited: false }; return { limited: false };
} }
function recordFail(req, username) { function recordFailedAttempt(req, username) {
const key = attemptKey(req, username); const key = getAttemptKey(req, username);
const now = Date.now(); const now = Date.now();
const entry = loginAttempts.get(key) || { attempts: 0, firstAttempt: now, lockedUntil: null }; const entry = loginAttempts.get(key) || { attempts: 0, lockedUntil: null, firstAttempt: now };
// Don't update firstAttempt if there's an existing entry within the window
entry.attempts += 1; 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); loginAttempts.set(key, entry);
return entry.attempts;
} }
function clearAttempts(req, username) { function clearAttempts(req, username) {
loginAttempts.delete(attemptKey(req, username)); loginAttempts.delete(getAttemptKey(req, username));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /login // POST /login
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
router.post('/login', async (req, res, next) => { 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 { 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( const result = await pool.query(
'SELECT id, username, password_hash, display_name, role, is_client FROM users WHERE username = $1', 'SELECT * FROM users WHERE username = $1',
[String(username).trim().toLowerCase()] [username.trim().toLowerCase()]
); );
const user = result.rows[0]; if (result.rows.length === 0) {
// Timing-safe: still run compare on a dummy hash so response time is constant
// 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'); await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
recordFail(req, username); recordFailedAttempt(req, username);
audit(req, 'auth.login.fail', { meta: { username, reason: 'no_such_user' } });
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: 'Invalid credentials' });
} }
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password_hash); const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) { if (!valid) {
const n = recordFail(req, username); recordFailedAttempt(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' }); return res.status(401).json({ error: 'Invalid credentials' });
} }
// Successful login — clear any accumulated failed attempts
clearAttempts(req, username); 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 // Regenerate session ID to prevent fixation attacks, then let
// and store-write on res.end — DO NOT call session.save() manually. // 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.
req.session.regenerate((err) => { req.session.regenerate((err) => {
if (err) return next(err); if (err) {
req.session.userId = user.id; console.error('[auth] session.regenerate failed:', err);
req.session.username = user.username; return next(err);
req.session.role = user.role; }
req.session.isClient = !!user.is_client; req.session.userId = user.id;
audit(req, 'auth.login.success', { targetType: 'user', targetId: user.id, meta: { username: user.username } }); 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}`);
res.json({ res.json({
id: user.id, id: user.id,
username: user.username, username: user.username,
display_name: user.display_name, display_name: user.display_name,
role: user.role, role: user.role,
is_client: !!user.is_client,
}); });
}); });
} catch (err) { } catch (err) {
@ -153,12 +157,9 @@ router.post('/login', async (req, res, next) => {
// POST /logout // POST /logout
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
router.post('/logout', (req, res, next) => { router.post('/logout', (req, res, next) => {
const userId = req.session?.userId;
const username = req.session?.username;
req.session.destroy((err) => { req.session.destroy((err) => {
if (err) return next(err); if (err) return next(err);
res.clearCookie('df.sid'); res.clearCookie('connect.sid');
if (userId) audit(req, 'auth.logout', { targetType: 'user', targetId: userId, meta: { username } });
res.json({ message: 'Logged out' }); res.json({ message: 'Logged out' });
}); });
}); });
@ -167,159 +168,98 @@ router.post('/logout', (req, res, next) => {
// GET /me // GET /me
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
router.get('/me', async (req, res) => { router.get('/me', async (req, res) => {
// Auth off → synthetic admin so the app loads in dev / unprotected setups. // 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.
if (process.env.AUTH_ENABLED !== 'true') { 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({ return res.json({
id: null, id: null,
username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''), username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''),
display_name: osUser, display_name: osUser,
role: 'admin', role: 'admin',
is_client: false, synthetic: true,
synthetic: true,
}); });
} }
if (!req.session?.userId) { if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' }); return res.status(401).json({ error: 'Not authenticated' });
} }
try { try {
const r = await pool.query( const result = await pool.query(
'SELECT id, username, display_name, role, is_client, last_login_at FROM users WHERE id = $1', 'SELECT id, username, display_name, role FROM users WHERE id = $1',
[req.session.userId] [req.session.userId]
); );
if (r.rows.length === 0) { if (result.rows.length === 0) {
// Session points at a user that no longer exists — drop the session.
req.session.destroy(() => {}); req.session.destroy(() => {});
return res.status(401).json({ error: 'User not found' }); return res.status(401).json({ error: 'User not found' });
} }
res.json(r.rows[0]); res.json(result.rows[0]);
} catch (err) { } catch (err) {
// DB hiccup — fall back to session data so the UI doesn't blank out. // Fallback to session data if DB unreachable
res.json({ res.json({
id: req.session.userId, id: req.session.userId,
username: req.session.username, username: req.session.username,
role: req.session.role, role: req.session.role,
is_client: !!req.session.isClient,
}); });
} }
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /setup-status — front-end hint for login.html // 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".
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
router.get('/setup-status', async (req, res, next) => { router.get('/setup-status', async (req, res, next) => {
try { try {
const r = await pool.query('SELECT COUNT(*)::int AS n FROM users'); const count = await pool.query('SELECT COUNT(*) FROM users');
const n = r.rows[0].n; const n = parseInt(count.rows[0].count, 10);
res.json({ res.json({
needs_setup: n === 0, needs_setup: n === 0,
user_count: n, user_count: n,
auth_enabled: process.env.AUTH_ENABLED === 'true', auth_enabled: process.env.AUTH_ENABLED === 'true',
}); });
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /setup — UI-driven first-admin bootstrap. Disabled once any user exists. // POST /setup — one-time first-admin bootstrap
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
router.post('/setup', async (req, res, next) => { router.post('/setup', async (req, res, next) => {
try { try {
const { username, password, display_name } = req.body || {}; const { username, password, display_name } = req.body;
if (!username || !password) { if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' }); return res.status(400).json({ error: 'Username and password are required' });
} }
const policyErr = checkPassword(password, { username }); if (password.length < 8) {
if (policyErr) return res.status(400).json({ error: policyErr }); return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const count = await pool.query('SELECT COUNT(*)::int AS n FROM users'); // Block if any user already exists
if (count.rows[0].n > 0) { const count = await pool.query('SELECT COUNT(*) FROM users');
if (parseInt(count.rows[0].count, 10) > 0) {
return res.status(403).json({ return res.status(403).json({
error: 'Setup is already complete. Use an existing admin to add more users.', error: 'Setup is already complete. Use an existing admin account to add more users.',
}); });
} }
const hash = await bcrypt.hash(password, 12); const hash = await bcrypt.hash(password, 12);
const r = await pool.query( const result = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role, is_client) `INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $3, 'admin', FALSE) VALUES ($1, $2, $3, 'admin')
RETURNING id, username, display_name, role, is_client`, RETURNING id, username, display_name, role`,
[String(username).trim().toLowerCase(), hash, display_name || username] [username.trim().toLowerCase(), hash, display_name || username]
); );
const newUser = r.rows[0];
audit(req, 'auth.setup', { targetType: 'user', targetId: newUser.id, meta: { username: newUser.username } }); res.status(201).json(result.rows[0]);
res.status(201).json(newUser);
} catch (err) { } catch (err) {
next(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; export default router;

View file

@ -1,10 +1,9 @@
import express from 'express'; import express from 'express';
import { requireAuth, requireRole } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.use(requireRole(['admin', 'editor'], { rejectClients: true }));
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001'; const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';

View file

@ -1,11 +1,10 @@
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAuth, requireRole } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); 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 // 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. // itself came from a real LAN address, prefer the request source IP instead.

View file

@ -11,7 +11,6 @@ import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// Base port for on-demand SDI sidecar containers on remote worker nodes. // Base port for on-demand SDI sidecar containers on remote worker nodes.

View file

@ -10,7 +10,6 @@ import { validateUuid } from '../middleware/errors.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']); const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);

View file

@ -14,11 +14,10 @@ import multer from 'multer';
import { promises as fs, createWriteStream } from 'fs'; import { promises as fs, createWriteStream } from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import path from 'path'; import path from 'path';
import { requireAuth, requireRole } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.use(requireRole(['admin', 'editor'], { rejectClients: true }));
const SDK_ROOT = process.env.SDK_ROOT || '/sdk'; const SDK_ROOT = process.env.SDK_ROOT || '/sdk';

View file

@ -1,211 +1,172 @@
/** /**
* User management routes admin only. * User management routes (admin-only when AUTH_ENABLED=true)
* *
* GET /api/v1/users list users * GET /api/v1/users list all users
* POST /api/v1/users create user * POST /api/v1/users create user
* GET /api/v1/users/:id get one user * GET /api/v1/users/:id get user
* PATCH /api/v1/users/:id update display_name / role / is_client / password * PATCH /api/v1/users/:id update user (display_name, role, password)
* DELETE /api/v1/users/:id delete user * 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 express from 'express';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAuth, requireAdmin } from '../middleware/auth.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(); const router = express.Router();
router.use(requireAuth, requireAdmin); router.use(requireAuth, requireAdmin);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
const VALID_ROLES = ['admin', 'editor', 'viewer']; const VALID_ROLES = ['admin', 'editor', 'viewer'];
const SELECT_FIELDS = ` // ── List ──────────────────────────────────────────────────────
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) => { router.get('/', async (_req, res, next) => {
try { try {
const { rows } = await pool.query(` const { rows } = await pool.query(
SELECT ${SELECT_FIELDS}, `SELECT u.id, u.username, u.display_name, u.role, u.created_at,
COUNT(ug.group_id)::int AS group_count COUNT(ug.group_id)::int AS group_count
FROM users u FROM users u
LEFT JOIN user_groups ug ON ug.user_id = u.id LEFT JOIN user_groups ug ON ug.user_id = u.id
GROUP BY u.id GROUP BY u.id
ORDER BY u.created_at ORDER BY u.created_at`
`); );
res.json(rows); res.json(rows);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// ── Create ────────────────────────────────────────────────────────────────── // ── Create ────────────────────────────────────────────────────
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const { username, password, display_name, role = 'editor', is_client = false } = req.body || {}; const { username, password, display_name, role = 'editor' } = req.body;
if (!username || !password) return res.status(400).json({ error: 'username and password required' }); if (!username || !password)
if (!VALID_ROLES.includes(role)) { 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))
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` }); 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 hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
const r = await pool.query( `INSERT INTO users (username, password_hash, display_name, role)
`INSERT INTO users (username, password_hash, display_name, role, is_client) VALUES ($1, $2, $3, $4)
VALUES ($1, $2, $3, $4, $5) RETURNING id, username, display_name, role, created_at`,
RETURNING ${SELECT_FIELDS}`, [username.trim().toLowerCase(), hash, display_name || username, role]
[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) { } catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' }); if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' });
next(err); next(err);
} }
}); });
// ── Get ───────────────────────────────────────────────────────────────────── // ── Get ───────────────────────────────────────────────────────
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
const r = await pool.query(`SELECT ${SELECT_FIELDS} FROM users u WHERE u.id = $1`, [req.params.id]); const { rows } = await pool.query(
if (!r.rows.length) return res.status(404).json({ error: 'User not found' }); `SELECT id, username, display_name, role, created_at FROM users WHERE id = $1`,
res.json(r.rows[0]); [req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'User not found' });
res.json(rows[0]);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// ── Update ────────────────────────────────────────────────────────────────── // ── Update ────────────────────────────────────────────────────
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
try { try {
const { display_name, role, password, is_client } = req.body || {}; const { display_name, role, password } = 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 = []; const sets = []; const vals = [];
const auditMeta = { username: target.username }; let passwordChanged = false;
if (display_name !== undefined) { if (display_name !== undefined) {
sets.push(`display_name = $${vals.length + 1}`); vals.push(String(display_name).trim().slice(0, 120)); sets.push(`display_name = $${sets.length + 1}`);
auditMeta.display_name = display_name; vals.push(display_name);
} }
if (role !== undefined) { if (role !== undefined) {
sets.push(`role = $${vals.length + 1}`); vals.push(role); if (!VALID_ROLES.includes(role))
auditMeta.role = role; return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
sets.push(`role = $${sets.length + 1}`);
vals.push(role);
} }
if (is_client !== undefined) { if (password) {
sets.push(`is_client = $${vals.length + 1}`); vals.push(!!is_client); if (password.length < 8)
auditMeta.is_client = !!is_client; return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
} const hashed = await bcrypt.hash(password, 12);
let passwordChanged = false; sets.push(`password_hash = $${sets.length + 1}`);
if (password !== undefined) { vals.push(hashed);
const hash = await bcrypt.hash(password, 12);
sets.push(`password_hash = $${vals.length + 1}`); vals.push(hash);
sets.push(`failed_attempts = 0`);
passwordChanged = true; passwordChanged = true;
} }
if (!sets.length) return res.status(400).json({ error: 'Nothing to update' }); if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
sets.push(`updated_at = NOW()`); vals.push(req.params.id);
vals.push(id); const { rows } = await pool.query(
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length}
const r = await pool.query( RETURNING id, username, display_name, role, created_at`,
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING ${SELECT_FIELDS}`,
vals vals
); );
if (!rows.length) return res.status(404).json({ error: 'User not found' });
// Password rotation invalidates every session for this user. Session // BUG FIX #5: Invalidate all active sessions for this user when their
// store is connect-pg-simple, so a direct DELETE on the sessions table // password is changed. Without this, an attacker who has already stolen a
// is the fastest path. Best-effort — never block the response. // session cookie retains access even after the password is rotated, and a
if (passwordChanged) { // user who changes their own password doesn't log out other devices.
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`)) // Implementation note: express-session stores sessions keyed by session ID,
.catch(e => console.warn('[users] session invalidation failed:', e.message)); // 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);
}
} }
audit(req, passwordChanged ? 'user.password.reset' : 'user.update', { res.json(rows[0]);
targetType: 'user',
targetId: id,
meta: auditMeta,
});
res.json(r.rows[0]);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// ── Delete ────────────────────────────────────────────────────────────────── // ── Delete ────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
try { try {
const id = req.params.id; const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
const actorId = req.user?.id || req.session?.userId; if (!rowCount) return res.status(404).json({ error: 'User not found' });
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' }); res.json({ message: 'User deleted' });
} catch (err) { next(err); } } catch (err) { next(err); }
}); });

View file

@ -1,93 +0,0 @@
// 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' },
});
}

View file

@ -118,14 +118,6 @@ function App() {
// Home (launcher) suppresses the topbar it's a full-bleed landing page. // Home (launcher) suppresses the topbar it's a full-bleed landing page.
const hideTopbar = !openAsset && route === 'home'; 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 ( return (
<div className="app" data-density="comfortable" data-grid-size="md" data-sidebar={sidebarCollapsed ? 'collapsed' : 'expanded'}> <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} /> <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} me={window.ZAMPP_DATA?.ME} collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
@ -142,9 +134,6 @@ function App() {
{content} {content}
</div> </div>
{showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />} {showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}
{accountOpen && window.AccountSettingsModal && (
<window.AccountSettingsModal onClose={() => setAccountOpen(false)} />
)}
</div> </div>
); );
} }

View file

@ -244,17 +244,15 @@ async function loadData() {
const me = meR.value; const me = meR.value;
const label = me.display_name || me.username || 'User'; const label = me.display_name || me.username || 'User';
window.ZAMPP_DATA.ME = { window.ZAMPP_DATA.ME = {
id: me.id, id: me.id,
username: me.username, username: me.username,
display_name: me.display_name || me.username, name: label,
name: label, initials: label.slice(0, 2).toUpperCase(),
initials: label.slice(0, 2).toUpperCase(), role: me.role || 'viewer',
role: me.role || 'viewer',
is_client: !!me.is_client,
// True when the server returned a synthetic user (AUTH_ENABLED=false). // True when the server returned a synthetic user (AUTH_ENABLED=false).
// Surfaced as a small "auth off" hint in the sidebar so the operator // 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. // understands why the corner shows the OS user instead of a login.
synthetic: !!me.synthetic, synthetic: !!me.synthetic,
}; };
} }
} }

View file

@ -37,7 +37,6 @@
<script src="dist/screens-editor.js"></script> <script src="dist/screens-editor.js"></script>
<script src="dist/screens-admin.js"></script> <script src="dist/screens-admin.js"></script>
<script src="dist/modal-new-recorder.js"></script> <script src="dist/modal-new-recorder.js"></script>
<script src="dist/modal-account-settings.js"></script>
<script src="dist/app.js"></script> <script src="dist/app.js"></script>
</body> </body>
</html> </html>

View file

@ -182,8 +182,6 @@
/* Make the login button full-width (the primitive is inline-flex by default) */ /* Make the login button full-width (the primitive is inline-flex by default) */
.wd-btn--full { width: 100%; margin-top: 10px; } .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 */ /* Stack form groups vertically with consistent gap */
.login-form { display: flex; flex-direction: column; gap: 14px; } .login-form { display: flex; flex-direction: column; gap: 14px; }
</style> </style>
@ -241,9 +239,8 @@
<input class="wd-input" id="su-username" name="username" type="text" required placeholder="admin"> <input class="wd-input" id="su-username" name="username" type="text" required placeholder="admin">
</div> </div>
<div class="wd-form-group"> <div class="wd-form-group">
<label class="wd-label" for="su-password">Password</label> <label class="wd-label" for="su-password">Password (min 8 chars)</label>
<input class="wd-input" id="su-password" name="password" type="password" autocomplete="new-password" required minlength="8" placeholder="••••••••"> <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>
<div class="wd-form-group"> <div class="wd-form-group">
<label class="wd-label" for="su-display">Display name (optional)</label> <label class="wd-label" for="su-display">Display name (optional)</label>

View file

@ -1,149 +0,0 @@
// 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;

View file

@ -46,16 +46,14 @@ function _normalizeNode(n, x, y) {
} }
function InviteUserModal({ onCreated, onClose }) { function InviteUserModal({ onCreated, onClose }) {
const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer', is_client: false }); const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer' });
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null); const [err, setErr] = React.useState(null);
const submit = () => { const submit = () => {
if (!form.username || !form.password) { setErr('Username and password are required'); return; } 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); setSaving(true); setErr(null);
window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(payload) }) window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(form) })
.then(user => { onCreated(user); onClose(); }) .then(user => { onCreated(user); onClose(); })
.catch(e => { setSaving(false); setErr(e.message || 'Failed to create user'); }); .catch(e => { setSaving(false); setErr(e.message || 'Failed to create user'); });
}; };
@ -88,26 +86,17 @@ function InviteUserModal({ onCreated, onClose }) {
autoComplete="new-password" autoComplete="new-password"
onChange={e => setForm(p => ({...p, password: e.target.value}))} onChange={e => setForm(p => ({...p, password: e.target.value}))}
onKeyDown={onKey} placeholder="Temporary password" /> onKeyDown={onKey} placeholder="Temporary password" />
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>8+ chars, mixed case, digit, symbol</div>
</div> </div>
<div className="field"> <div className="field">
<label className="field-label">Role</label> <label className="field-label">Role</label>
<select className="field-input" value={form.role} <select className="field-input" value={form.role}
onChange={e => setForm(p => ({...p, role: e.target.value, is_client: e.target.value === 'admin' ? false : p.is_client }))} onChange={e => setForm(p => ({...p, role: e.target.value}))}
style={{ appearance: 'auto' }}> style={{ appearance: 'auto' }}>
<option value="viewer">Viewer</option> <option value="viewer">Viewer</option>
<option value="editor">Editor</option> <option value="editor">Editor</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
</div> </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>} {err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div> </div>
<div className="modal-foot"> <div className="modal-foot">
@ -160,8 +149,8 @@ function Users() {
}, [menuFor]); }, [menuFor]);
const exportCsv = () => { const exportCsv = () => {
const rows = [['Username', 'Name', 'Role', 'Client', 'Groups', 'Last login', 'Created']].concat( const rows = [['Username', 'Name', 'Role', 'Groups', '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 || '']) users.map(u => [u.username || '', u.name || '', u.role || '', u.group_count || 0, u.created_at || ''])
); );
const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n'); const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n');
const a = document.createElement('a'); const a = document.createElement('a');
@ -184,20 +173,11 @@ function Users() {
const changeRole = (u, newRole) => { const changeRole = (u, newRole) => {
if (u.role === newRole) return; if (u.role === newRole) return;
const body = { role: newRole }; window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
if (newRole === 'admin') body.is_client = false;
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify(body) })
.then(refreshUsers) .then(refreshUsers)
.catch(e => alert('Role change failed: ' + e.message)); .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 ( return (
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
@ -220,9 +200,8 @@ function Users() {
<div className="user-row head"> <div className="user-row head">
<div>User</div> <div>User</div>
<div>Role</div> <div>Role</div>
<div>Client</div>
<div>Groups</div> <div>Groups</div>
<div>Last login</div> <div>Created</div>
<div></div> <div></div>
</div> </div>
{users.length === 0 && ( {users.length === 0 && (
@ -247,19 +226,11 @@ function Users() {
<option value="viewer">viewer</option> <option value="viewer">viewer</option>
</select> </select>
</div> </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)" }}> <div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'} {u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
</div> </div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}> <div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : (u.created_at ? `created ${new Date(u.created_at).toLocaleDateString()}` : '—')} {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
</div> </div>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}> <button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>

View file

@ -102,32 +102,11 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
return () => { cancelled = true; clearInterval(id); }; return () => { cancelled = true; clearInterval(id); };
}, []); }, []);
// Apply the live jobs badge to the Jobs nav item, and gate items the // Apply the live jobs badge to the Jobs nav item.
// current user shouldn't see. Clients lose the entire ingest tree, jobs, const navTree = React.useMemo(
// and the editor. Viewers keep Library + Projects. () => NAV_TREE.map(n => n.id === 'jobs' && jobsBadge ? { ...n, badge: jobsBadge } : n),
const isClient = !!me?.is_client; [jobsBadge]
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) => { const toggleGroup = (id) => {
setOpenGroups(prev => { setOpenGroups(prev => {
const next = new Set(prev); const next = new Set(prev);
@ -178,45 +157,26 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
toggleGroup={toggleGroup} toggleGroup={toggleGroup}
/> />
))} ))}
{adminTree.length > 0 && ( <div className="nav-section-label">Admin</div>
<> {ADMIN_TREE.map(item => (
<div className="nav-section-label">Admin</div> <NavItem
{adminTree.map(item => ( key={item.id}
<NavItem item={item}
key={item.id} active={active}
item={item} onSelect={onNavigate}
active={active} openGroups={openGroups}
onSelect={onNavigate} toggleGroup={toggleGroup}
openGroups={openGroups} />
toggleGroup={toggleGroup} ))}
/>
))}
</>
)}
</div> </div>
<div className="sidebar-footer"> <div className="sidebar-footer">
<button <div className="avatar">{me?.initials || '—'}</div>
className="sidebar-userbtn" <div className="user-meta">
onClick={() => { if (!me?.synthetic) window.dispatchEvent(new CustomEvent('df:open-account-settings')); }} <div className="user-name">{me?.name || 'Not signed in'}</div>
disabled={!!me?.synthetic} <div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false — showing the OS user running the server' : ''}>
title={me?.synthetic ? 'AUTH_ENABLED=false — account settings unavailable' : 'Account settings'} {me?.role || '—'}{me?.synthetic ? ' · auth off' : ''}
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> </div>
{me?.synthetic ? null : ( {me?.synthetic ? null : (
<button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out" <button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out"
onClick={async () => { onClick={async () => {

View file

@ -607,7 +607,7 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
font-size: 12.5px; font-size: 12.5px;
} }
.user-row { grid-template-columns: 1.5fr 100px 70px 1.2fr 180px 40px; } .user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 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; } .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; } .schedule-row { grid-template-columns: 1.6fr 1.2fr 1.2fr 90px 110px 110px 150px; }