mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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>
82 lines
3.7 KiB
JavaScript
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() {}
|