mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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>
194 lines
9.5 KiB
JavaScript
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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
}
|