screentinker/frontend/js/components/admin-user-workspaces-modal.js
ScreenTinker 2872b883c7 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).
2026-06-08 16:24:52 -05:00

164 lines
7.8 KiB
JavaScript

// "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>`;
}
})();
}