diff --git a/frontend/js/api.js b/frontend/js/api.js index 8aad3ab..446b9f5 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -178,6 +178,12 @@ export const api = { // workspaceId, role, mustChangePassword } adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }), + // Per-user workspace membership management (platform Users page modal). + adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`), + adminAddUserWorkspace: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces`, { method: 'POST', body: JSON.stringify({ workspaceId, role }) }), + adminSetUserWorkspaceRole: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'PUT', body: JSON.stringify({ role }) }), + adminRemoveUserWorkspace: (id, workspaceId) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'DELETE' }), + // Admin - Users getUsers: () => request('/auth/users'), deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }), diff --git a/frontend/js/components/admin-user-workspaces-modal.js b/frontend/js/components/admin-user-workspaces-modal.js new file mode 100644 index 0000000..2544799 --- /dev/null +++ b/frontend/js/components/admin-user-workspaces-modal.js @@ -0,0 +1,163 @@ +// "Manage workspaces" modal for the platform Users admin page. Lets a platform +// admin see/manage ALL of a user's workspace memberships: list each with an +// inline role dropdown + Remove, and add the user to more workspaces via a +// type-to-filter picker. Backed by /api/admin/users/:id/workspaces. +import { api } from '../api.js'; +import { t } from '../i18n.js'; +import { showToast } from '../components/toast.js'; + +// Display order = least-privilege first (the default for the add row). The SET +// must match the server's accepted WORKSPACE_ROLES (routes/admin.js). +const WORKSPACE_ROLES = ['workspace_viewer', 'workspace_editor', 'workspace_admin']; +const STAFF_ROLES = ['platform_admin', 'superadmin', 'platform_operator']; + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); +} +function roleOptions(selected) { + return WORKSPACE_ROLES.map(r => ``).join(''); +} +const wsLabel = w => `${w.organization_name || '—'} / ${w.name}`; + +// user: { id, name, email, role }; opts.onClose fires (once) if anything changed. +export function openManageWorkspacesModal(user, opts = {}) { + const { onClose } = opts; + const isStaff = STAFF_ROLES.includes(user.role); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const listEl = overlay.querySelector('#mwsList'); + const filterEl = overlay.querySelector('#mwsFilter'); + const addWsEl = overlay.querySelector('#mwsAddWs'); + const addRoleEl = overlay.querySelector('#mwsAddRole'); + const addBtn = overlay.querySelector('#mwsAddBtn'); + const errorEl = overlay.querySelector('#mwsError'); + + let allWs = []; // assignable workspaces (from /me) + let memberships = []; // current memberships + let changed = false; // refresh the table on close only if something changed + + function close() { + overlay.remove(); + document.removeEventListener('keydown', onKey); + if (changed && typeof onClose === 'function') { try { onClose(); } catch (e) { console.error(e); } } + } + function onKey(e) { if (e.key === 'Escape') close(); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + overlay.querySelectorAll('[data-mws-close]').forEach(b => b.addEventListener('click', close)); + + const showError = m => { errorEl.textContent = m; errorEl.style.display = 'block'; }; + const clearError = () => { errorEl.style.display = 'none'; }; + + function renderAddOptions() { + const memberIds = new Set(memberships.map(m => m.workspace_id)); + const f = (filterEl.value || '').trim().toLowerCase(); + const avail = allWs.filter(w => !memberIds.has(w.id) && (!f || wsLabel(w).toLowerCase().includes(f))); + let html = ``; + let curOrg = null; + for (const w of avail) { + const org = w.organization_name || '—'; + if (org !== curOrg) { if (curOrg !== null) html += ''; html += ``; curOrg = org; } + html += ``; + } + if (curOrg !== null) html += ''; + addWsEl.innerHTML = html; + } + + function renderList() { + if (!memberships.length) { + listEl.innerHTML = `

${t('manage_ws.empty')}

`; + return; + } + listEl.innerHTML = memberships.map(m => ` +
+
+
${esc(m.workspace_name)}
+
${esc(m.organization_name || '')}
+
+ + +
+ `).join(''); + + listEl.querySelectorAll('[data-mws-role]').forEach(sel => { + sel.onchange = async () => { + clearError(); + try { await api.adminSetUserWorkspaceRole(user.id, sel.dataset.mwsRole, sel.value); changed = true; showToast(t('manage_ws.toast.role'), 'success'); await reload(); } + catch (e) { showError(e.message); await reload(); } + }; + }); + listEl.querySelectorAll('[data-mws-remove]').forEach(btn => { + btn.onclick = async () => { + clearError(); + try { await api.adminRemoveUserWorkspace(user.id, btn.dataset.mwsRemove); changed = true; showToast(t('manage_ws.toast.removed'), 'success'); await reload(); } + catch (e) { showError(e.message); await reload(); } + }; + }); + } + + async function reload() { + memberships = await api.adminGetUserWorkspaces(user.id).catch(() => memberships); + renderList(); + renderAddOptions(); + } + + filterEl.addEventListener('input', renderAddOptions); + addBtn.addEventListener('click', async () => { + clearError(); + const wsId = addWsEl.value; + const role = addRoleEl.value; + if (!wsId) { showError(t('manage_ws.pick_required')); return; } + addBtn.disabled = true; + try { + await api.adminAddUserWorkspace(user.id, wsId, role); + changed = true; + showToast(t('manage_ws.toast.added'), 'success'); + filterEl.value = ''; + await reload(); + } catch (e) { showError(e.message); } + finally { addBtn.disabled = false; } + }); + + // initial load + (async () => { + try { + const [mem, me] = await Promise.all([api.adminGetUserWorkspaces(user.id), api.getMe().catch(() => ({}))]); + memberships = Array.isArray(mem) ? mem : []; + allWs = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces.slice() : []; + renderList(); + renderAddOptions(); + } catch (e) { + listEl.innerHTML = `

${esc(e.message || 'Failed to load')}

`; + } + })(); +} diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 694d399..3f36d0e 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -798,8 +798,23 @@ export default { 'admin.col.actions': 'Actions', 'admin.workspace.unassigned': 'Unassigned', 'admin.workspace.multi': '{n} workspaces', - 'admin.workspace.multi_hint': 'Belongs to multiple workspaces - manage in the workspace members view', 'admin.workspace.platform_all': 'Platform (all)', + 'admin.workspace.manage': 'Manage', + // "Manage workspaces" modal (per-user membership management) + 'manage_ws.title': 'Manage workspaces — {user}', + 'manage_ws.staff_note': 'This user has platform-wide access; the memberships below are in addition to that.', + 'manage_ws.current': 'Current workspaces', + 'manage_ws.empty': 'Not a member of any workspace.', + 'manage_ws.add': 'Add to workspace', + 'manage_ws.filter': 'Filter workspaces…', + 'manage_ws.pick': 'Select a workspace…', + 'manage_ws.pick_required': 'Pick a workspace to add.', + 'manage_ws.add_btn': 'Add', + 'manage_ws.remove': 'Remove', + 'manage_ws.done': 'Done', + 'manage_ws.toast.added': 'Added to workspace', + 'manage_ws.toast.removed': 'Removed from workspace', + 'manage_ws.toast.role': 'Role updated', 'admin.col.devices': 'Devices', 'admin.col.storage': 'Storage', 'admin.col.monthly': 'Monthly', diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index 01283d0..acf40f0 100644 --- a/frontend/js/views/admin.js +++ b/frontend/js/views/admin.js @@ -3,6 +3,7 @@ import { showToast } from '../components/toast.js'; import { esc, isPlatformAdmin } from '../utils.js'; import { t } from '../i18n.js'; import { openAddUserModal } from '../components/workspace-members-add-user-modal.js'; +import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js'; // Reuse the members view's server-error -> friendly-string mapper (handles the // 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a // second mapper. @@ -24,37 +25,26 @@ function isPlatformStaffRole(role) { return role === 'platform_admin' || role === 'superadmin' || role === 'platform_operator'; } -// Build the org-grouped workspace `; - let currentOrg = null; - for (const w of list) { - const org = w.organization_name || '—'; - if (org !== currentOrg) { - if (currentOrg !== null) html += ''; - html += ``; - currentOrg = org; - } - html += ``; - } - if (currentOrg !== null) html += ''; - return html; +// Short summary of a user's workspace membership for the Users-table cell. +// Platform staff have cross-org access (not per-workspace membership) -> "Platform +// (all)". Otherwise: Unassigned (0), the workspace name (1), or "N workspaces". +function workspaceSummary(u) { + if (isPlatformStaffRole(u.role)) return t('admin.workspace.platform_all'); + const count = u.workspace_count || 0; + if (count === 0) return t('admin.workspace.unassigned'); + if (count === 1) return esc(u.workspace_name || ''); + return t('admin.workspace.multi', { n: count }); } -// Workspace cell for one user row. Editable ${optionsHtml} +
+ ${workspaceSummary(u)} + +
`; } @@ -110,14 +100,11 @@ export async function render(container) { async function loadUsers() { const el = document.getElementById('allUsersTable'); try { - const [users, plans, me] = await Promise.all([ + const [users, plans] = await Promise.all([ API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json()), - api.getMe().catch(() => ({})), // workspace-picker source (same as Add User modal) ]); const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); - // Build the org-grouped workspace options ONCE, reuse per row. - const wsOptionsHtml = buildWorkspaceOptions(Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : []); el.innerHTML = `
@@ -147,7 +134,7 @@ async function loadUsers() { ${plans.map(p => ``).join('')} - ${workspaceCell(u, wsOptionsHtml)} + ${workspaceCell(u)} ${u.auth_provider === 'local' && u.id !== currentUser.id ? `` : ''} ${!isPlatformAdmin(u) ? `` : `${t('admin.owner')}`} @@ -178,22 +165,14 @@ async function loadUsers() { }; }); - // Workspace move/assign (editable rows only: a 'user' with 0 or 1 membership). - // Set the current selection per row (the shared options string carries no - // per-row `selected`), then move/assign on change. Picking "Unassigned" or - // the same workspace is a no-op so a stray pick can't strip a membership. - el.querySelectorAll('[data-ws-user]').forEach(select => { - select.value = select.dataset.current || ''; - select.onchange = async () => { - const wsId = select.value; - const current = select.dataset.current || ''; - if (!wsId || wsId === current) { select.value = current; return; } - try { - const r = await API(`/admin/users/${select.dataset.wsUser}/workspace`, { method: 'PUT', body: JSON.stringify({ workspaceId: wsId }) }); - if (r && r.error) { showToast(r.error, 'error'); loadUsers(); return; } - showToast(t('admin.toast.workspace_updated'), 'success'); - loadUsers(); - } catch (err) { showToast(err.message, 'error'); loadUsers(); } + // Manage workspaces: open the per-user membership modal (add/remove + // workspaces, set per-workspace role). Refresh the table on close only if + // something changed (the modal calls onClose then). + el.querySelectorAll('[data-ws-manage]').forEach(btn => { + btn.onclick = () => { + const u = users.find(x => x.id === btn.dataset.wsManage); + if (!u) return; + openManageWorkspacesModal(u, { onClose: () => loadUsers() }); }; }); diff --git a/server/routes/admin.js b/server/routes/admin.js index 38792f1..6d07374 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -147,4 +147,81 @@ router.put('/users/:id/workspace', requirePlatformAdmin, (req, res) => { res.json({ user_id: target.id, workspace_id: ws.id, workspace_name: ws.name, organization_name: org?.name || null, role: 'workspace_viewer' }); }); +// ===================== Per-user workspace membership management ===================== +// Platform-admin only (cross-org, platform-level). Unlike the single-workspace +// "move" above, these manage a user's FULL set of memberships - a user can +// belong to several workspaces, each with its own role - from the platform Users +// page "Manage workspaces" modal. requirePlatformAdmin excludes platform_operator +// (no user/role management, #13). + +function userMembershipList(userId) { + return db.prepare(` + SELECT wm.workspace_id, w.name AS workspace_name, o.name AS organization_name, wm.role + FROM workspace_members wm + JOIN workspaces w ON w.id = wm.workspace_id + JOIN organizations o ON o.id = w.organization_id + WHERE wm.user_id = ? + ORDER BY o.name, w.name + `).all(userId); +} + +// GET - list every workspace the user belongs to (with role + org/workspace name). +router.get('/users/:id/workspaces', requirePlatformAdmin, (req, res) => { + const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); + if (!target) return res.status(404).json({ error: 'User not found' }); + res.json(userMembershipList(req.params.id)); +}); + +// POST - add the user to a workspace (or update their role if already a member). +router.post('/users/:id/workspaces', requirePlatformAdmin, (req, res) => { + const role = String(req.body?.role || '').trim(); + const workspaceId = String(req.body?.workspaceId || '').trim(); + if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' }); + if (!WORKSPACE_ROLES.includes(role)) { + return res.status(400).json({ error: 'Role must be workspace_admin, workspace_editor, or workspace_viewer' }); + } + const target = db.prepare('SELECT id, email FROM users WHERE id = ?').get(req.params.id); + if (!target) return res.status(404).json({ error: 'User not found' }); + const ws = db.prepare('SELECT id, name, organization_id FROM workspaces WHERE id = ?').get(workspaceId); + if (!ws) return res.status(404).json({ error: 'Workspace not found' }); + req.workspaceId = ws.id; + + const existing = db.prepare('SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(ws.id, target.id); + if (existing) { + db.prepare('UPDATE workspace_members SET role = ? WHERE workspace_id = ? AND user_id = ?').run(role, ws.id, target.id); + } else { + db.prepare('INSERT INTO workspace_members (workspace_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)').run(ws.id, target.id, role, req.user.id); + } + logActivity(req.user.id, 'admin_add_user_workspace', `target: ${target.email}, workspace: ${ws.id}, role: ${role}`, null, getClientIp(req), ws.id); + const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(ws.organization_id); + res.status(existing ? 200 : 201).json({ workspace_id: ws.id, workspace_name: ws.name, organization_name: org?.name || null, role }); +}); + +// PUT - change the user's role in a specific workspace. +router.put('/users/:id/workspaces/:workspaceId', requirePlatformAdmin, (req, res) => { + const role = String(req.body?.role || '').trim(); + if (!WORKSPACE_ROLES.includes(role)) { + return res.status(400).json({ error: 'Role must be workspace_admin, workspace_editor, or workspace_viewer' }); + } + const member = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(req.params.workspaceId, req.params.id); + if (!member) return res.status(404).json({ error: 'Membership not found' }); + db.prepare('UPDATE workspace_members SET role = ? WHERE workspace_id = ? AND user_id = ?').run(role, req.params.workspaceId, req.params.id); + req.workspaceId = req.params.workspaceId; + const target = db.prepare('SELECT email FROM users WHERE id = ?').get(req.params.id); + logActivity(req.user.id, 'admin_set_user_workspace_role', `target: ${target?.email}, workspace: ${req.params.workspaceId}, role: ${role}`, null, getClientIp(req), req.params.workspaceId); + res.json({ workspace_id: req.params.workspaceId, role }); +}); + +// DELETE - remove the user from a workspace. Allowed even if it's their last one +// (they become Unassigned - the no-workspace state from #12). +router.delete('/users/:id/workspaces/:workspaceId', requirePlatformAdmin, (req, res) => { + const member = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(req.params.workspaceId, req.params.id); + if (!member) return res.status(404).json({ error: 'Membership not found' }); + db.prepare('DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?').run(req.params.workspaceId, req.params.id); + req.workspaceId = req.params.workspaceId; + const target = db.prepare('SELECT email FROM users WHERE id = ?').get(req.params.id); + logActivity(req.user.id, 'admin_remove_user_workspace', `target: ${target?.email}, workspace: ${req.params.workspaceId}`, null, getClientIp(req), req.params.workspaceId); + res.json({ success: true }); +}); + module.exports = router; diff --git a/server/test/admin-users.test.js b/server/test/admin-users.test.js index 9f92e99..0b62207 100644 --- a/server/test/admin-users.test.js +++ b/server/test/admin-users.test.js @@ -293,3 +293,56 @@ test('workspace move denied for a non-platform-admin (403)', async () => { assert.equal(op.status, 403); assert.equal(wsRows('u-ws-zero')[0].workspace_id, 'ws-a', 'unchanged by denied calls'); }); + +// ---- Per-user multi-workspace membership management (Manage workspaces modal) ---- +function ws(method, userId, token, { workspaceId, role, suffix = '' } = {}) { + return fetch(base + `/api/admin/users/${userId}/workspaces${suffix}`, { + method, + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + ...(workspaceId || role ? { body: JSON.stringify({ workspaceId, role }) } : {}), + }); +} +seedUser({ id: 'u-mgmt', email: 'mgmt@test.local', role: 'user' }); + +test('membership mgmt: add a user to multiple workspaces with per-workspace roles', async () => { + const a = await ws('POST', 'u-mgmt', tokens.admin, { workspaceId: 'ws-a', role: 'workspace_editor' }); + assert.equal(a.status, 201); + const b = await ws('POST', 'u-mgmt', tokens.admin, { workspaceId: 'ws-b', role: 'workspace_viewer' }); + assert.equal(b.status, 201); + const list = await (await ws('GET', 'u-mgmt', tokens.admin)).json(); + assert.equal(list.length, 2, 'user is now in two workspaces'); + assert.deepEqual( + list.map(m => [m.workspace_id, m.role]).sort(), + [['ws-a', 'workspace_editor'], ['ws-b', 'workspace_viewer']].sort() + ); + assert.ok(list[0].workspace_name && list[0].organization_name, 'list carries names for the picker/summary'); +}); + +test('membership mgmt: change role in one workspace', async () => { + const r = await ws('PUT', 'u-mgmt', tokens.admin, { role: 'workspace_admin', suffix: '/ws-a' }); + assert.equal(r.status, 200); + assert.equal(db.prepare("SELECT role FROM workspace_members WHERE user_id='u-mgmt' AND workspace_id='ws-a'").get().role, 'workspace_admin'); +}); + +test('membership mgmt: re-adding an existing workspace updates the role (upsert, 200)', async () => { + const r = await ws('POST', 'u-mgmt', tokens.admin, { workspaceId: 'ws-a', role: 'workspace_viewer' }); + assert.equal(r.status, 200); + assert.equal(db.prepare("SELECT role FROM workspace_members WHERE user_id='u-mgmt' AND workspace_id='ws-a'").get().role, 'workspace_viewer'); +}); + +test('membership mgmt: remove memberships, including the last one (-> unassigned)', async () => { + assert.equal((await ws('DELETE', 'u-mgmt', tokens.admin, { suffix: '/ws-a' })).status, 200); + assert.equal((await ws('DELETE', 'u-mgmt', tokens.admin, { suffix: '/ws-b' })).status, 200); // last one allowed + assert.equal((await (await ws('GET', 'u-mgmt', tokens.admin)).json()).length, 0, 'user now unassigned'); +}); + +test('membership mgmt: bad role 400, missing workspace 404, unknown user 404', async () => { + assert.equal((await ws('POST', 'u-mgmt', tokens.admin, { workspaceId: 'ws-a', role: 'org_admin' })).status, 400); + assert.equal((await ws('POST', 'u-mgmt', tokens.admin, { workspaceId: 'ws-missing', role: 'workspace_viewer' })).status, 404); + assert.equal((await ws('GET', 'nobody', tokens.admin)).status, 404); +}); + +test('membership mgmt: non-platform-admin denied (403)', async () => { + assert.equal((await ws('GET', 'u-mgmt', tokens.regular)).status, 403); + assert.equal((await ws('POST', 'u-mgmt', tokens.operator, { workspaceId: 'ws-a', role: 'workspace_viewer' })).status, 403); +});