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 <select> only for a 'user' with 0/1 membership; multi-membership ->
  read-only "N workspaces", platform staff -> read-only "Platform (all)".
- Options grouped by org via <optgroup>, built ONCE from /me's
  accessible_workspaces (same source as the Add User picker) and reused per row.
- Picking "Unassigned" or the same workspace is a no-op so a stray pick can't
  strip a membership. Success -> toast + refresh. EN i18n only.

Tests: 4 added (single-membership move 200 + changed, zero-membership assign
200, multi-membership 400 refused, non-platform-admin/operator 403). npm test
16/16. Verified headless: column renders, selected value correct, "Platform
(all)" for staff, and a dropdown move persisted (throwaway user, cleaned up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-08 10:21:41 -05:00
parent 65691e26da
commit 7615eabdd5
5 changed files with 196 additions and 2 deletions

View file

@ -794,7 +794,12 @@ export default {
'admin.col.last_login': 'Last Login',
'admin.col.role': 'Role',
'admin.col.plan': 'Plan',
'admin.col.workspace': 'Workspace',
'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.col.devices': 'Devices',
'admin.col.storage': 'Storage',
'admin.col.monthly': 'Monthly',
@ -818,6 +823,7 @@ export default {
'admin.server_status': 'Server Status',
'admin.toast.role_updated': 'Role updated',
'admin.toast.plan_updated': 'Plan updated',
'admin.toast.workspace_updated': 'Workspace updated',
'admin.toast.user_removed': 'User removed',
'admin.reset_password': 'Reset Password',
'admin.prompt_reset_password': 'Enter a new password for {email} (minimum 8 characters):',

View file

@ -17,6 +17,47 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opt
// were normalized away. #13 adds 'platform_operator' (cross-org staff).
const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin'];
// Platform staff have cross-org access (no single workspace), so the Workspace
// column shows read-only "Platform (all)" for them. Note utils.isPlatformAdmin
// only covers admin/superadmin; operators are staff here too.
function isPlatformStaffRole(role) {
return role === 'platform_admin' || role === 'superadmin' || role === 'platform_operator';
}
// Build the org-grouped workspace <option> list ONCE (reused for every editable
// row). Source is /me's accessible_workspaces (already ORDER BY org, name), same
// as the Add User picker. Leading blank = "Unassigned"; selecting it is a no-op.
function buildWorkspaceOptions(list) {
let html = `<option value="">${esc(t('admin.workspace.unassigned'))}</option>`;
let currentOrg = null;
for (const w of list) {
const org = w.organization_name || '—';
if (org !== currentOrg) {
if (currentOrg !== null) html += '</optgroup>';
html += `<optgroup label="${esc(org)}">`;
currentOrg = org;
}
html += `<option value="${esc(w.id)}">${esc(w.name)}</option>`;
}
if (currentOrg !== null) html += '</optgroup>';
return html;
}
// Workspace cell for one user row. Editable <select> 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 `<td style="padding:8px"><span style="color:var(--text-muted);font-size:12px">${t('admin.workspace.platform_all')}</span></td>`;
}
const count = u.workspace_count || 0;
if (count > 1) {
return `<td style="padding:8px"><span style="color:var(--text-muted);font-size:12px" title="${esc(t('admin.workspace.multi_hint'))}">${t('admin.workspace.multi', { n: count })}</span></td>`;
}
return `<td style="padding:8px">
<select class="input" style="max-width:180px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-ws-user="${esc(u.id)}" data-current="${esc(u.workspace_id || '')}">${optionsHtml}</select>
</td>`;
}
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 <optgroup> workspace options ONCE, reuse per row.
const wsOptionsHtml = buildWorkspaceOptions(Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : []);
el.innerHTML = `
<div class="table-wrap">
@ -81,6 +128,7 @@ async function loadUsers() {
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.last_login')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.role')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.plan')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.workspace')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.actions')}</th>
</tr></thead>
<tbody>
@ -99,6 +147,7 @@ async function loadUsers() {
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
</select>
</td>
${workspaceCell(u, wsOptionsHtml)}
<td style="padding:8px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
@ -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 () => {

View file

@ -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;

View file

@ -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

View file

@ -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');
});