From 9d098e9778e7343a498e103546fc8da4cb608e62 Mon Sep 17 00:00:00 2001 From: Zac Date: Sat, 30 May 2026 15:59:27 +0000 Subject: [PATCH] feat(auth-ui): interactive permissions matrix, admin 2FA reset, Downloads button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:; 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 --- services/mam-api/src/routes/users.js | 96 +++++++++- services/web-ui/public/data.jsx | 6 + services/web-ui/public/screens-admin.jsx | 221 ++++++++++++++++++++--- services/web-ui/public/screens-home.jsx | 55 ++++-- 4 files changed, 339 insertions(+), 39 deletions(-) diff --git a/services/mam-api/src/routes/users.js b/services/mam-api/src/routes/users.js index b28bcb5..3e93dba 100644 --- a/services/mam-api/src/routes/users.js +++ b/services/mam-api/src/routes/users.js @@ -3,10 +3,12 @@ import express from 'express'; import pool from '../db/pool.js'; import { hashPassword } from '../auth/passwords.js'; -import { DEV_USER_ID } from '../middleware/auth.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 }); } @@ -14,7 +16,7 @@ function bad(res, msg) { return res.status(400).json({ error: msg }); } router.get('/', async (_req, res, next) => { try { const { rows } = await pool.query( - `SELECT id, username, display_name, role, last_login_at, created_at + `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); } @@ -26,6 +28,7 @@ router.post('/', async (req, res, next) => { 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) @@ -76,7 +79,10 @@ router.patch('/:id', async (req, res, next) => { 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') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); } + 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()'); @@ -93,4 +99,88 @@ router.patch('/:id', async (req, res, next) => { } 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:' 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; diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index b940408..cd5be25 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -38,6 +38,12 @@ window.PREMIERE_RELEASES = [ ]; window.PREMIERE_LATEST = window.PREMIERE_RELEASES.find(r => r.latest) || window.PREMIERE_RELEASES[0]; +// Teams ISO workstation installer. Placeholder slot: the .exe is not in the +// repo yet, so `available` is false and the Downloads modal renders the row +// disabled with a "coming soon" note. Drop the file into public/downloads/ +// and flip `available: true` (set `version`) to finish it. +window.TEAMS_ISO = { version: null, url: '/downloads/TeamsISO.exe', available: false }; + window.ZAMPP_DATA = { PROJECTS: [], ASSETS: [], diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 16ae8e8..926eb75 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -258,28 +258,7 @@ function Users() { {tab === 'groups' && } {tab === 'policies' && ( -
-
- -
Access model
-
-
-
- admin — full access to every - project plus user, group, cluster, and system administration. -
-
- editor / viewer — see only the - projects they've been granted. A view grant is read-only; an - edit grant allows changes. Grants can target an individual user or a group. -
-
- Manage a project's grants from the Projects page - → a project's Manage access… menu. Group membership is managed on the - Groups tab above. -
-
-
+ )} {showInvite && setShowInvite(false)} />} @@ -299,6 +278,204 @@ function Users() { ); } +// ──────────────────────────────────────────────────────────────────────────── +// PoliciesPanel - interactive per-user permission matrix for the Policies tab. +// Keeps the access-model explainer as a small header, then renders one row per +// user with: inline role onChangeRole(u, e.target.value)} + className="field-input" + style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}> + + + + + +
+ {u.totp_enabled + ? 2FA on + : 2FA off} +
+
+ +
+
+ {u.totp_enabled && ( + + )} +
+ + + {expanded && ( +
+ {loading &&
Loading access…
} + {accessErr &&
{accessErr}
} + {!loading && !accessErr && (u.role === 'admin') && ( +
+ + Admin — full access to every project. +
+ )} + {!loading && !accessErr && u.role !== 'admin' && ( +
+ {/* Accessible projects */} +
+
+ Projects ({projects.length}) +
+ {projects.length === 0 && ( +
No project access granted.
+ )} + {projects.map(p => { + // Backend `via` is 'direct' for a user grant, or 'group:' + // when inherited from a group. Split the label off the prefix. + const via = p.via || 'direct'; + const isGroup = via.indexOf('group') === 0; + const viaLabel = isGroup ? (via.indexOf(':') >= 0 ? via.slice(via.indexOf(':') + 1) : 'group') : 'direct'; + return ( +
+ {p.project_name || p.name || p.project_id || p.id} + {p.level || 'view'} + + {viaLabel} + +
+ ); + })} +
+ {/* Group memberships */} +
+
+ Groups ({memberships.length}) +
+ {memberships.length === 0 && ( +
Not a member of any group.
+ )} +
+ {memberships.map(g => ( + + {g.name || g.group_name || g.group_id} + + ))} +
+
+
+ )} +
+ )} + + ); +} + function EditUserModal({ user, onClose, onSaved }) { const [name, setName] = React.useState(user.display_name || user.name || ''); const [saving, setSaving] = React.useState(false); diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx index 50fe5f7..3497e47 100644 --- a/services/web-ui/public/screens-home.jsx +++ b/services/web-ui/public/screens-home.jsx @@ -18,7 +18,7 @@ // Anything that would just say "all clear" is hidden, not rendered. function Home({ navigate }) { - const [showPremiereDownload, setShowPremiereDownload] = React.useState(false); + const [showDownloads, setShowDownloads] = React.useState(false); // Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running") // reflect what's actually in the DB right now, not a stale boot-time cache. @@ -86,12 +86,12 @@ function Home({ navigate }) { desc: 'Master Control. SDI · NDI · SRT · RTMP playout, playlists, as-run.', }, { - id: '__premiere', - label: 'Premiere panel', - icon: 'editor', + id: '__downloads', + label: 'Downloads', + icon: 'download', tone: 'purple', - sub: 'v' + ((window.PREMIERE_LATEST || {}).version || '·'), - desc: 'Download the Adobe Premiere Pro panel for frame-accurate editing.', + sub: 'Plugin · Teams ISO', + desc: 'Download the Premiere Pro UXP plugin and the Teams ISO installer.', }, { id: 'jobs', @@ -149,7 +149,7 @@ function Home({ navigate }) {
+
+
+ Teams ISO + {teamsIso.version && ( + v{teamsIso.version} + )} +
+
+ Windows installer for the Teams ISO workstation build. +
+
+ {teamsIso.available && teamsIso.url ? ( + + Teams ISO (.exe) + + ) : ( + <> + + Teams ISO (.exe) + + coming soon — file pending + + )} +
+
{releases.length === 0 && (
No releases registered yet. Upload one from Settings → Capture SDKs.