screentinker/frontend/js/components/workspace-members-add-user-modal.js
ScreenTinker 400872f8ea feat(admin): Add User from the platform Users page (workspace picker)
Extends the shared add-user modal (workspace-members-add-user-modal.js) with
an optional picker mode instead of forking a second form:
- opened with a fixed workspace (members view) -> unchanged, no picker;
- opened with null (platform Users admin page) -> shows an Org/Workspace
  picker (type-to-filter over /me's accessible_workspaces, labelled
  "org / workspace") plus the role select; email/name/password+generate/
  must-change/error-mapping stay shared.

Role options are rendered from a single WORKSPACE_ROLES constant that mirrors
the set POST /api/admin/users accepts (routes/admin.js) - so we never offer a
value the endpoint 400s (the platform_operator mismatch we already hit).
org_admin is intentionally NOT offered: the endpoint accepts only the three
workspace roles.

admin.js: "Add user" button in the page header (page is already
platform_admin-gated; the endpoint additionally enforces canAdminWorkspace,
which platform_admin passes everywhere). On success -> toast + refresh the
user list. Reuses workspace-members.js's mapMutationError. EN i18n only.

Frontend only - no backend change. Verified headless (Playwright): button
opens the modal, picker lists all 45 workspaces with working filter, role
options = [viewer, editor, admin], and submit created + assigned a user into
the chosen workspace (test row cleaned up afterward). npm test still 12/12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:34:47 -05:00

194 lines
9.5 KiB
JavaScript

// 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 => `<option value="${r}">${esc(t('members.role.' + r))}</option>`)
.join('');
// Workspace picker block — only rendered in picker mode. A filter input above
// a <select> gives type-to-filter for the 70+ workspaces without a dependency.
const workspaceGroup = pickerMode ? `
<div class="form-group">
<label for="addUserWs">${t('members.modal.workspace_label')}</label>
<input id="addUserWsFilter" type="text" class="input" placeholder="${t('members.modal.workspace_filter_placeholder')}" style="width:100%;margin-bottom:6px" autocomplete="off" autocapitalize="off" spellcheck="false">
<select id="addUserWs" class="input" style="width:100%">
<option value="">${t('members.modal.workspace_loading')}</option>
</select>
</div>` : '';
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>${title}</h3>
<button class="btn-icon" type="button" data-add-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">
<div class="form-group">
<label for="addUserEmail">${t('members.modal.email_label')}</label>
<input id="addUserEmail" type="email" class="input" placeholder="${t('members.modal.email_placeholder')}" style="width:100%" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-group">
<label for="addUserName">${t('members.modal.name_label')}</label>
<input id="addUserName" type="text" class="input" placeholder="${t('members.modal.name_placeholder')}" style="width:100%" autocomplete="off">
</div>
<div class="form-group">
<label for="addUserPassword">${t('members.modal.password_label')}</label>
<div style="display:flex;gap:8px">
<input id="addUserPassword" type="text" class="input" placeholder="${t('members.modal.password_placeholder')}" style="flex:1" autocomplete="off" autocapitalize="off" spellcheck="false">
<button class="btn btn-secondary" type="button" id="addUserGenerate" style="white-space:nowrap">${t('members.modal.generate')}</button>
</div>
</div>
${workspaceGroup}
<div class="form-group">
<label for="addUserRole">${t('members.modal.role_label')}</label>
<select id="addUserRole" class="input" style="width:100%">
${roleOptions}
</select>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input id="addUserMustChange" type="checkbox" checked>
${t('members.modal.must_change_label')}
</label>
<div id="addUserError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-add-close>${t('members.modal.cancel')}</button>
<button class="btn btn-primary" type="button" id="addUserSubmit">${t('members.modal.create')}</button>
</div>
</div>
`;
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 = `<option value="">${esc(t('members.modal.workspace_placeholder'))}</option>`
+ matches.map(w => `<option value="${esc(w.id)}">${esc(wsLabel(w))}</option>`).join('');
}
if (pickerMode) {
api.getMe()
.then(me => {
allWs = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces.slice() : [];
if (!allWs.length) { wsSelect.innerHTML = `<option value="">${esc(t('members.modal.workspace_none'))}</option>`; return; }
renderWsOptions('');
})
.catch(() => { wsSelect.innerHTML = `<option value="">${esc(t('members.modal.workspace_load_error'))}</option>`; });
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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}