mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
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>
This commit is contained in:
parent
48902f6807
commit
6e31770cee
|
|
@ -174,6 +174,10 @@ export const api = {
|
|||
// Slice 2C - accept a workspace invite by id (post-auth flow)
|
||||
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
|
||||
|
||||
// Admin-provisioned user creation (#10). data: { email, name, password,
|
||||
// workspaceId, role, mustChangePassword }
|
||||
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
// Admin - Users
|
||||
getUsers: () => request('/auth/users'),
|
||||
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import * as adminPlayerDebug from './views/admin-player-debug.js';
|
|||
import * as designer from './views/designer.js';
|
||||
import * as playlists from './views/playlists.js';
|
||||
import * as workspaceMembers from './views/workspace-members.js';
|
||||
import * as forcePasswordChange from './views/force-password-change.js';
|
||||
import { applyBranding } from './branding.js';
|
||||
import { t } from './i18n.js';
|
||||
import { isPlatformAdmin } from './utils.js';
|
||||
|
|
@ -276,6 +277,33 @@ function route() {
|
|||
}
|
||||
}
|
||||
|
||||
// #10: forced first-login password change. An admin-provisioned user carries
|
||||
// must_change_password until they set their own password. Block every other
|
||||
// authenticated view and force them to the change-password screen; the server
|
||||
// clears the flag on a successful PUT /api/auth/me. The screen itself is the
|
||||
// one exception (so they can actually change it).
|
||||
if (isAuthenticated()) {
|
||||
const u = getCurrentUser();
|
||||
if (u && u.must_change_password && hash !== '#/change-password') {
|
||||
window.location.hash = '#/change-password';
|
||||
return;
|
||||
}
|
||||
if (hash === '#/change-password') {
|
||||
if (!u || !u.must_change_password) {
|
||||
// Not (or no longer) required - don't strand the user on a dead screen.
|
||||
window.location.hash = '#/';
|
||||
return;
|
||||
}
|
||||
sidebar.style.display = 'none';
|
||||
app.style.marginLeft = '0';
|
||||
const mb = document.getElementById('mobileMenuBtn');
|
||||
if (mb) mb.style.display = 'none';
|
||||
currentView = forcePasswordChange;
|
||||
forcePasswordChange.render(app);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Onboarding for new users
|
||||
if (hash === '#/onboarding' && isAuthenticated()) {
|
||||
sidebar.style.display = 'none';
|
||||
|
|
|
|||
133
frontend/js/components/workspace-members-add-user-modal.js
Normal file
133
frontend/js/components/workspace-members-add-user-modal.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
|
|
@ -1159,9 +1159,21 @@ export default {
|
|||
'members.modal.send': 'Send invite',
|
||||
'members.modal.sending': 'Sending...',
|
||||
|
||||
// Modal — Add User form (#10, admin-provisioned account)
|
||||
'members.modal.add_user_title': 'Add user to {workspace}',
|
||||
'members.modal.name_label': 'Name',
|
||||
'members.modal.name_placeholder': 'Full name (optional)',
|
||||
'members.modal.password_label': 'Password',
|
||||
'members.modal.password_placeholder': 'Set a password',
|
||||
'members.modal.generate': 'Generate',
|
||||
'members.modal.must_change_label': 'Require a password change on first login',
|
||||
'members.modal.create': 'Create user',
|
||||
'members.modal.creating': 'Creating...',
|
||||
|
||||
// Buttons — page header + per-row action affordances (titles double as
|
||||
// ARIA labels for the icon-only buttons).
|
||||
'members.button.invite': 'Invite member',
|
||||
'members.button.add_user': 'Add user',
|
||||
'members.button.remove': 'Remove member',
|
||||
'members.button.cancel_invite': 'Cancel invite',
|
||||
|
||||
|
|
@ -1178,6 +1190,8 @@ export default {
|
|||
'members.error.invalid_email': 'Please enter a valid email address.',
|
||||
'members.error.org_owner_remove': 'Cannot remove the organization owner.',
|
||||
'members.error.email_send_failed': 'Email send failed. Try again.',
|
||||
'members.error.user_exists': 'A user with that email already exists.',
|
||||
'members.error.password_min_8': 'Password must be at least 8 characters.',
|
||||
'members.error.mutation_generic': 'Action failed: {error}',
|
||||
|
||||
// Success toasts fired post-mutation.
|
||||
|
|
@ -1185,6 +1199,23 @@ export default {
|
|||
'members.success.invite_cancelled': 'Invite cancelled',
|
||||
'members.success.role_changed': 'Role updated',
|
||||
'members.success.member_removed': '{name} removed',
|
||||
'members.success.user_created': 'User {email} created',
|
||||
|
||||
// Forced first-login password change (#10). Shown when an admin-provisioned
|
||||
// user (must_change_password) is routed to #/change-password.
|
||||
'forcepw.title': 'Set a new password',
|
||||
'forcepw.subtitle': 'Your account was set up by an administrator. Choose your own password to continue.',
|
||||
'forcepw.current': 'Current password',
|
||||
'forcepw.new': 'New password',
|
||||
'forcepw.confirm': 'Confirm new password',
|
||||
'forcepw.hint': 'At least 8 characters.',
|
||||
'forcepw.submit': 'Set password',
|
||||
'forcepw.submitting': 'Saving...',
|
||||
'forcepw.success': 'Password updated',
|
||||
'forcepw.error_required': 'Enter your current and new password.',
|
||||
'forcepw.error_min8': 'Password must be at least 8 characters.',
|
||||
'forcepw.error_mismatch': "Passwords don't match.",
|
||||
'forcepw.error_generic': 'Could not update password. Try again.',
|
||||
|
||||
// Accept-invite flow (Slice 2C). Toasts that fire post-accept on the
|
||||
// dashboard. Error variants share one helper in app.js's mapAcceptError().
|
||||
|
|
|
|||
81
frontend/js/views/force-password-change.js
Normal file
81
frontend/js/views/force-password-change.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// #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() {}
|
||||
|
|
@ -12,6 +12,7 @@ import { api } from '../api.js';
|
|||
import { t } from '../i18n.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { openInviteMemberModal } from '../components/workspace-members-invite-modal.js';
|
||||
import { openAddUserModal } from '../components/workspace-members-add-user-modal.js';
|
||||
|
||||
export async function render(container, workspaceId) {
|
||||
container.innerHTML = `
|
||||
|
|
@ -62,10 +63,15 @@ export async function render(container, workspaceId) {
|
|||
}
|
||||
}
|
||||
|
||||
// Invite button - admin only, opens modal.
|
||||
// Invite + Add User buttons - admin only. Invite is self-service (emails a
|
||||
// link); Add User (#10) provisions an account directly with an admin-set
|
||||
// password (for instances with no outbound email). They coexist.
|
||||
if (canAdmin) {
|
||||
headerActions.innerHTML = `
|
||||
<button class="btn btn-primary" id="inviteMemberBtn">${t('members.button.invite')}</button>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-secondary" id="addUserBtn">${t('members.button.add_user')}</button>
|
||||
<button class="btn btn-primary" id="inviteMemberBtn">${t('members.button.invite')}</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('inviteMemberBtn').addEventListener('click', () => {
|
||||
openInviteMemberModal({ id: workspaceId, name: workspaceName }, {
|
||||
|
|
@ -76,6 +82,15 @@ export async function render(container, workspaceId) {
|
|||
mapError: mapMutationError,
|
||||
});
|
||||
});
|
||||
document.getElementById('addUserBtn').addEventListener('click', () => {
|
||||
openAddUserModal({ id: workspaceId, name: workspaceName }, {
|
||||
onSuccess: (result) => {
|
||||
showToast(t('members.success.user_created', { email: result.email }), 'success');
|
||||
render(container, workspaceId);
|
||||
},
|
||||
mapError: mapMutationError,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const direct = members.filter(m => !m.via_org);
|
||||
|
|
@ -264,6 +279,9 @@ export function mapMutationError(err) {
|
|||
if (/Cannot demote the last admin/i.test(msg)) return t('members.error.last_admin_demote');
|
||||
if (/Cannot remove the last admin/i.test(msg)) return t('members.error.last_admin_remove');
|
||||
if (/already a member/i.test(msg)) return t('members.error.already_member');
|
||||
// #10 Add User: duplicate email + weak password.
|
||||
if (/user with that email already exists/i.test(msg)) return t('members.error.user_exists');
|
||||
if (/at least 8 characters/i.test(msg)) return t('members.error.password_min_8');
|
||||
if (/Valid email required/i.test(msg)) return t('members.error.invalid_email');
|
||||
if (/Cannot remove the organization owner/i.test(msg)) return t('members.error.org_owner_remove');
|
||||
if (/Email send failed/i.test(msg)) return t('members.error.email_send_failed');
|
||||
|
|
|
|||
|
|
@ -173,6 +173,10 @@ const migrations = [
|
|||
// already in the current model ('user'/'platform_admin'/'platform_operator').
|
||||
"UPDATE users SET role = 'platform_admin' WHERE role = 'superadmin'",
|
||||
"UPDATE users SET role = 'user' WHERE role = 'admin'",
|
||||
// Issue #10: admin-provisioned users. When an admin creates a user with a
|
||||
// known password, must_change_password=1 forces a password change on first
|
||||
// login. Default 0 so all existing users are unaffected.
|
||||
"ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0",
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { db.exec(sql); } catch (e) { /* already exists */ }
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ function requireAuth(req, res, next) {
|
|||
req.jwtWorkspaceId = null;
|
||||
return next();
|
||||
}
|
||||
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(decoded.id);
|
||||
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id);
|
||||
if (!user) return res.status(401).json({ error: 'User not found' });
|
||||
req.user = user;
|
||||
// Tenancy middleware reads this on the resolver step.
|
||||
|
|
|
|||
102
server/routes/admin.js
Normal file
102
server/routes/admin.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db } = require('../db/database');
|
||||
const { canAdminWorkspace } = require('../lib/permissions');
|
||||
const { logActivity, getClientIp } = require('../services/activity');
|
||||
|
||||
// Admin-provisioned user creation (#10). Operates on a target workspace
|
||||
// specified in the body, NOT the caller's active workspace - so this router is
|
||||
// mounted with requireAuth only (no resolveTenancy), mirroring routes/workspaces.js.
|
||||
// Permission is gated per-handler via canAdminWorkspace() against the TARGET
|
||||
// workspace, which:
|
||||
// - lets a platform_admin create users anywhere,
|
||||
// - scopes an org_admin / org_owner to workspaces in orgs they administer,
|
||||
// - and excludes platform_operator (isPlatformRole owner-only) - operators
|
||||
// have no user/role-management power (#13).
|
||||
|
||||
// Same email shape the invite-create endpoint validates against (workspaces.js).
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer'];
|
||||
// Mirror the server-side minimum enforced by PUT /api/auth/me and register.
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
// POST /api/admin/users - create a user with an admin-set password and assign
|
||||
// them to a workspace + role. The result is indistinguishable from an
|
||||
// invite-accepted user (a global users row + a workspace_members row).
|
||||
router.post('/users', (req, res) => {
|
||||
const email = String(req.body?.email || '').trim().toLowerCase();
|
||||
const name = String(req.body?.name || '').trim();
|
||||
const password = String(req.body?.password || '');
|
||||
// Accept workspaceId (preferred) or orgId as an alias for the target field.
|
||||
const workspaceId = String(req.body?.workspaceId || req.body?.orgId || '').trim();
|
||||
const role = String(req.body?.role || '').trim();
|
||||
const mustChangePassword = !!req.body?.mustChangePassword;
|
||||
|
||||
if (!email || !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ error: 'Valid email required' });
|
||||
}
|
||||
if (!WORKSPACE_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: 'Role must be workspace_admin, workspace_editor, or workspace_viewer' });
|
||||
}
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
}
|
||||
if (!workspaceId) {
|
||||
return res.status(400).json({ error: 'workspaceId required' });
|
||||
}
|
||||
|
||||
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId);
|
||||
if (!ws) return res.status(404).json({ error: 'Workspace not found' });
|
||||
if (!canAdminWorkspace(db, req.user, ws)) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
// Stamp the target workspace so the activityLogger middleware (and our
|
||||
// explicit audit row) attribute to the right tenant.
|
||||
req.workspaceId = ws.id;
|
||||
|
||||
// Email uniqueness: clean 409, never overwrite an existing account.
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'A user with that email already exists' });
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const passwordHash = bcrypt.hashSync(password, 10);
|
||||
|
||||
// HOSTED_INSTANCE: an admin-provisioned user is already set up with a
|
||||
// password, so they must NOT receive the welcome email or enter the
|
||||
// activation-nudge lifecycle. We never call sendSignupEmails here, and the
|
||||
// nudge sweep already excludes them (they have a workspace_members row); we
|
||||
// additionally stamp both *_sent_at sentinels so any future sweep treats them
|
||||
// as already-handled. See services/signupEmails.js + services/activationNudge.js.
|
||||
const txn = db.transaction(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO users (
|
||||
id, email, name, password_hash, auth_provider, role, plan_id,
|
||||
must_change_password, welcome_email_sent_at, activation_nudge_sent_at
|
||||
) VALUES (?, ?, ?, ?, 'local', 'user', 'free', ?, strftime('%s','now'), strftime('%s','now'))
|
||||
`).run(id, email, name || email.split('@')[0], passwordHash, mustChangePassword ? 1 : 0);
|
||||
|
||||
// Same membership footprint as an accepted invite: one workspace_members
|
||||
// row, invited_by = the admin who created them.
|
||||
db.prepare(`
|
||||
INSERT INTO workspace_members (workspace_id, user_id, role, invited_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(ws.id, id, role, req.user.id);
|
||||
});
|
||||
txn();
|
||||
|
||||
// Explicit audit row - who created whom, where, with what role. Never the
|
||||
// plaintext password (and the generic activityLogger only summarizes name).
|
||||
logActivity(req.user.id, 'admin_create_user', `target: ${email}, role: ${role}`, null, getClientIp(req), ws.id);
|
||||
|
||||
// Response never includes password or hash.
|
||||
const created = db.prepare(
|
||||
'SELECT id, email, name, role, auth_provider, plan_id, must_change_password, created_at FROM users WHERE id = ?'
|
||||
).get(id);
|
||||
res.status(201).json({ ...created, workspace_id: ws.id, workspace_role: role });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -429,10 +429,12 @@ router.put('/me', requireAuth, (req, res) => {
|
|||
}
|
||||
}
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
||||
// #10: a successful password change clears must_change_password, releasing
|
||||
// the first-login change-password gate.
|
||||
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
||||
.run(hash, req.user.id);
|
||||
}
|
||||
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(req.user.id);
|
||||
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(req.user.id);
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -365,6 +365,12 @@ app.use(activityLogger);
|
|||
// no resolveTenancy. Permission gated per-handler via canAdminWorkspace().
|
||||
app.use('/api/workspaces', requireAuth, require('./routes/workspaces'));
|
||||
|
||||
// /api/admin: admin-provisioned user creation (#10). Like /api/workspaces it
|
||||
// targets a workspace by body param (not the caller's active one), so
|
||||
// requireAuth only - per-handler canAdminWorkspace() gates it. Mounted after
|
||||
// activityLogger so creations are auto-logged.
|
||||
app.use('/api/admin', requireAuth, require('./routes/admin'));
|
||||
|
||||
app.use('/api/devices', requireAuth, resolveTenancy, require('./routes/devices'));
|
||||
app.use('/api/content', requireAuth, resolveTenancy, require('./routes/content'));
|
||||
app.use('/api/folders', requireAuth, resolveTenancy, require('./routes/folders'));
|
||||
|
|
|
|||
Loading…
Reference in a new issue