Revert "auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap"
This reverts commit 002e5acb82.
This commit is contained in:
parent
002e5acb82
commit
9726dbb2df
23 changed files with 297 additions and 1010 deletions
|
|
@ -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:-}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
93
services/mam-api/src/tasks/bootstrapAdmin.js
vendored
93
services/mam-api/src/tasks/bootstrapAdmin.js
vendored
|
|
@ -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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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); }}>
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue