Merge pull request #17 from screentinker/feat/role-model-and-admin-users

Role model + MSP user provisioning (#14, #13, #10, #12)
This commit is contained in:
screentinker 2026-06-05 13:44:46 -05:00 committed by GitHub
commit 212170eb88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1025 additions and 51 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,8 @@ 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 * as noWorkspace from './views/no-workspace.js';
import { applyBranding } from './branding.js';
import { t } from './i18n.js';
import { isPlatformAdmin } from './utils.js';
@ -210,6 +212,18 @@ function getCurrentUser() {
} catch { return null; }
}
// #12: true when a signed-in user provably has zero accessible workspaces and
// no platform-level reach. Requires accessible_workspaces to be present (only
// /me populates it) - undefined means "not loaded yet", so we DON'T trigger and
// fall through to the normal (workspace-empty-safe) views until /me resolves.
function hasNoAccessibleWorkspace(u) {
return !!u
&& Array.isArray(u.accessible_workspaces)
&& u.accessible_workspaces.length === 0
&& !u.current_workspace_id
&& !isPlatformAdmin(u);
}
// Refresh the cached user from the server. The server reads plan_id fresh
// from the DB on every request, but the frontend only wrote `user` into
// localStorage at login — so plan/role changes made by an admin weren't
@ -226,6 +240,16 @@ async function refreshCurrentUser() {
// the dropdown in sync if a workspace was added/removed in another tab.
renderWorkspaceSwitcher(fresh);
window.dispatchEvent(new CustomEvent('user-refreshed', { detail: fresh }));
// #12: /me is the first place accessible_workspaces is known. If it resolves
// to zero (org-less user), send them to the empty state now - on a fresh
// load route() may have already rendered the dashboard before /me returned.
// Guard against the login / change-password / already-there screens to avoid
// a redirect loop.
const hash = window.location.hash || '#/';
if (hasNoAccessibleWorkspace(fresh)
&& hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') {
window.location.hash = '#/no-workspace';
}
} catch {}
}
@ -276,6 +300,56 @@ 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;
}
}
// #12: a signed-in user with zero accessible workspaces (org-less self-signup
// on an AUTO_CREATE_ORG_ON_SIGNUP=false deployment) lands on a "no workspaces
// yet" empty state instead of being bounced into onboarding (whose pairing
// step needs a workspace). Only fires once /me has populated
// accessible_workspaces; until then the workspace-empty-safe dashboard shows.
if (isAuthenticated()) {
const u = getCurrentUser();
if (hasNoAccessibleWorkspace(u) && hash !== '#/no-workspace') {
window.location.hash = '#/no-workspace';
return;
}
if (hash === '#/no-workspace') {
if (!hasNoAccessibleWorkspace(u)) { window.location.hash = '#/'; return; }
sidebar.style.display = 'none';
app.style.marginLeft = '0';
const mb = document.getElementById('mobileMenuBtn');
if (mb) mb.style.display = 'none';
currentView = noWorkspace;
noWorkspace.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

@ -799,6 +799,10 @@ export default {
'admin.col.monthly': 'Monthly',
'admin.col.yearly': 'Yearly',
'admin.role.user': 'User',
'admin.role.platform_operator': 'Platform operator',
'admin.role.platform_admin': 'Platform admin',
// Legacy labels kept for back-compat with any not-yet-normalized data; the
// role dropdown no longer offers these (#14 normalization).
'admin.role.admin': 'Admin',
'admin.role.superadmin': 'Superadmin',
'admin.remove': 'Remove',
@ -1155,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',
@ -1174,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.
@ -1181,6 +1199,28 @@ 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.',
// No-workspace empty state (#12): shown to an org-less signed-in user.
'noworkspace.title': 'No workspaces yet',
'noworkspace.body': "Your account isn't part of any workspace yet. Ask your administrator to add you to one, then sign in again.",
'noworkspace.sign_out': 'Sign out',
// 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

@ -6,6 +6,12 @@ import { t } from '../i18n.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
// #14: the platform user-management dropdown manages users.role (the
// PLATFORM-level role) only - workspace/org roles are managed in the members
// views. Options are the current model; the legacy 'admin'/'superadmin' strings
// were normalized away. #13 adds 'platform_operator' (cross-org staff).
const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin'];
export async function render(container) {
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (!isPlatformAdmin(user)) {
@ -65,9 +71,7 @@ async function loadUsers() {
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')}</td>
<td style="padding:8px">
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
<option value="user" ${u.role === 'user' ? 'selected' : ''}>${t('admin.role.user')}</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>${t('admin.role.admin')}</option>
<option value="superadmin" ${u.role === 'superadmin' ? 'selected' : ''}>${t('admin.role.superadmin')}</option>
${PLATFORM_ROLE_OPTIONS.map(r => `<option value="${r}" ${u.role === r ? 'selected' : ''}>${t('admin.role.' + r)}</option>`).join('')}
</select>
</td>
<td style="padding:8px">
@ -77,7 +81,7 @@ async function loadUsers() {
</td>
<td style="padding:8px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
${u.role !== 'superadmin' ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
</td>
</tr>
`).join('')}

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

@ -0,0 +1,31 @@
// #12: empty state for a signed-in user who belongs to zero workspaces. Happens
// on deployments with AUTO_CREATE_ORG_ON_SIGNUP=false, where a self-service
// signup is created org-less and an admin/operator assigns them to a workspace
// afterward. Without this, such a user would be bounced into onboarding (whose
// device-pairing step needs a workspace) - a broken flow. Here they get a clear
// "ask your admin" message instead.
import { t } from '../i18n.js';
export function render(container) {
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
<div style="width:440px;max-width:100%;text-align:center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.6" style="margin:0 auto 16px">
<rect x="3" y="4" width="18" height="14" rx="2"/>
<path d="M3 9h18"/>
</svg>
<h1 style="font-size:20px;font-weight:700;margin-bottom:8px">${t('noworkspace.title')}</h1>
<p style="color:var(--text-secondary);font-size:14px;line-height:1.6;margin-bottom:24px">${t('noworkspace.body')}</p>
<button class="btn btn-secondary" id="noWsSignOut" style="padding:8px 16px">${t('noworkspace.sign_out')}</button>
</div>
</div>
`;
container.querySelector('#noWsSignOut').addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.hash = '#/login';
window.location.reload();
});
}
export function cleanup() {}

View file

@ -12,7 +12,10 @@ export async function render(container) {
try { user = await api.getMe(); localStorage.setItem('user', JSON.stringify(user)); }
catch { user = JSON.parse(localStorage.getItem('user') || '{}'); }
const isSuperAdmin = isPlatformAdmin(user);
const isAdmin = user.role === 'admin' || isSuperAdmin;
// #14: the legacy 'admin' platform role was normalized away; platform-level
// admin is now just isPlatformAdmin. (Elevated capability otherwise comes from
// org/workspace membership, gated in the members views, not users.role.)
const isAdmin = isSuperAdmin;
container.innerHTML = `
<div class="page-header">
@ -430,7 +433,7 @@ async function loadUsers() {
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span>
</td>
<td style="padding:10px 12px">
<span style="color:${u.role === 'admin' ? 'var(--accent)' : 'var(--text-secondary)'}">${u.role}</span>
<span style="color:${isPlatformAdmin(u) ? 'var(--accent)' : 'var(--text-secondary)'}">${u.role}</span>
</td>
<td style="padding:10px 12px">
<select class="input plan-select" data-user-id="${u.id}" style="padding:4px 8px;font-size:12px;width:auto">

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

@ -69,4 +69,10 @@ module.exports = {
// Redirect / -> /app instead of serving the marketing landing page.
// For self-hosted internal deployments that don't want the public homepage.
disableHomepage: ['true', '1'].includes(String(process.env.DISABLE_HOMEPAGE || '').toLowerCase()),
// Issue #12: auto-create a personal org + Default workspace for self-service
// signups (public register + OAuth). Defaults TRUE so single-tenant and the
// hosted self-service flow are unaffected; set AUTO_CREATE_ORG_ON_SIGNUP=false
// on MSP-style deployments where an admin/operator assigns users to existing
// orgs after signup instead.
autoCreateOrgOnSignup: !['false', '0'].includes(String(process.env.AUTO_CREATE_ORG_ON_SIGNUP || '').toLowerCase()),
};

View file

@ -162,6 +162,21 @@ const migrations = [
// only genuinely-new signups (NULL) become eligible going forward.
"ALTER TABLE users ADD COLUMN activation_nudge_sent_at INTEGER",
"UPDATE users SET activation_nudge_sent_at = 1 WHERE activation_nudge_sent_at IS NULL",
// Issue #14: normalize the platform-role model. The legacy /api/auth/users
// dropdown could write 'superadmin' and 'admin' strings that not every code
// path recognized (some checks matched only 'platform_admin', so a superadmin
// could list orgs but not act-as into them). Collapse to the current model:
// superadmin -> platform_admin (equivalent everywhere; fixes act-as)
// admin -> user (legacy middle tier; elevated power now
// comes from org/workspace membership)
// Strictly idempotent: mutates ONLY exact legacy strings, no-ops on rows
// 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

@ -14,20 +14,26 @@
'use strict';
const { isPlatformRole, isPlatformStaff } = require('../middleware/auth');
// #13: platform staff (admin OR operator) get cross-org read/write. canRead and
// canWrite include req.isPlatformStaff; canAdmin deliberately does NOT - it stays
// owner-gated, so operators can read/write resources everywhere but cannot
// perform workspace-admin actions (member mgmt, rename, branding, etc.).
function canRead(req) {
if (req.isPlatformAdmin) return true;
if (req.isPlatformStaff) return true;
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return !!req.workspaceRole; // any workspace_member can read
}
function canWrite(req) {
if (req.isPlatformAdmin) return true;
if (req.isPlatformStaff) return true;
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return req.workspaceRole === 'workspace_admin' || req.workspaceRole === 'workspace_editor';
}
function canAdmin(req) {
if (req.isPlatformAdmin) return true;
if (req.isPlatformAdmin) return true; // owner only - NOT platform_operator
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return req.workspaceRole === 'workspace_admin';
}
@ -86,12 +92,11 @@ function requireOrgOwner(req, res, next) {
next();
}
function requirePlatformAdmin(req, res, next) {
if (!req.user || req.user.role !== 'platform_admin') {
return res.status(403).json({ error: 'Platform admin required' });
}
next();
}
// #14: the dead/stricter requirePlatformAdmin that used to live here (bare
// `=== 'platform_admin'`, excluding legacy superadmin) was removed. The single
// platform-admin guard is requirePlatformAdmin in server/middleware/auth.js,
// which is the alias every route already imports and which accepts the full
// PLATFORM_ROLES set via isPlatformRole().
// Decoupled "can admin this workspace" predicate. Unlike canAdmin(req) above,
// this takes an explicit (user, workspace) pair instead of reading from req,
@ -100,7 +105,9 @@ function requirePlatformAdmin(req, res, next) {
// active one. Does its own DB lookups against workspace_members + organization_members.
function canAdminWorkspace(db, user, workspace) {
if (!user || !workspace) return false;
if (user.role === 'platform_admin' || user.role === 'superadmin') return true;
// Owner only (isPlatformRole) - platform_operator is intentionally excluded,
// so operators cannot manage workspace members, rename, or set branding (#13).
if (isPlatformRole(user.role)) return true;
const om = db.prepare('SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?')
.get(workspace.organization_id, user.id);
if (om && (om.role === 'org_owner' || om.role === 'org_admin')) return true;
@ -115,7 +122,10 @@ function canAdminWorkspace(db, user, workspace) {
// where resolveTenancy is not on the request (e.g. /api/workspaces/:id/members).
function canAccessWorkspace(db, user, workspace) {
if (!user || !workspace) return false;
if (user.role === 'platform_admin' || user.role === 'superadmin') return true;
// Read access: platform staff (admin OR operator) can view any workspace,
// including its member list (#13, read-only - mutations stay owner-gated via
// canAdminWorkspace).
if (isPlatformStaff(user.role)) return true;
const om = db.prepare('SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?')
.get(workspace.organization_id, user.id);
if (om && (om.role === 'org_owner' || om.role === 'org_admin')) return true;
@ -134,5 +144,4 @@ module.exports = {
requireWorkspaceAdmin,
requireOrgAdmin,
requireOrgOwner,
requirePlatformAdmin,
};

View file

@ -8,7 +8,8 @@
// req.organizationId string | null parent org of req.workspace
// req.workspaceRole string | null 'workspace_admin' | 'workspace_editor' | 'workspace_viewer'
// req.orgRole string | null 'org_owner' | 'org_admin'
// req.isPlatformAdmin boolean shortcut for req.user.role === 'platform_admin'
// req.isPlatformAdmin boolean true when req.user.role is a platform-owner role
// (isPlatformRole: platform_admin / legacy superadmin)
// req.actingAs boolean true when the user reached this workspace via
// org-level or platform-level access rather than
// a direct workspace_members row
@ -26,6 +27,7 @@
'use strict';
const { db } = require('../db/database');
const { isPlatformRole, isPlatformStaff } = require('../middleware/auth');
function membershipOf(userId, workspaceId) {
return db.prepare(
@ -55,10 +57,9 @@ function firstAccessibleWorkspace(userId) {
}
// Check whether userId can access workspace via any path (member, org admin,
// or platform admin). Returns the access context: { workspaceRole, actingAs }
// or platform staff). Returns the access context: { workspaceRole, actingAs }
// or null if no access.
function accessContext(userId, role, workspace) {
const isPlatformAdmin = role === 'platform_admin';
const wsMembership = membershipOf(userId, workspace.id);
if (wsMembership) {
return { workspaceRole: wsMembership.role, actingAs: false };
@ -67,7 +68,13 @@ function accessContext(userId, role, workspace) {
if (orgMembership && (orgMembership.role === 'org_owner' || orgMembership.role === 'org_admin')) {
return { workspaceRole: null, actingAs: true };
}
if (isPlatformAdmin) {
// #14: isPlatformRole (not a bare === 'platform_admin') so a legacy
// 'superadmin' can act-as too. #13: isPlatformStaff additionally lets
// platform_operator act-as into any org. actingAs:true (workspaceRole null)
// is what skips the viewer-deny on resource writes, so staff get read/write
// in any workspace - while canAdmin()/canAdminWorkspace() stay owner-gated,
// so operators still can't perform workspace-admin actions.
if (isPlatformStaff(role)) {
return { workspaceRole: null, actingAs: true };
}
return null;
@ -79,8 +86,15 @@ function resolveTenancy(req, res, next) {
return next();
}
const isPlatformAdmin = req.user.role === 'platform_admin';
// isPlatformAdmin = OWNER tier (drives canAdmin/canWrite owner short-circuits).
// isPlatformStaff = OWNER + platform_operator; drives cross-org visibility and
// act-as only. Operators get isPlatformStaff=true but isPlatformAdmin=false,
// so they can see/act-as everywhere yet hold no owner power (#13).
const isPlatformAdmin = isPlatformRole(req.user.role);
const isPlatformStaffUser = isPlatformStaff(req.user.role);
req.isPlatformAdmin = isPlatformAdmin;
req.isPlatformOperator = isPlatformStaffUser && !isPlatformAdmin;
req.isPlatformStaff = isPlatformStaffUser;
// Build the ordered candidate list of workspace_ids to try.
const candidates = [];
@ -108,8 +122,11 @@ function resolveTenancy(req, res, next) {
workspace = first;
const wm = membershipOf(req.user.id, first.id);
context = { workspaceRole: wm.role, actingAs: false };
} else if (isPlatformAdmin) {
// Platform admin with no direct memberships: pick any workspace (acting-as).
} else if (isPlatformStaffUser) {
// Platform staff (admin or operator) with no direct memberships: pick any
// workspace (acting-as) so they land in a usable context. #13: operators
// included here too - they have no memberships of their own but must be
// able to act-as across orgs.
const any = db.prepare('SELECT * FROM workspaces LIMIT 1').get();
if (any) {
workspace = any;
@ -147,7 +164,9 @@ function resolveTenancy(req, res, next) {
// rather than reusing this helper (different shape needs).
function accessibleWorkspaceIds(userId, role) {
if (!userId) return [];
if (role === 'platform_admin' || role === 'superadmin') {
// #13: platform staff (admin OR operator) see every workspace - visibility,
// not an owner power.
if (isPlatformStaff(role)) {
return db.prepare('SELECT id FROM workspaces').all().map(r => r.id);
}
return db.prepare(`

View file

@ -26,7 +26,7 @@ function recoveryUser(decoded) {
id: decoded.id,
email: decoded.email || 'admin@localhost',
name: 'Recovery Admin',
role: decoded.role || 'admin',
role: decoded.role || 'platform_admin',
auth_provider: 'recovery',
avatar_url: null,
plan_id: 'enterprise'
@ -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.
@ -82,10 +82,41 @@ function optionalAuth(req, res, next) {
// either spelling so existing callers keep working without per-route edits.
// New code should prefer requirePlatformAdmin / requireOrgAdmin / workspace
// role guards from server/lib/permissions.js.
//
// Issue #14 (role normalization): the data migration in db/database.js collapses
// any legacy 'superadmin' -> 'platform_admin' and 'admin' -> 'user'. 'superadmin'
// is kept in PLATFORM_ROLES purely as back-compat belt-and-suspenders (recovery
// tokens, stray strings) - no row should carry it post-migration. Owner-level
// power lives here in PLATFORM_ROLES; anything not in this set is denied.
const PLATFORM_ROLES = ['superadmin', 'platform_admin'];
const ELEVATED_ROLES = ['admin', 'superadmin', 'platform_admin'];
// isPlatformRole: single predicate for "is this string a platform-owner role".
// Use this instead of a bare `role === 'platform_admin'` so a stray 'superadmin'
// is never silently treated as lower-privileged (the act-as bug fixed in #14).
// NOTE: this is the OWNER tier only - it deliberately does NOT include
// 'platform_operator' (issue #13), which is cross-org staff, not an owner.
function isPlatformRole(role) {
return PLATFORM_ROLES.includes(role);
}
// Issue #13: platform_operator is cross-org STAFF - it can see and act-as into
// every org and read/write workspace-scoped resources there, but holds NO
// owner-level power (no billing, no org/workspace deletion, no user/role
// management, no shared/template asset curation, no branding). The owner powers
// stay gated on PLATFORM_ROLES / isPlatformRole, which operator is deliberately
// NOT a member of - so every owner capability is deny-by-default for operators,
// and any NEW owner endpoint added later inherits that denial automatically.
//
// PLATFORM_STAFF / isPlatformStaff is the union used ONLY for cross-org
// VISIBILITY + act-as + workspace-scoped read/write. It must never gate an
// owner action.
const PLATFORM_STAFF = ['superadmin', 'platform_admin', 'platform_operator'];
function isPlatformStaff(role) {
return PLATFORM_STAFF.includes(role);
}
function requireAdmin(req, res, next) {
if (!req.user || !ELEVATED_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' });
@ -103,4 +134,4 @@ function requireSuperAdmin(req, res, next) {
// Preferred alias for new code.
const requirePlatformAdmin = requireSuperAdmin;
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, PLATFORM_ROLES, ELEVATED_ROLES };
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES };

View file

@ -5,7 +5,8 @@
"main": "server.js",
"scripts": {
"start": "node --env-file-if-exists=.env server.js",
"dev": "node --watch --env-file-if-exists=.env server.js"
"dev": "node --watch --env-file-if-exists=.env server.js",
"test": "node --test"
},
"dependencies": {
"@azure/msal-node": "^5.2.1",

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

@ -5,7 +5,7 @@ const https = require('https');
const { v4: uuidv4 } = require('uuid');
const { OAuth2Client } = require('google-auth-library');
const { db } = require('../db/database');
const { generateToken, requireAuth, requireAdmin, requireSuperAdmin, PLATFORM_ROLES } = require('../middleware/auth');
const { generateToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const { logActivity, getClientIp } = require('../services/activity');
const { sendSignupEmails } = require('../services/signupEmails');
@ -15,7 +15,11 @@ const config = require('../config');
// workspace_id to embed in the JWT. Idempotent: if the user already has
// memberships (e.g. migrated from Phase 1), returns the first one without
// creating anything.
function ensureDefaultOrgForUser(user) {
// #12: allowCreate gates the MINT path only. An existing membership is always
// returned (idempotent). When allowCreate is false and the user has no
// membership, returns null - the caller is created org-less and an admin /
// operator assigns them to a workspace afterward.
function ensureDefaultOrgForUser(user, { allowCreate = true } = {}) {
const existing = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
@ -23,6 +27,7 @@ function ensureDefaultOrgForUser(user) {
ORDER BY wm.joined_at ASC LIMIT 1
`).get(user.id);
if (existing) return existing.id;
if (!allowCreate) return null;
// No memberships -> mint a fresh org and Default workspace owned by user.
const orgId = uuidv4();
@ -85,7 +90,7 @@ router.post('/register', (req, res) => {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const { email, password, name } = req.body;
const { email, password, name, createOrg } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
@ -109,7 +114,14 @@ router.post('/register', (req, res) => {
`).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, stripe_customer_id, stripe_subscription_id, subscription_status, subscription_ends FROM users WHERE id = ?').get(id);
const workspaceId = ensureDefaultOrgForUser(user);
// #12: org-on-create. Per-request createOrg overrides the deployment default
// (config.autoCreateOrgOnSignup). The first user is always given an org so a
// fresh install is never left headless. When neither applies, the user is
// created org-less and lands on the "no workspaces yet" state until an admin
// assigns them.
const createOrgForUser = isFirstUser
|| (createOrg !== undefined ? !!createOrg : config.autoCreateOrgOnSignup);
const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: createOrgForUser });
const token = generateToken(user, workspaceId);
res.status(201).json({ token, user, current_workspace_id: workspaceId });
@ -135,7 +147,7 @@ router.post('/login', (req, res) => {
}
logSuccessfulLogin(user.id, email, getClientIp(req));
const workspaceId = ensureDefaultOrgForUser(user);
const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: workspaceId });
@ -187,7 +199,7 @@ router.post('/google', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
const workspaceId = ensureDefaultOrgForUser(user);
const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: workspaceId });
@ -268,7 +280,7 @@ router.post('/microsoft', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
const workspaceId = ensureDefaultOrgForUser(user);
const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: workspaceId });
@ -324,8 +336,12 @@ router.get('/me', requireAuth, resolveTenancy, (req, res) => {
// so unclaimed pair-pool devices (workspace_id IS NULL) are correctly excluded.
// Microseconds per row at current scale (~37 rows worst case for platform_admin);
// not optimizing - revisit if the admin list grows past a few hundred workspaces.
const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin';
const accessible = isPlatformAdmin
// #13: platform staff (admin OR operator) SEE every workspace (visibility).
// can_admin below is computed separately from isPlatformRole (owner only), so
// operators see all workspaces but get can_admin:false on each.
const isPlatformStaffUser = isPlatformStaff(req.user.role);
const isPlatformAdmin = isPlatformRole(req.user.role);
const accessible = isPlatformStaffUser
? db.prepare(`
SELECT w.id, w.name, w.organization_id, o.name AS organization_name,
wm.role AS workspace_role, om.role AS org_role,
@ -385,12 +401,13 @@ router.post('/switch-workspace', requireAuth, (req, res) => {
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspace_id);
if (!ws) return res.status(404).json({ error: 'Workspace not found' });
const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin';
// #13: platform staff (admin OR operator) can switch into any workspace.
const isPlatformStaffUser = isPlatformStaff(req.user.role);
const wsMember = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(ws.id, req.user.id);
const orgMember = db.prepare(`
SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?
`).get(ws.organization_id, req.user.id);
const canAct = isPlatformAdmin
const canAct = isPlatformStaffUser
|| !!wsMember
|| (orgMember && (orgMember.role === 'org_owner' || orgMember.role === 'org_admin'));
@ -424,10 +441,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);
});
@ -456,11 +475,18 @@ router.delete('/users/:id', requireAuth, requireSuperAdmin, (req, res) => {
res.json({ success: true });
});
// Update user role (superadmin only)
// Update user platform role (platform admin only).
// #14: this manages users.role (the PLATFORM-level role) only - workspace and
// org roles are managed in the members views. Whitelist is the current model:
// 'user' and 'platform_admin' (the legacy 'admin'/'superadmin' strings are gone
// after normalization and are no longer accepted here).
const ASSIGNABLE_PLATFORM_ROLES = ['user', 'platform_operator', 'platform_admin'];
router.put('/users/:id/role', requireAuth, requireSuperAdmin, (req, res) => {
const { role } = req.body;
if (!['user', 'admin', 'superadmin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (req.params.id === req.user.id && role !== 'superadmin') return res.status(400).json({ error: 'Cannot demote yourself' });
if (!ASSIGNABLE_PLATFORM_ROLES.includes(role)) return res.status(400).json({ error: 'Invalid role' });
// Self-demotion guard: a platform admin can't strip their own platform role
// (would lock themselves out of platform admin actions).
if (req.params.id === req.user.id && !isPlatformRole(role)) return res.status(400).json({ error: 'Cannot demote yourself' });
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
res.json({ success: true });
});

View file

@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middleware/auth');
// Phase 2.2a: workspace-aware access. accessContext returns { workspaceRole, actingAs }
// or null based on the caller's reach into a specific workspace.
const { accessContext } = require('../lib/tenancy');
@ -42,9 +42,11 @@ router.get('/', (req, res) => {
res.json(devices);
});
// List unclaimed provisioning devices (admin only)
// List unclaimed provisioning devices (admin only).
// #13: read-only, so platform_operator may view the pool too (cross-org staff
// troubleshooting). Claiming a device is a separate workspace-scoped mutation.
router.get('/unassigned', (req, res) => {
if (!ELEVATED_ROLES.includes(req.user.role)) {
if (!ELEVATED_ROLES.includes(req.user.role) && !isPlatformStaff(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' });
}
const devices = db.prepare(`

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

View file

@ -0,0 +1,240 @@
'use strict';
// Tests for #10 (admin-provisioned user creation) and the must_change_password
// lifecycle, plus the #13 operator denial on this endpoint.
//
// No DB_PATH override (per project constraint): we mount the real routers
// against an isolated in-memory better-sqlite3 instance that we seed here, by
// injecting it into the require cache for ../db/database BEFORE any module that
// requires it is loaded. Node v20 built-ins only (node:test, node:assert, fetch).
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const Database = require('better-sqlite3');
process.env.JWT_SECRET = 'test-secret-admin-users';
// --- isolated in-memory DB + minimal schema (only what these paths touch) ---
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'local',
provider_id TEXT,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user',
plan_id TEXT DEFAULT 'free',
email_alerts INTEGER DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 0,
welcome_email_sent_at INTEGER,
activation_nudge_sent_at INTEGER,
last_login INTEGER,
trial_started INTEGER,
trial_plan TEXT,
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
subscription_status TEXT DEFAULT 'active',
subscription_ends INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
name TEXT NOT NULL,
slug TEXT,
created_by TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE organization_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'org_admin',
invited_by TEXT,
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(organization_id, user_id)
);
CREATE TABLE workspace_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'workspace_viewer',
invited_by TEXT,
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(workspace_id, user_id)
);
CREATE TABLE activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
device_id TEXT,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
workspace_id TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
`);
// Inject the mock BEFORE requiring anything that pulls ../db/database.
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = {
id: dbModulePath,
filename: dbModulePath,
loaded: true,
exports: { db, pruneTelemetry() {}, pruneScreenshots() {} },
};
const express = require('express');
const bcrypt = require('bcryptjs');
const { generateToken, requireAuth } = require('../middleware/auth');
const { activityLogger } = require('../services/activity');
const adminRouter = require('../routes/admin');
const authRouter = require('../routes/auth');
// --- seed orgs/workspaces/users ---
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-a','org-a','Workspace A')").run();
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-b','org-b','Workspace B')").run();
function seedUser({ id, email, role = 'user' }) {
db.prepare("INSERT INTO users (id, email, name, password_hash, auth_provider, role) VALUES (?, ?, ?, 'x', 'local', ?)")
.run(id, email, email.split('@')[0], role);
return { id, email, role };
}
const adminUser = seedUser({ id: 'u-admin', email: 'admin@test.local', role: 'platform_admin' });
const orgAdminA = seedUser({ id: 'u-orgadmin-a', email: 'orgadmin-a@test.local', role: 'user' });
db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES ('org-a','u-orgadmin-a','org_admin')").run();
const operator = seedUser({ id: 'u-operator', email: 'operator@test.local', role: 'platform_operator' });
const regular = seedUser({ id: 'u-regular', email: 'regular@test.local', role: 'user' });
// Dedicated target for the role-assignment regression test (kept separate so it
// can't perturb the non-admin/operator tokens used by the deny tests above).
seedUser({ id: 'u-role-target', email: 'role-target@test.local', role: 'user' });
const tokens = {
admin: generateToken(adminUser, null),
orgAdminA: generateToken(orgAdminA, 'ws-a'),
operator: generateToken(operator, null),
regular: generateToken(regular, null),
};
// --- build + start the app ---
const app = express();
app.use(express.json());
app.use('/api/auth', authRouter); // matches prod: auth before activityLogger
app.use(activityLogger);
app.use('/api/admin', requireAuth, adminRouter);
const server = app.listen(0);
let base;
test.before(async () => {
await new Promise(r => server.listening ? r() : server.once('listening', r));
base = `http://127.0.0.1:${server.address().port}`;
});
test.after(() => { server.close(); db.close(); });
function post(pathname, token, body) {
return fetch(base + pathname, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
});
}
const newUserBody = (over = {}) => ({
email: 'created@test.local', name: 'Created User', password: 'TempPass123',
workspaceId: 'ws-a', role: 'workspace_editor', mustChangePassword: true, ...over,
});
test('platform_admin can create a user (201); response omits password/hash; membership written', async () => {
const res = await post('/api/admin/users', tokens.admin, newUserBody());
assert.equal(res.status, 201);
const body = await res.json();
assert.equal(body.email, 'created@test.local');
assert.equal(body.workspace_role, 'workspace_editor');
assert.equal(body.must_change_password, 1);
assert.ok(!('password' in body), 'response must not include password');
assert.ok(!('password_hash' in body), 'response must not include hash');
const row = db.prepare('SELECT * FROM users WHERE email = ?').get('created@test.local');
assert.ok(row && row.password_hash && row.password_hash !== 'TempPass123', 'password is hashed, not plaintext');
const mem = db.prepare("SELECT * FROM workspace_members WHERE workspace_id='ws-a' AND user_id=?").get(row.id);
assert.equal(mem.role, 'workspace_editor');
assert.equal(mem.invited_by, 'u-admin');
// HOSTED: excluded from welcome + activation-nudge lifecycle.
assert.ok(row.welcome_email_sent_at && row.activation_nudge_sent_at, 'lifecycle sentinels stamped');
// Audit row written, never the password.
const audit = db.prepare("SELECT * FROM activity_log WHERE action='admin_create_user'").get();
assert.ok(audit && /created@test\.local/.test(audit.details));
assert.ok(!/TempPass123/.test(audit.details), 'audit must not contain the password');
});
test('duplicate email returns 409 and does not overwrite', async () => {
const res = await post('/api/admin/users', tokens.admin, newUserBody({ password: 'Different999' }));
assert.equal(res.status, 409);
// original hash unchanged
const row = db.prepare('SELECT password_hash FROM users WHERE email = ?').get('created@test.local');
assert.ok(bcrypt.compareSync('TempPass123', row.password_hash), 'existing password untouched');
});
test('non-admin user is denied (403)', async () => {
const res = await post('/api/admin/users', tokens.regular, newUserBody({ email: 'x1@test.local' }));
assert.equal(res.status, 403);
assert.equal(db.prepare('SELECT COUNT(*) c FROM users WHERE email=?').get('x1@test.local').c, 0);
});
test('platform_operator is denied from Add User (403) - user mgmt is owner-only', async () => {
const res = await post('/api/admin/users', tokens.operator, newUserBody({ email: 'x2@test.local' }));
assert.equal(res.status, 403);
assert.equal(db.prepare('SELECT COUNT(*) c FROM users WHERE email=?').get('x2@test.local').c, 0);
});
test('org_admin can create in their own org but NOT another org', async () => {
const ok = await post('/api/admin/users', tokens.orgAdminA, newUserBody({ email: 'in-a@test.local', workspaceId: 'ws-a' }));
assert.equal(ok.status, 201);
const denied = await post('/api/admin/users', tokens.orgAdminA, newUserBody({ email: 'in-b@test.local', workspaceId: 'ws-b' }));
assert.equal(denied.status, 403);
assert.equal(db.prepare('SELECT COUNT(*) c FROM users WHERE email=?').get('in-b@test.local').c, 0);
});
test('validation: bad email 400, bad role 400, short password 400, missing workspace 404', async () => {
assert.equal((await post('/api/admin/users', tokens.admin, newUserBody({ email: 'nope' }))).status, 400);
assert.equal((await post('/api/admin/users', tokens.admin, newUserBody({ email: 'r@test.local', role: 'org_admin' }))).status, 400);
assert.equal((await post('/api/admin/users', tokens.admin, newUserBody({ email: 'p@test.local', password: 'short' }))).status, 400);
assert.equal((await post('/api/admin/users', tokens.admin, newUserBody({ email: 'w@test.local', workspaceId: 'ws-missing' }))).status, 404);
});
test('must_change_password lifecycle: set on create, surfaced on login, cleared on /me password change', async () => {
// created@test.local was created with mustChangePassword:true in the first test.
const login = await post('/api/auth/login', null, { email: 'created@test.local', password: 'TempPass123' });
assert.equal(login.status, 200);
const loginBody = await login.json();
assert.equal(loginBody.user.must_change_password, 1, 'login response carries the flag (drives the redirect)');
// Change password via PUT /api/auth/me -> clears the flag.
const meRes = await fetch(base + '/api/auth/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${loginBody.token}` },
body: JSON.stringify({ password: 'BrandNewPass1', current_password: 'TempPass123' }),
});
assert.equal(meRes.status, 200);
const meBody = await meRes.json();
assert.equal(meBody.must_change_password, 0, '/me response shows the flag cleared');
const row = db.prepare('SELECT must_change_password FROM users WHERE email=?').get('created@test.local');
assert.equal(row.must_change_password, 0, 'flag cleared in the DB');
});
test('platform_operator is assignable via PUT /users/:id/role (regression for #13/#14 whitelist gap)', async () => {
const res = await fetch(base + '/api/auth/users/u-role-target/role', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.admin}` },
body: JSON.stringify({ role: 'platform_operator' }),
});
assert.equal(res.status, 200);
const dbRole = db.prepare('SELECT role FROM users WHERE id = ?').get('u-role-target').role;
assert.equal(dbRole, 'platform_operator', 'role actually persisted as platform_operator');
});

View file

@ -0,0 +1,129 @@
'use strict';
// #13 regression: platform_operator gets cross-org workspace read/write (via the
// canWrite broadening to isPlatformStaff) but must STILL be denied writing
// shared/global assets (workspace_id IS NULL), which carry a SEPARATE
// PLATFORM_ROLES gate on top of canWrite. This is the highest-blast-radius deny
// (operator editing platform-wide content), so we prove both halves:
// (a) operator CAN update/delete a workspace-scoped content row, and
// (b) operator CANNOT update/delete a shared (workspace_id IS NULL) row.
//
// Same isolated-in-memory-DB harness as admin-users.test.js: inject the DB into
// the require cache before any module that pulls ../db/database loads. Node v20
// built-ins only. (node --test runs each file in its own process, so this
// injection does not collide with the other suite's.)
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
const { v4: uuidv4 } = require('uuid');
process.env.JWT_SECRET = 'test-secret-operator-perms';
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL DEFAULT '',
password_hash TEXT, auth_provider TEXT NOT NULL DEFAULT 'local', avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user', plan_id TEXT DEFAULT 'free', email_alerts INTEGER DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE workspaces (
id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, name TEXT NOT NULL, slug TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE organization_members (
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL, user_id TEXT NOT NULL,
role TEXT NOT NULL, UNIQUE(organization_id, user_id)
);
CREATE TABLE workspace_members (
id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, user_id TEXT NOT NULL,
role TEXT NOT NULL, joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(workspace_id, user_id)
);
CREATE TABLE content (
id TEXT PRIMARY KEY, filename TEXT NOT NULL, filepath TEXT NOT NULL, mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL, duration_sec REAL, thumbnail_path TEXT, width INTEGER, height INTEGER,
remote_url TEXT, user_id TEXT, folder TEXT, folder_id TEXT, workspace_id TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- Empty, but the DELETE handler queries these for playlist cleanup.
CREATE TABLE devices (id TEXT PRIMARY KEY, playlist_id TEXT);
CREATE TABLE playlists (id TEXT PRIMARY KEY, workspace_id TEXT, published_snapshot TEXT);
CREATE TABLE playlist_items (id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT, content_id TEXT);
`);
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = {
id: dbModulePath, filename: dbModulePath, loaded: true,
exports: { db, pruneTelemetry() {}, pruneScreenshots() {} },
};
const express = require('express');
const { generateToken, requireAuth } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const contentRouter = require('../routes/content');
// Seed: org + workspace, a platform_operator user, and two content rows.
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-a','org-a','Workspace A')").run();
db.prepare("INSERT INTO users (id, email, role) VALUES ('u-op','op@test.local','platform_operator')").run();
const operator = { id: 'u-op', email: 'op@test.local', role: 'platform_operator' };
// JWT carries current_workspace_id so resolveTenancy lands the operator (acting-as) in ws-a.
const opToken = generateToken(operator, 'ws-a');
const wsContentId = uuidv4();
const sharedContentId = uuidv4();
function seedContent(id, workspaceId) {
db.prepare(`INSERT INTO content (id, filename, filepath, mime_type, file_size, workspace_id)
VALUES (?, 'orig.png', '/does/not/exist.png', 'image/png', 123, ?)`).run(id, workspaceId);
}
seedContent(wsContentId, 'ws-a'); // workspace-scoped
seedContent(sharedContentId, null); // shared / platform-global (workspace_id IS NULL)
const app = express();
app.use(express.json());
app.use('/api/content', requireAuth, resolveTenancy, contentRouter);
const server = app.listen(0);
let base;
test.before(async () => {
await new Promise(r => server.listening ? r() : server.once('listening', r));
base = `http://127.0.0.1:${server.address().port}`;
});
test.after(() => { server.close(); db.close(); });
const op = (method, id, body) => fetch(`${base}/api/content/${id}`, {
method,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opToken}` },
...(body ? { body: JSON.stringify(body) } : {}),
});
// (a) Proves the operator's cross-org write actually works (so (b) isn't just
// "operator can't write anything").
test('operator CAN update a workspace-scoped content row', async () => {
const res = await op('PUT', wsContentId, { filename: 'renamed.png' });
assert.equal(res.status, 200);
assert.equal(db.prepare('SELECT filename FROM content WHERE id=?').get(wsContentId).filename, 'renamed.png');
});
// (b) The separate PLATFORM_ROLES gate on workspace_id IS NULL must deny operator.
test('operator CANNOT update a shared (workspace_id IS NULL) content row -> 403', async () => {
const res = await op('PUT', sharedContentId, { filename: 'hijacked.png' });
assert.equal(res.status, 403);
assert.equal(db.prepare('SELECT filename FROM content WHERE id=?').get(sharedContentId).filename, 'orig.png',
'shared row must be unchanged');
});
test('operator CANNOT delete a shared (workspace_id IS NULL) content row -> 403', async () => {
const res = await op('DELETE', sharedContentId);
assert.equal(res.status, 403);
assert.ok(db.prepare('SELECT 1 FROM content WHERE id=?').get(sharedContentId), 'shared row must still exist');
});
// Delete last so the workspace-scoped row survives the update assertion above.
test('operator CAN delete a workspace-scoped content row', async () => {
const res = await op('DELETE', wsContentId);
assert.equal(res.status, 200);
assert.ok(!db.prepare('SELECT 1 FROM content WHERE id=?').get(wsContentId), 'workspace row deleted');
});