feat(admin): manage a user's workspace memberships (multi + per-workspace role)

The Workspace column on the platform Users page could only move a 0/1-workspace
user and showed a dead "N workspaces" label for multi-membership users. Replace
it with a "Manage workspaces" modal that handles the full picture.

Backend (routes/admin.js, requirePlatformAdmin):
- GET    /api/admin/users/:id/workspaces            list memberships (+org/ws names, role)
- POST   /api/admin/users/:id/workspaces            add to a workspace (upsert role)
- PUT    /api/admin/users/:id/workspaces/:wsId      change role in a workspace
- DELETE /api/admin/users/:id/workspaces/:wsId      remove (last one allowed -> unassigned)
Roles validated against WORKSPACE_ROLES; each mutation writes an audit row.

Frontend:
- Workspace cell is now a summary (Unassigned / <name> / N workspaces /
  "Platform (all)" for staff) + a Manage button.
- New admin-user-workspaces-modal: lists every membership with an inline role
  dropdown + Remove, plus a type-to-filter "Add to workspace" picker (org-grouped,
  excludes current memberships) with a role select. Staff get a note that they
  already have platform-wide access. Refreshes the table on close if changed.
- Removed the old single-select inline move control (superseded by the modal).

Tests: 6 added (add to multiple workspaces, per-workspace role change, upsert,
remove incl. last->unassigned, validation 400/404, non-platform-admin 403).
Full suite 33/33. Verified headless: Manage opens, lists memberships, filtered
picker, add/role-change/remove round-trips persist (throwaway user, cleaned up).
This commit is contained in:
ScreenTinker 2026-06-08 16:24:52 -05:00
parent 66c95bb331
commit 2872b883c7
6 changed files with 343 additions and 50 deletions

View file

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

View file

@ -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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function roleOptions(selected) {
return WORKSPACE_ROLES.map(r => `<option value="${r}"${r === selected ? ' selected' : ''}>${esc(t('members.role.' + r))}</option>`).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 = `
<div class="modal">
<div class="modal-header">
<h3>${t('manage_ws.title', { user: esc(user.name || user.email) })}</h3>
<button class="btn-icon" type="button" data-mws-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
${isStaff ? `<p style="font-size:12px;color:var(--text-muted);background:var(--bg-input);padding:8px 10px;border-radius:6px;margin-bottom:12px">${t('manage_ws.staff_note')}</p>` : ''}
<h4 style="font-size:14px;margin:0 0 8px">${t('manage_ws.current')}</h4>
<div id="mwsList" style="color:var(--text-muted);font-size:13px">${t('common.loading')}</div>
<h4 style="font-size:14px;margin:16px 0 8px">${t('manage_ws.add')}</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input id="mwsFilter" type="text" class="input" placeholder="${t('manage_ws.filter')}" style="flex:1;min-width:150px" autocomplete="off">
<select id="mwsAddWs" class="input" style="flex:2;min-width:170px"></select>
<select id="mwsAddRole" class="input" style="width:auto">${roleOptions('workspace_viewer')}</select>
<button class="btn btn-secondary" type="button" id="mwsAddBtn">${t('manage_ws.add_btn')}</button>
</div>
<div id="mwsError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="button" data-mws-close>${t('manage_ws.done')}</button>
</div>
</div>
`;
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 = `<option value="">${esc(t('manage_ws.pick'))}</option>`;
let curOrg = null;
for (const w of avail) {
const org = w.organization_name || '—';
if (org !== curOrg) { if (curOrg !== null) html += '</optgroup>'; html += `<optgroup label="${esc(org)}">`; curOrg = org; }
html += `<option value="${esc(w.id)}">${esc(w.name)}</option>`;
}
if (curOrg !== null) html += '</optgroup>';
addWsEl.innerHTML = html;
}
function renderList() {
if (!memberships.length) {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${t('manage_ws.empty')}</p>`;
return;
}
listEl.innerHTML = memberships.map(m => `
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
<div style="flex:1;min-width:0">
<div style="font-weight:500">${esc(m.workspace_name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(m.organization_name || '')}</div>
</div>
<select class="input" style="width:auto;font-size:12px;padding:4px;background:var(--bg-input)" data-mws-role="${esc(m.workspace_id)}">${roleOptions(m.role)}</select>
<button class="btn btn-danger btn-sm" type="button" data-mws-remove="${esc(m.workspace_id)}">${t('manage_ws.remove')}</button>
</div>
`).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 = `<p style="color:var(--danger);font-size:13px">${esc(e.message || 'Failed to load')}</p>`;
}
})();
}

View file

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

View file

@ -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 <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;
// 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 <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>`;
}
// Workspace cell: a summary + a "Manage" button that opens the full membership
// modal (add/remove workspaces, set per-workspace role). Manage is offered for
// everyone, including staff (you can grant them explicit memberships too).
function workspaceCell(u) {
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>
<div style="display:flex;align-items:center;gap:8px">
<span style="color:var(--text-muted);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${workspaceSummary(u)}</span>
<button class="btn btn-secondary btn-sm" type="button" data-ws-manage="${esc(u.id)}">${t('admin.workspace.manage')}</button>
</div>
</td>`;
}
@ -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 <optgroup> workspace options ONCE, reuse per row.
const wsOptionsHtml = buildWorkspaceOptions(Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : []);
el.innerHTML = `
<div class="table-wrap">
@ -147,7 +134,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)}
${workspaceCell(u)}
<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>`}
@ -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() });
};
});

View file

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

View file

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