screentinker/frontend/js/components/workspace-members-add-user-modal.js
ScreenTinker 6e31770cee feat(admin): admin-provisioned user creation + first-login gate (#10)
Adds POST /api/admin/users so an admin can create a user directly with a
known password and assign them to a workspace + role - for self-hosted
instances with no outbound email, where invites never deliver.

Server (routes/admin.js, mounted /api/admin with requireAuth + activityLogger):
- Gated by canAdminWorkspace(db, req.user, targetWorkspace): 404 if the
  workspace is missing, 403 if not an admin of it. This scopes org_admins
  to their own org and excludes platform_operator (no user/role mgmt, #13).
- Validates email (invite-create regex), role in WORKSPACE_ROLES, password
  min-8 (the /me rule). 409 on duplicate email - never overwrites.
- One transaction: global users row (auth_provider 'local',
  bcrypt.hashSync(pw,10), must_change_password from the flag) + a
  workspace_members row written inline (same footprint as an accepted
  invite; accept-invite left untouched).
- Explicit audit row admin_create_user; never logs the password; response
  excludes password/hash.
- HOSTED_INSTANCE: never calls sendSignupEmails and stamps both
  welcome_email_sent_at / activation_nudge_sent_at, so an admin-created
  user gets no welcome email and never enters the activation-nudge sweep.

must_change_password (frontend-first enforcement, per spec):
- Migration adds users.must_change_password INTEGER NOT NULL DEFAULT 0;
  surfaced via requireAuth + /me + login responses.
- route() in app.js forces users with the flag to a #/change-password
  screen (new force-password-change view, reuses PUT /api/auth/me) and
  blocks every other view until set. The /me update clears the flag.

Frontend: "Add User" button beside "Invite member" in the members view
(admin-only) opening a modal (email, name, password + generate, role,
must-change checkbox); invite and Add User coexist. api.adminCreateUser;
EN i18n only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:03:56 -05:00

134 lines
6.4 KiB
JavaScript

// 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.
//
// 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';
import { t } from '../i18n.js';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 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;
}
export function openAddUserModal(workspace, opts = {}) {
const { onSuccess, mapError } = opts;
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>
<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>
<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>
</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');
emailInput.focus();
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;
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; }
submitBtn.disabled = true;
submitBtn.textContent = t('members.modal.creating');
try {
const result = await api.adminCreateUser({
email, name, password, role,
workspaceId: workspace.id,
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]));
}