mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
Merge pull request #23 from screentinker/feat/admin-user-workspace-mgmt
feat(admin): manage a user's workspace memberships (multi + per-workspace role)
This commit is contained in:
commit
0f84cac440
|
|
@ -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' }),
|
||||
|
|
|
|||
163
frontend/js/components/admin-user-workspaces-modal.js
Normal file
163
frontend/js/components/admin-user-workspaces-modal.js
Normal 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>`;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue