Backend (routes/users.js):
- GET / now returns totp_enabled so the UI can show 2FA status
- GET /:id/access — admin-only effective per-project access (MAX over direct +
group grants), labels via=direct|group:<name>; admins report all/edit
- POST /:id/totp/disable — admin clears a locked-out user's 2FA without their
password (self-service disable still requires it); dev user blocked
- role validated against {admin,editor,viewer} on create + PATCH (was unchecked)
Frontend:
- Users>Policies tab: static prose replaced with interactive per-user matrix —
inline role select, 2FA badge, Reset-2FA action, lazy per-user access expander
- Home "Premiere panel" tile -> "Downloads"; modal renamed, adds Teams ISO row
(disabled "coming soon" until the .exe is supplied); UXP .ccx link unchanged
- data.jsx: window.TEAMS_ISO placeholder ({available:false})
Not runtime-tested in browser yet. Teams ISO .exe still pending from user.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
186 lines
8.5 KiB
JavaScript
186 lines
8.5 KiB
JavaScript
// User CRUD. Mounted at /api/v1/auth/users by index.js (behind the auth gate).
|
|
// Flat access: any logged-in user can manage other users (spec).
|
|
import express from 'express';
|
|
import pool from '../db/pool.js';
|
|
import { hashPassword } from '../auth/passwords.js';
|
|
import { DEV_USER_ID, requireAdmin } from '../middleware/auth.js';
|
|
import { accessibleProjectIds } from '../auth/authz.js';
|
|
|
|
const router = express.Router();
|
|
const MIN_PASSWORD_LEN = 12;
|
|
const ROLES = ['admin', 'editor', 'viewer'];
|
|
|
|
function bad(res, msg) { return res.status(400).json({ error: msg }); }
|
|
|
|
// GET / — list users (real ones; dev seed hidden)
|
|
router.get('/', async (_req, res, next) => {
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`SELECT id, username, display_name, role, totp_enabled, last_login_at, created_at
|
|
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
|
|
res.json(rows);
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// POST / — create user
|
|
router.post('/', async (req, res, next) => {
|
|
try {
|
|
const { username, password, display_name, role } = req.body || {};
|
|
if (!username || typeof username !== 'string') return bad(res, 'username required');
|
|
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
|
if (role !== undefined && !ROLES.includes(role)) return bad(res, "role must be one of: " + ROLES.join(', '));
|
|
const hash = await hashPassword(password);
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO users (username, password_hash, display_name, role)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, username, display_name, role, created_at`,
|
|
[username.trim(), hash, display_name || username.trim(), role || 'admin']
|
|
);
|
|
res.status(201).json(rows[0]);
|
|
} catch (err) {
|
|
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// POST /:id/password — admin reset another user's password
|
|
router.post('/:id/password', async (req, res, next) => {
|
|
try {
|
|
const { new_password } = req.body || {};
|
|
if (!new_password || new_password.length < MIN_PASSWORD_LEN) {
|
|
return bad(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
|
}
|
|
const hash = await hashPassword(new_password);
|
|
const { rowCount } = await pool.query(
|
|
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2 AND id <> $3`,
|
|
[hash, req.params.id, DEV_USER_ID]);
|
|
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
|
|
res.status(204).end();
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// DELETE /:id — delete a user, except the last real user
|
|
router.delete('/:id', async (req, res, next) => {
|
|
try {
|
|
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot delete dev user' });
|
|
const { rows } = await pool.query(
|
|
`SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
|
|
if (rows[0].n <= 1) return res.status(409).json({ error: 'cannot delete last user' });
|
|
const { rowCount } = await pool.query(`DELETE FROM users WHERE id = $1`, [req.params.id]);
|
|
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
|
|
res.status(204).end();
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// PATCH /:id { display_name?, role?, password? } — generic update.
|
|
// password update goes through hashPassword; other fields are passed through.
|
|
router.patch('/:id', async (req, res, next) => {
|
|
try {
|
|
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
|
const sets = []; const vals = [];
|
|
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
|
|
if (typeof req.body?.role === 'string') {
|
|
if (!ROLES.includes(req.body.role)) return bad(res, "role must be one of: " + ROLES.join(', '));
|
|
sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role);
|
|
}
|
|
if (typeof req.body?.password === 'string') {
|
|
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
|
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
|
|
vals.push(await hashPassword(req.body.password));
|
|
}
|
|
if (sets.length === 0) return bad(res, 'nothing to update');
|
|
vals.push(req.params.id);
|
|
const { rows } = await pool.query(
|
|
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING id, username, display_name, role`,
|
|
vals
|
|
);
|
|
if (rows.length === 0) return res.status(404).json({ error: 'user not found' });
|
|
res.json(rows[0]);
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// GET /:id/access — effective per-project access for one user (admin only).
|
|
// Reuses authz.accessibleProjectIds (MAX over direct user grant + every group the
|
|
// user belongs to). `via` is 'direct' for a user grant, 'group:<name>' otherwise.
|
|
// When the effective level comes from several sources we report the direct grant
|
|
// if present, else the first contributing group.
|
|
router.get('/:id/access', requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const { rows: urows } = await pool.query(
|
|
`SELECT id, role FROM users WHERE id = $1`, [req.params.id]);
|
|
if (urows.length === 0) return res.status(404).json({ error: 'user not found' });
|
|
const target = urows[0];
|
|
|
|
const { rows: groups } = await pool.query(
|
|
`SELECT g.id, g.name
|
|
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
|
|
WHERE ug.user_id = $1 ORDER BY g.name`, [target.id]);
|
|
|
|
// Admins bypass scoping — every project at 'edit', via their role.
|
|
const access = await accessibleProjectIds(target);
|
|
if (access.all) {
|
|
const { rows: projects } = await pool.query(
|
|
`SELECT id, name FROM projects ORDER BY name`);
|
|
return res.json({
|
|
projects: projects.map(p => ({
|
|
project_id: p.id, project_name: p.name, level: 'edit', via: 'direct',
|
|
})),
|
|
groups,
|
|
});
|
|
}
|
|
|
|
const ids = [...access.ids];
|
|
if (ids.length === 0) return res.json({ projects: [], groups });
|
|
|
|
// Resolve names + the source of each grant. groupNameById lets us label a
|
|
// group-sourced grant; a direct user grant always wins the `via` label.
|
|
const groupNameById = new Map(groups.map(g => [g.id, g.name]));
|
|
const { rows: grants } = await pool.query(
|
|
`SELECT pa.project_id, pa.subject_type, pa.subject_id, pa.level, p.name AS project_name
|
|
FROM project_access pa JOIN projects p ON p.id = pa.project_id
|
|
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
|
|
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
|
SELECT group_id FROM user_groups WHERE user_id = $1
|
|
))`,
|
|
[target.id]);
|
|
|
|
const byProject = new Map();
|
|
for (const g of grants) {
|
|
const eff = access.levelByProject.get(g.project_id); // already the MAX
|
|
const via = g.subject_type === 'user'
|
|
? 'direct'
|
|
: 'group:' + (groupNameById.get(g.subject_id) || g.subject_id);
|
|
const prev = byProject.get(g.project_id);
|
|
// Keep a row only if it carries the effective level; prefer a direct grant
|
|
// when both a direct and a group grant hit the same level.
|
|
if (g.level === eff && (!prev || (prev.via !== 'direct' && via === 'direct'))) {
|
|
byProject.set(g.project_id, {
|
|
project_id: g.project_id, project_name: g.project_name, level: eff, via,
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
projects: [...byProject.values()].sort((a, b) => a.project_name.localeCompare(b.project_name)),
|
|
groups,
|
|
});
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// POST /:id/totp/disable — admin clears a locked-out user's 2FA WITHOUT their
|
|
// password (the self-service /auth/totp/disable needs the victim's own). Mirrors
|
|
// that handler's SQL but targets :id and skips the password check. Dev user blocked.
|
|
router.post('/:id/totp/disable', requireAdmin, async (req, res, next) => {
|
|
try {
|
|
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
|
const { rowCount } = await pool.query(
|
|
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0
|
|
WHERE id = $1 AND id <> $2`,
|
|
[req.params.id, DEV_USER_ID]);
|
|
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
|
|
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.params.id]);
|
|
res.status(204).end();
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
export default router;
|