mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 10:43:36 -06:00
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).
164 lines
7.8 KiB
JavaScript
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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>`;
|
|
}
|
|
})();
|
|
}
|