From 7615eabdd5067c4763e603494c758eeb9ac89f92 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 8 Jun 2026 10:21:41 -0500 Subject: [PATCH] feat(admin): Workspace column + inline move/assign on the Users page Adds a "Workspace" column (after Plan) to the platform Users admin table so a platform_admin can see and reassign a user's workspace inline, alongside the Role/Plan dropdowns. Single-workspace move/assign model. Backend: - GET /api/auth/users (platform branch): one aggregate query adds workspace_count and, for exactly-one membership, the workspace id/name + org name (no N+1). - PUT /api/admin/users/:id/workspace (requirePlatformAdmin - operator excluded): move (1 membership) or assign (0) into the chosen workspace, default role workspace_viewer, in a transaction; no-op if already there; REFUSES (400) a user with >1 membership (manage in the members view). logActivity admin_set_user_workspace. Frontend (admin.js): - Editable only for a 'user' with 0 or +// 1 membership; multi-membership users and platform staff render read-only. +function workspaceCell(u, optionsHtml) { + if (isPlatformStaffRole(u.role)) { + return `${t('admin.workspace.platform_all')}`; + } + const count = u.workspace_count || 0; + if (count > 1) { + return `${t('admin.workspace.multi', { n: count })}`; + } + return ` + + `; +} + export async function render(container) { const user = JSON.parse(localStorage.getItem('user') || '{}'); if (!isPlatformAdmin(user)) { @@ -69,8 +110,14 @@ export async function render(container) { async function loadUsers() { const el = document.getElementById('allUsersTable'); try { - const [users, plans] = await Promise.all([API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json())]); + const [users, plans, me] = 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 = `
@@ -81,6 +128,7 @@ async function loadUsers() { ${t('admin.col.last_login')} ${t('admin.col.role')} ${t('admin.col.plan')} + ${t('admin.col.workspace')} ${t('admin.col.actions')} @@ -99,6 +147,7 @@ async function loadUsers() { ${plans.map(p => ``).join('')} + ${workspaceCell(u, wsOptionsHtml)} ${u.auth_provider === 'local' && u.id !== currentUser.id ? `` : ''} ${!isPlatformAdmin(u) ? `` : `${t('admin.owner')}`} @@ -129,6 +178,25 @@ 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(); } + }; + }); + // Reset password handlers el.querySelectorAll('[data-reset-pw-user]').forEach(btn => { btn.onclick = async () => { diff --git a/server/routes/admin.js b/server/routes/admin.js index f20c45e..38792f1 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -4,6 +4,7 @@ const bcrypt = require('bcryptjs'); const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); const { canAdminWorkspace } = require('../lib/permissions'); +const { requirePlatformAdmin } = require('../middleware/auth'); const { logActivity, getClientIp } = require('../services/activity'); // Admin-provisioned user creation (#10). Operates on a target workspace @@ -99,4 +100,51 @@ router.post('/users', (req, res) => { res.status(201).json({ ...created, workspace_id: ws.id, workspace_role: role }); }); +// PUT /api/admin/users/:id/workspace - move/assign a SINGLE-workspace user to a +// different workspace (platform Users admin page). Platform-admin only: this is +// a cross-org, platform-level action (requirePlatformAdmin excludes +// platform_operator, mirroring the page gating). +// +// Single-workspace model: refuses (400) a user who belongs to >1 workspace - +// a single pick must never silently clobber multiple memberships; those are +// managed in the workspace members view. Mirrors the frontend guard. +router.put('/users/:id/workspace', requirePlatformAdmin, (req, res) => { + const workspaceId = String(req.body?.workspaceId || '').trim(); + if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' }); + + 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 memberships = db.prepare('SELECT workspace_id FROM workspace_members WHERE user_id = ?').all(target.id); + if (memberships.length > 1) { + return res.status(400).json({ error: 'User belongs to multiple workspaces - manage in the workspace members view' }); + } + + 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' }); + + const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(ws.organization_id); + + // No-op if the chosen workspace is already their sole membership (preserve role). + if (memberships.length === 1 && memberships[0].workspace_id === ws.id) { + const cur = db.prepare('SELECT role FROM workspace_members WHERE user_id = ? AND workspace_id = ?').get(target.id, ws.id); + return res.json({ user_id: target.id, workspace_id: ws.id, workspace_name: ws.name, organization_name: org?.name || null, role: cur ? cur.role : 'workspace_viewer', unchanged: true }); + } + + req.workspaceId = ws.id; // audit attribution + // Move (drop the existing single membership) or assign (none to drop), then + // add the chosen one at the default role. Guarded above to <=1 membership, so + // the DELETE removes at most one row. + const txn = db.transaction(() => { + db.prepare('DELETE FROM workspace_members WHERE user_id = ?').run(target.id); + db.prepare('INSERT INTO workspace_members (workspace_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)') + .run(ws.id, target.id, 'workspace_viewer', req.user.id); + }); + txn(); + + logActivity(req.user.id, 'admin_set_user_workspace', `target: ${target.email}, workspace: ${ws.id}`, null, getClientIp(req), ws.id); + + res.json({ user_id: target.id, workspace_id: ws.id, workspace_name: ws.name, organization_name: org?.name || null, role: 'workspace_viewer' }); +}); + module.exports = router; diff --git a/server/routes/auth.js b/server/routes/auth.js index dab360e..03b7299 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -453,7 +453,24 @@ router.put('/me', requireAuth, (req, res) => { // List users - platform admins see all, admins see team members only router.get('/users', requireAuth, requireAdmin, (req, res) => { if (PLATFORM_ROLES.includes(req.user.role)) { - const users = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, created_at, last_login FROM users ORDER BY created_at ASC').all(); + // One aggregate query (no N+1): each user carries workspace_count, and for + // an exactly-one membership the single workspace id/name + org name (used by + // the admin Users page Workspace column). MAX() over a single grouped row + // yields that row's values; the CASE blanks them when count != 1 so we never + // surface a single workspace name for a multi-membership user. + const users = db.prepare(` + SELECT u.id, u.email, u.name, u.role, u.auth_provider, u.avatar_url, u.plan_id, u.created_at, u.last_login, + COUNT(wm.workspace_id) AS workspace_count, + CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(w.id) END AS workspace_id, + CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(w.name) END AS workspace_name, + CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(o.name) END AS organization_name + FROM users u + LEFT JOIN workspace_members wm ON wm.user_id = u.id + LEFT JOIN workspaces w ON w.id = wm.workspace_id + LEFT JOIN organizations o ON o.id = w.organization_id + GROUP BY u.id + ORDER BY u.created_at ASC + `).all(); res.json(users); } else { // Admin sees themselves + users in their teams diff --git a/server/test/admin-users.test.js b/server/test/admin-users.test.js index a0c6240..9f92e99 100644 --- a/server/test/admin-users.test.js +++ b/server/test/admin-users.test.js @@ -70,6 +70,9 @@ db.exec(` joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), UNIQUE(workspace_id, user_id) ); + CREATE TABLE organizations ( + id TEXT PRIMARY KEY, name TEXT NOT NULL + ); CREATE TABLE activity_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, @@ -99,6 +102,7 @@ const adminRouter = require('../routes/admin'); const authRouter = require('../routes/auth'); // --- seed orgs/workspaces/users --- +db.prepare("INSERT INTO organizations (id, name) VALUES ('org-a','Org A'),('org-b','Org B')").run(); db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-a','org-a','Workspace A')").run(); db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-b','org-b','Workspace B')").run(); @@ -116,6 +120,14 @@ const regular = seedUser({ id: 'u-regular', email: 'regular@test.local', role: ' // can't perturb the non-admin/operator tokens used by the deny tests above). seedUser({ id: 'u-role-target', email: 'role-target@test.local', role: 'user' }); +// Workspace move/assign targets (PUT /api/admin/users/:id/workspace). +seedUser({ id: 'u-ws-single', email: 'ws-single@test.local', role: 'user' }); +db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws-a','u-ws-single','workspace_editor')").run(); +seedUser({ id: 'u-ws-zero', email: 'ws-zero@test.local', role: 'user' }); +seedUser({ id: 'u-ws-multi', email: 'ws-multi@test.local', role: 'user' }); +db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws-a','u-ws-multi','workspace_viewer')").run(); +db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws-b','u-ws-multi','workspace_viewer')").run(); + const tokens = { admin: generateToken(adminUser, null), orgAdminA: generateToken(orgAdminA, 'ws-a'), @@ -238,3 +250,46 @@ test('platform_operator is assignable via PUT /users/:id/role (regression for #1 const dbRole = db.prepare('SELECT role FROM users WHERE id = ?').get('u-role-target').role; assert.equal(dbRole, 'platform_operator', 'role actually persisted as platform_operator'); }); + +// ---- PUT /api/admin/users/:id/workspace (move / assign single workspace) ---- +function setWorkspace(userId, workspaceId, token) { + return fetch(base + `/api/admin/users/${userId}/workspace`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ workspaceId }), + }); +} +const wsRows = id => db.prepare('SELECT workspace_id, role FROM workspace_members WHERE user_id = ?').all(id); + +test('workspace move: single-membership user moved to another workspace (200, membership changed)', async () => { + const res = await setWorkspace('u-ws-single', 'ws-b', tokens.admin); + assert.equal(res.status, 200); + const rows = wsRows('u-ws-single'); + assert.equal(rows.length, 1, 'still exactly one membership'); + assert.equal(rows[0].workspace_id, 'ws-b', 'moved to ws-b'); + assert.equal(rows[0].role, 'workspace_viewer', 'default role on move'); +}); + +test('workspace assign: zero-membership user assigned a workspace (200)', async () => { + const res = await setWorkspace('u-ws-zero', 'ws-a', tokens.admin); + assert.equal(res.status, 200); + const rows = wsRows('u-ws-zero'); + assert.equal(rows.length, 1); + assert.equal(rows[0].workspace_id, 'ws-a'); + assert.equal(rows[0].role, 'workspace_viewer'); +}); + +test('workspace move REFUSED for a multi-membership user (400, untouched)', async () => { + const res = await setWorkspace('u-ws-multi', 'ws-a', tokens.admin); + assert.equal(res.status, 400); + assert.equal(wsRows('u-ws-multi').length, 2, 'both memberships preserved'); +}); + +test('workspace move denied for a non-platform-admin (403)', async () => { + const reg = await setWorkspace('u-ws-zero', 'ws-b', tokens.regular); + assert.equal(reg.status, 403); + // platform_operator is also denied (platform user-mgmt is owner-only) + const op = await setWorkspace('u-ws-zero', 'ws-b', tokens.operator); + assert.equal(op.status, 403); + assert.equal(wsRows('u-ws-zero')[0].workspace_id, 'ws-a', 'unchanged by denied calls'); +});