mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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:
parent
9aae64c47a
commit
400872f8ea
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue