screentinker/frontend/js/views/force-password-change.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

82 lines
3.7 KiB
JavaScript

// #10: forced first-login password change. When an admin provisions a user
// with must_change_password=1, route() in app.js redirects them here and blocks
// every other view until they set a new password. Reuses the same PUT /api/auth/me
// path as the Settings change-password form; on success the server clears
// must_change_password, we refresh the cached user, and return to the app.
import { api } from '../api.js';
import { t } from '../i18n.js';
import { showToast } from '../components/toast.js';
export async function render(container) {
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
<div style="width:400px;max-width:100%">
<div style="text-align:center;margin-bottom:24px">
<h1 style="font-size:22px;font-weight:700;color:var(--accent)">${t('forcepw.title')}</h1>
<p style="color:var(--text-secondary);font-size:13px;margin-top:6px">${t('forcepw.subtitle')}</p>
</div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
<div class="form-group">
<label>${t('forcepw.current')}</label>
<input type="password" id="fpwCurrent" class="input" autocomplete="current-password">
</div>
<div class="form-group">
<label>${t('forcepw.new')}</label>
<input type="password" id="fpwNew" class="input" autocomplete="new-password">
</div>
<div class="form-group">
<label>${t('forcepw.confirm')}</label>
<input type="password" id="fpwConfirm" class="input" autocomplete="new-password">
</div>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('forcepw.hint')}</p>
<button class="btn btn-primary" id="fpwSubmit" style="width:100%;justify-content:center;padding:10px">${t('forcepw.submit')}</button>
<p id="fpwError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
</div>
</div>
</div>
`;
const current = container.querySelector('#fpwCurrent');
const next = container.querySelector('#fpwNew');
const confirm = container.querySelector('#fpwConfirm');
const submit = container.querySelector('#fpwSubmit');
const errorEl = container.querySelector('#fpwError');
current.focus();
const showError = (msg) => { errorEl.textContent = msg; errorEl.style.display = 'block'; };
async function doChange() {
errorEl.style.display = 'none';
const cur = current.value;
const nw = next.value;
const cf = confirm.value;
if (!cur || !nw) { showError(t('forcepw.error_required')); return; }
if (nw.length < 8) { showError(t('forcepw.error_min8')); return; }
if (nw !== cf) { showError(t('forcepw.error_mismatch')); return; }
submit.disabled = true;
submit.textContent = t('forcepw.submitting');
try {
await api.updateMe({ password: nw, current_password: cur });
// Refresh the cached user so the (now-cleared) must_change_password flag
// is reflected, then return to the app.
try {
const fresh = await api.getMe();
localStorage.setItem('user', JSON.stringify(fresh));
} catch { /* fall through; reload re-fetches */ }
showToast(t('forcepw.success'), 'success');
window.location.hash = '#/';
window.location.reload();
} catch (err) {
submit.disabled = false;
submit.textContent = t('forcepw.submit');
showError(err?.message || t('forcepw.error_generic'));
}
}
submit.addEventListener('click', doChange);
[current, next, confirm].forEach(el => el.addEventListener('keydown', (e) => { if (e.key === 'Enter') doChange(); }));
}
export function cleanup() {}