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>
This commit is contained in:
ScreenTinker 2026-06-05 14:40:31 -05:00
parent 9aae64c47a
commit 400872f8ea
3 changed files with 101 additions and 11 deletions

View file

@ -1,10 +1,13 @@
// Add-User modal (#10). Sits next to the invite modal in the members view, but
// instead of emailing an invite it creates a user account directly with an
// admin-set password and assigns them to this workspace + role. For
// admin-provisioning on instances with no outbound email (where invites never
// deliver). Mirrors workspace-members-invite-modal.js's structure.
// 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.
//
// workspace: { id, name }
// 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';
@ -12,6 +15,13 @@ 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';
@ -22,14 +32,40 @@ function generatePassword(len = 16) {
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>${t('members.modal.add_user_title', { workspace: esc(workspace.name) })}</h3>
<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"/>
@ -52,12 +88,11 @@ export function openAddUserModal(workspace, opts = {}) {
<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%">
<option value="workspace_viewer">${t('members.role.workspace_viewer')}</option>
<option value="workspace_editor">${t('members.role.workspace_editor')}</option>
<option value="workspace_admin">${t('members.role.workspace_admin')}</option>
${roleOptions}
</select>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
@ -82,8 +117,31 @@ export function openAddUserModal(workspace, opts = {}) {
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);
@ -97,15 +155,17 @@ export function openAddUserModal(workspace, opts = {}) {
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: workspace.id,
workspaceId,
mustChangePassword: mustChange.checked,
});
close();

View file

@ -783,6 +783,7 @@ export default {
// Admin (platform admin panel)
'admin.title': 'Platform Admin',
'admin.subtitle': 'Superadmin controls - only you can see this',
'admin.add_user': 'Add user',
'admin.access_denied': 'Access Denied',
'admin.access_denied_desc': 'Platform admin access required.',
'admin.all_users': 'All Users',
@ -1161,6 +1162,15 @@ export default {
// Modal — Add User form (#10, admin-provisioned account)
'members.modal.add_user_title': 'Add user to {workspace}',
'members.modal.add_user_title_generic': 'Add user',
// Add User picker mode (platform Users admin page): choose the target workspace.
'members.modal.workspace_label': 'Organization / Workspace',
'members.modal.workspace_filter_placeholder': 'Filter workspaces…',
'members.modal.workspace_placeholder': 'Select a workspace…',
'members.modal.workspace_loading': 'Loading workspaces…',
'members.modal.workspace_none': 'No workspaces available',
'members.modal.workspace_load_error': 'Failed to load workspaces',
'members.modal.workspace_required': 'Please select a workspace.',
'members.modal.name_label': 'Name',
'members.modal.name_placeholder': 'Full name (optional)',
'members.modal.password_label': 'Password',

View file

@ -2,6 +2,11 @@ import { api } from '../api.js';
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';
// 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.
import { mapMutationError } from './workspace-members.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
@ -22,6 +27,7 @@ export async function render(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>${t('admin.title')}</h1><div class="subtitle">${t('admin.subtitle')}</div></div>
<button class="btn btn-primary" id="adminAddUserBtn">${t('admin.add_user')}</button>
</div>
<div class="settings-section">
@ -40,6 +46,20 @@ export async function render(container) {
</div>
`;
// Add User (#10): platform admin provisions a user into ANY workspace. The
// page is platform_admin-gated; the modal opens in picker mode (no fixed
// workspace) so the admin chooses the target org/workspace. The endpoint
// additionally enforces canAdminWorkspace (platform_admin passes everywhere).
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
openAddUserModal(null, {
onSuccess: (result) => {
showToast(t('members.success.user_created', { email: result.email }), 'success');
loadUsers();
},
mapError: mapMutationError,
});
});
loadUsers();
loadPlans();
loadSystem();