// Add-User modal (#10). Creates a user account directly with an admin-set // password and assigns them to a workspace + role (admin-provisioning for // instances with no outbound email). Two open modes, ONE shared form: // // openAddUserModal({ id, name }, opts) -> fixed-workspace mode (members view). // No picker; assigns into that workspace. // openAddUserModal(null, opts) -> picker mode (platform Users admin page). // Shows an Org/Workspace picker; the admin // chooses the target workspace. // // opts.onSuccess: (result) => void - fires on 201 (server response body) // opts.mapError: (err) => string - translates server error to display text import { api } from '../api.js'; import { t } from '../i18n.js'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // Roles the picker offers. This is the SET POST /api/admin/users accepts // (server: routes/admin.js WORKSPACE_ROLES) - keep them in sync so we never // offer a value the endpoint 400s (the platform_operator dropdown/endpoint // mismatch we already hit). Order here is display order (least-privilege first // = the default selection); the server validates set membership, not order. const WORKSPACE_ROLES = ['workspace_viewer', 'workspace_editor', 'workspace_admin']; // Crockford-ish readable random password: avoids ambiguous chars (0/O, 1/l/I). function generatePassword(len = 16) { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; const arr = new Uint32Array(len); crypto.getRandomValues(arr); let out = ''; for (let i = 0; i < len; i++) out += alphabet[arr[i] % alphabet.length]; return out; } function wsLabel(w) { return `${w.organization_name || '—'} / ${w.name}`; } export function openAddUserModal(workspace, opts = {}) { const { onSuccess, mapError } = opts; // Picker mode whenever no concrete target workspace was supplied. const pickerMode = !(workspace && workspace.id); const title = pickerMode ? t('members.modal.add_user_title_generic') : t('members.modal.add_user_title', { workspace: esc(workspace.name) }); const roleOptions = WORKSPACE_ROLES .map(r => ``) .join(''); // Workspace picker block — only rendered in picker mode. A filter input above // a ` : ''; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const emailInput = overlay.querySelector('#addUserEmail'); const nameInput = overlay.querySelector('#addUserName'); const pwInput = overlay.querySelector('#addUserPassword'); const genBtn = overlay.querySelector('#addUserGenerate'); const roleSelect = overlay.querySelector('#addUserRole'); const mustChange = overlay.querySelector('#addUserMustChange'); const errorEl = overlay.querySelector('#addUserError'); const submitBtn = overlay.querySelector('#addUserSubmit'); const wsSelect = overlay.querySelector('#addUserWs'); // null in fixed mode const wsFilter = overlay.querySelector('#addUserWsFilter'); emailInput.focus(); // Picker mode: load the workspaces this platform_admin can assign into from // /me's accessible_workspaces (already org+name shaped, all workspaces for a // platform_admin). Filter input rebuilds the option list live. let allWs = []; function renderWsOptions(filter) { const f = (filter || '').trim().toLowerCase(); const matches = f ? allWs.filter(w => wsLabel(w).toLowerCase().includes(f)) : allWs; wsSelect.innerHTML = `` + matches.map(w => ``).join(''); } if (pickerMode) { api.getMe() .then(me => { allWs = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces.slice() : []; if (!allWs.length) { wsSelect.innerHTML = ``; return; } renderWsOptions(''); }) .catch(() => { wsSelect.innerHTML = ``; }); wsFilter.addEventListener('input', () => renderWsOptions(wsFilter.value)); } function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } function onKey(e) { if (e.key === 'Escape') close(); } document.addEventListener('keydown', onKey); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.querySelectorAll('[data-add-close]').forEach(b => b.addEventListener('click', close)); genBtn.addEventListener('click', () => { pwInput.value = generatePassword(); pwInput.type = 'text'; }); async function submit() { errorEl.style.display = 'none'; const email = emailInput.value.trim().toLowerCase(); const name = nameInput.value.trim(); const password = pwInput.value; const role = roleSelect.value; const workspaceId = pickerMode ? (wsSelect.value || '') : workspace.id; if (!email || !EMAIL_RE.test(email)) { showError(t('members.error.invalid_email')); emailInput.focus(); return; } if (!password || password.length < 8) { showError(t('members.error.password_min_8')); pwInput.focus(); return; } if (pickerMode && !workspaceId) { showError(t('members.modal.workspace_required')); (wsFilter || wsSelect).focus(); return; } submitBtn.disabled = true; submitBtn.textContent = t('members.modal.creating'); try { const result = await api.adminCreateUser({ email, name, password, role, workspaceId, mustChangePassword: mustChange.checked, }); close(); if (typeof onSuccess === 'function') { try { onSuccess(result); } catch (e) { console.error('add-user modal onSuccess threw:', e); } } } catch (err) { submitBtn.disabled = false; submitBtn.textContent = t('members.modal.create'); const msg = (typeof mapError === 'function') ? mapError(err) : (err?.message || t('members.error.mutation_generic', { error: '' })); showError(msg); } } function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; } submitBtn.addEventListener('click', submit); } function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }