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:
ScreenTinker 2026-06-05 11:03:56 -05:00
parent 48902f6807
commit 6e31770cee
11 changed files with 414 additions and 5 deletions

View file

@ -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' }),

View file

@ -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';

View 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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -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().

View 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() {}

View file

@ -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');

View file

@ -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 */ }

View file

@ -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
View 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;

View file

@ -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);
});

View file

@ -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'));