mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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:
commit
212170eb88
|
|
@ -174,6 +174,10 @@ export const api = {
|
|||
// Slice 2C - accept a workspace invite by id (post-auth flow)
|
||||
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
|
||||
|
||||
// Admin-provisioned user creation (#10). data: { email, name, password,
|
||||
// workspaceId, role, mustChangePassword }
|
||||
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
// Admin - Users
|
||||
getUsers: () => request('/auth/users'),
|
||||
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,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';
|
||||
|
|
|
|||
133
frontend/js/components/workspace-members-add-user-modal.js
Normal file
133
frontend/js/components/workspace-members-add-user-modal.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// Add-User modal (#10). Sits next to the invite modal in the members view, but
|
||||
// instead of emailing an invite it creates a user account directly with an
|
||||
// admin-set password and assigns them to this workspace + role. For
|
||||
// admin-provisioning on instances with no outbound email (where invites never
|
||||
// deliver). Mirrors workspace-members-invite-modal.js's structure.
|
||||
//
|
||||
// workspace: { id, name }
|
||||
// opts.onSuccess: (result) => void - fires on 201 (server response body)
|
||||
// opts.mapError: (err) => string - translates server error to display text
|
||||
import { api } from '../api.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// Crockford-ish readable random password: avoids ambiguous chars (0/O, 1/l/I).
|
||||
function generatePassword(len = 16) {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||
const arr = new Uint32Array(len);
|
||||
crypto.getRandomValues(arr);
|
||||
let out = '';
|
||||
for (let i = 0; i < len; i++) out += alphabet[arr[i] % alphabet.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function openAddUserModal(workspace, opts = {}) {
|
||||
const { onSuccess, mapError } = opts;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>${t('members.modal.add_user_title', { workspace: esc(workspace.name) })}</h3>
|
||||
<button class="btn-icon" type="button" data-add-close aria-label="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="addUserEmail">${t('members.modal.email_label')}</label>
|
||||
<input id="addUserEmail" type="email" class="input" placeholder="${t('members.modal.email_placeholder')}" style="width:100%" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="addUserName">${t('members.modal.name_label')}</label>
|
||||
<input id="addUserName" type="text" class="input" placeholder="${t('members.modal.name_placeholder')}" style="width:100%" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="addUserPassword">${t('members.modal.password_label')}</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input id="addUserPassword" type="text" class="input" placeholder="${t('members.modal.password_placeholder')}" style="flex:1" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<button class="btn btn-secondary" type="button" id="addUserGenerate" style="white-space:nowrap">${t('members.modal.generate')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="addUserRole">${t('members.modal.role_label')}</label>
|
||||
<select id="addUserRole" class="input" style="width:100%">
|
||||
<option value="workspace_viewer">${t('members.role.workspace_viewer')}</option>
|
||||
<option value="workspace_editor">${t('members.role.workspace_editor')}</option>
|
||||
<option value="workspace_admin">${t('members.role.workspace_admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
||||
<input id="addUserMustChange" type="checkbox" checked>
|
||||
${t('members.modal.must_change_label')}
|
||||
</label>
|
||||
<div id="addUserError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" type="button" data-add-close>${t('members.modal.cancel')}</button>
|
||||
<button class="btn btn-primary" type="button" id="addUserSubmit">${t('members.modal.create')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const emailInput = overlay.querySelector('#addUserEmail');
|
||||
const nameInput = overlay.querySelector('#addUserName');
|
||||
const pwInput = overlay.querySelector('#addUserPassword');
|
||||
const genBtn = overlay.querySelector('#addUserGenerate');
|
||||
const roleSelect = overlay.querySelector('#addUserRole');
|
||||
const mustChange = overlay.querySelector('#addUserMustChange');
|
||||
const errorEl = overlay.querySelector('#addUserError');
|
||||
const submitBtn = overlay.querySelector('#addUserSubmit');
|
||||
emailInput.focus();
|
||||
|
||||
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||
function onKey(e) { if (e.key === 'Escape') close(); }
|
||||
document.addEventListener('keydown', onKey);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelectorAll('[data-add-close]').forEach(b => b.addEventListener('click', close));
|
||||
genBtn.addEventListener('click', () => { pwInput.value = generatePassword(); pwInput.type = 'text'; });
|
||||
|
||||
async function submit() {
|
||||
errorEl.style.display = 'none';
|
||||
const email = emailInput.value.trim().toLowerCase();
|
||||
const name = nameInput.value.trim();
|
||||
const password = pwInput.value;
|
||||
const role = roleSelect.value;
|
||||
if (!email || !EMAIL_RE.test(email)) { showError(t('members.error.invalid_email')); emailInput.focus(); return; }
|
||||
if (!password || password.length < 8) { showError(t('members.error.password_min_8')); pwInput.focus(); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t('members.modal.creating');
|
||||
try {
|
||||
const result = await api.adminCreateUser({
|
||||
email, name, password, role,
|
||||
workspaceId: workspace.id,
|
||||
mustChangePassword: mustChange.checked,
|
||||
});
|
||||
close();
|
||||
if (typeof onSuccess === 'function') {
|
||||
try { onSuccess(result); }
|
||||
catch (e) { console.error('add-user modal onSuccess threw:', e); }
|
||||
}
|
||||
} catch (err) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = t('members.modal.create');
|
||||
const msg = (typeof mapError === 'function')
|
||||
? mapError(err)
|
||||
: (err?.message || t('members.error.mutation_generic', { error: '' }));
|
||||
showError(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||
|
||||
submitBtn.addEventListener('click', submit);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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('')}
|
||||
|
|
|
|||
81
frontend/js/views/force-password-change.js
Normal file
81
frontend/js/views/force-password-change.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// #10: forced first-login password change. When an admin provisions a user
|
||||
// with must_change_password=1, route() in app.js redirects them here and blocks
|
||||
// every other view until they set a new password. Reuses the same PUT /api/auth/me
|
||||
// path as the Settings change-password form; on success the server clears
|
||||
// must_change_password, we refresh the cached user, and return to the app.
|
||||
import { api } from '../api.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
|
||||
<div style="width:400px;max-width:100%">
|
||||
<div style="text-align:center;margin-bottom:24px">
|
||||
<h1 style="font-size:22px;font-weight:700;color:var(--accent)">${t('forcepw.title')}</h1>
|
||||
<p style="color:var(--text-secondary);font-size:13px;margin-top:6px">${t('forcepw.subtitle')}</p>
|
||||
</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
|
||||
<div class="form-group">
|
||||
<label>${t('forcepw.current')}</label>
|
||||
<input type="password" id="fpwCurrent" class="input" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t('forcepw.new')}</label>
|
||||
<input type="password" id="fpwNew" class="input" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t('forcepw.confirm')}</label>
|
||||
<input type="password" id="fpwConfirm" class="input" autocomplete="new-password">
|
||||
</div>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('forcepw.hint')}</p>
|
||||
<button class="btn btn-primary" id="fpwSubmit" style="width:100%;justify-content:center;padding:10px">${t('forcepw.submit')}</button>
|
||||
<p id="fpwError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const current = container.querySelector('#fpwCurrent');
|
||||
const next = container.querySelector('#fpwNew');
|
||||
const confirm = container.querySelector('#fpwConfirm');
|
||||
const submit = container.querySelector('#fpwSubmit');
|
||||
const errorEl = container.querySelector('#fpwError');
|
||||
current.focus();
|
||||
|
||||
const showError = (msg) => { errorEl.textContent = msg; errorEl.style.display = 'block'; };
|
||||
|
||||
async function doChange() {
|
||||
errorEl.style.display = 'none';
|
||||
const cur = current.value;
|
||||
const nw = next.value;
|
||||
const cf = confirm.value;
|
||||
if (!cur || !nw) { showError(t('forcepw.error_required')); return; }
|
||||
if (nw.length < 8) { showError(t('forcepw.error_min8')); return; }
|
||||
if (nw !== cf) { showError(t('forcepw.error_mismatch')); return; }
|
||||
|
||||
submit.disabled = true;
|
||||
submit.textContent = t('forcepw.submitting');
|
||||
try {
|
||||
await api.updateMe({ password: nw, current_password: cur });
|
||||
// Refresh the cached user so the (now-cleared) must_change_password flag
|
||||
// is reflected, then return to the app.
|
||||
try {
|
||||
const fresh = await api.getMe();
|
||||
localStorage.setItem('user', JSON.stringify(fresh));
|
||||
} catch { /* fall through; reload re-fetches */ }
|
||||
showToast(t('forcepw.success'), 'success');
|
||||
window.location.hash = '#/';
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
submit.disabled = false;
|
||||
submit.textContent = t('forcepw.submit');
|
||||
showError(err?.message || t('forcepw.error_generic'));
|
||||
}
|
||||
}
|
||||
|
||||
submit.addEventListener('click', doChange);
|
||||
[current, next, confirm].forEach(el => el.addEventListener('keydown', (e) => { if (e.key === 'Enter') doChange(); }));
|
||||
}
|
||||
|
||||
export function cleanup() {}
|
||||
31
frontend/js/views/no-workspace.js
Normal file
31
frontend/js/views/no-workspace.js
Normal 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() {}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
102
server/routes/admin.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db } = require('../db/database');
|
||||
const { canAdminWorkspace } = require('../lib/permissions');
|
||||
const { logActivity, getClientIp } = require('../services/activity');
|
||||
|
||||
// Admin-provisioned user creation (#10). Operates on a target workspace
|
||||
// specified in the body, NOT the caller's active workspace - so this router is
|
||||
// mounted with requireAuth only (no resolveTenancy), mirroring routes/workspaces.js.
|
||||
// Permission is gated per-handler via canAdminWorkspace() against the TARGET
|
||||
// workspace, which:
|
||||
// - lets a platform_admin create users anywhere,
|
||||
// - scopes an org_admin / org_owner to workspaces in orgs they administer,
|
||||
// - and excludes platform_operator (isPlatformRole owner-only) - operators
|
||||
// have no user/role-management power (#13).
|
||||
|
||||
// Same email shape the invite-create endpoint validates against (workspaces.js).
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer'];
|
||||
// Mirror the server-side minimum enforced by PUT /api/auth/me and register.
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
// POST /api/admin/users - create a user with an admin-set password and assign
|
||||
// them to a workspace + role. The result is indistinguishable from an
|
||||
// invite-accepted user (a global users row + a workspace_members row).
|
||||
router.post('/users', (req, res) => {
|
||||
const email = String(req.body?.email || '').trim().toLowerCase();
|
||||
const name = String(req.body?.name || '').trim();
|
||||
const password = String(req.body?.password || '');
|
||||
// Accept workspaceId (preferred) or orgId as an alias for the target field.
|
||||
const workspaceId = String(req.body?.workspaceId || req.body?.orgId || '').trim();
|
||||
const role = String(req.body?.role || '').trim();
|
||||
const mustChangePassword = !!req.body?.mustChangePassword;
|
||||
|
||||
if (!email || !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ error: 'Valid email required' });
|
||||
}
|
||||
if (!WORKSPACE_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: 'Role must be workspace_admin, workspace_editor, or workspace_viewer' });
|
||||
}
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
}
|
||||
if (!workspaceId) {
|
||||
return res.status(400).json({ error: 'workspaceId required' });
|
||||
}
|
||||
|
||||
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId);
|
||||
if (!ws) return res.status(404).json({ error: 'Workspace not found' });
|
||||
if (!canAdminWorkspace(db, req.user, ws)) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
// Stamp the target workspace so the activityLogger middleware (and our
|
||||
// explicit audit row) attribute to the right tenant.
|
||||
req.workspaceId = ws.id;
|
||||
|
||||
// Email uniqueness: clean 409, never overwrite an existing account.
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'A user with that email already exists' });
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const passwordHash = bcrypt.hashSync(password, 10);
|
||||
|
||||
// HOSTED_INSTANCE: an admin-provisioned user is already set up with a
|
||||
// password, so they must NOT receive the welcome email or enter the
|
||||
// activation-nudge lifecycle. We never call sendSignupEmails here, and the
|
||||
// nudge sweep already excludes them (they have a workspace_members row); we
|
||||
// additionally stamp both *_sent_at sentinels so any future sweep treats them
|
||||
// as already-handled. See services/signupEmails.js + services/activationNudge.js.
|
||||
const txn = db.transaction(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO users (
|
||||
id, email, name, password_hash, auth_provider, role, plan_id,
|
||||
must_change_password, welcome_email_sent_at, activation_nudge_sent_at
|
||||
) VALUES (?, ?, ?, ?, 'local', 'user', 'free', ?, strftime('%s','now'), strftime('%s','now'))
|
||||
`).run(id, email, name || email.split('@')[0], passwordHash, mustChangePassword ? 1 : 0);
|
||||
|
||||
// Same membership footprint as an accepted invite: one workspace_members
|
||||
// row, invited_by = the admin who created them.
|
||||
db.prepare(`
|
||||
INSERT INTO workspace_members (workspace_id, user_id, role, invited_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(ws.id, id, role, req.user.id);
|
||||
});
|
||||
txn();
|
||||
|
||||
// Explicit audit row - who created whom, where, with what role. Never the
|
||||
// plaintext password (and the generic activityLogger only summarizes name).
|
||||
logActivity(req.user.id, 'admin_create_user', `target: ${email}, role: ${role}`, null, getClientIp(req), ws.id);
|
||||
|
||||
// Response never includes password or hash.
|
||||
const created = db.prepare(
|
||||
'SELECT id, email, name, role, auth_provider, plan_id, must_change_password, created_at FROM users WHERE id = ?'
|
||||
).get(id);
|
||||
res.status(201).json({ ...created, workspace_id: ws.id, workspace_role: role });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
240
server/test/admin-users.test.js
Normal file
240
server/test/admin-users.test.js
Normal 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');
|
||||
});
|
||||
129
server/test/operator-permissions.test.js
Normal file
129
server/test/operator-permissions.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue