Files
picpeak/backend/src/services/userManagementService.js
T
LucaandPaul Nothaft 9431b9f094 feat(setup): configure the public address and SMTP in the wizard, not .env (#1104)
* feat(setup): configure the public address and SMTP in the wizard, not .env

A fresh install could not configure its own public address. `general_site_url`
and the `email_configs` row already existed as admin settings, but nothing
could reach them:

- docker-compose.yml injected FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
  and Dockerfile.aio baked in ENV FRONTEND_URL=http://localhost:3000, so
  getFrontendBaseUrl() returned on its first branch every time and the setting
  was never read. .env.example shipped the same value as an uncommented
  placeholder for FRONTEND_URL / ADMIN_URL / API_URL.
- the wizard never asked for the address at all, and skipped its whole config
  step unless a CRM-ish feature was selected — so a gallery-only install was
  also never offered SMTP, despite gallery links, guest invites and expiry
  warnings all going out through email_configs.
- eleven call sites read process.env.FRONTEND_URL directly rather than the
  resolver, three of them defaulting to placeholder hosts that reached real
  recipients: https://app.example.com in payment-reminder emails, localhost:3005
  in admin invitation emails, https://app.example.com in dev template previews.

Stop injecting a default anywhere, and resolve the origin instead:
FRONTEND_URL -> general_site_url -> the origin the request arrived on ->
whichever exists -> ''. A loopback candidate is treated as unconfigured so the
installs that already have http://localhost:3000 baked into their environment
self-heal; the same guard previously lived inline in routes/gallery.js for the
slideshow QR (#848) and is now shared. The empty return is preserved because
shareLinkService and the SSO redirects in routes/auth rely on it to emit
relative urls — callers needing an absolute url use getAbsoluteFrontendUrl(),
which still ends at http://localhost:3000.

The wizard now persists window.location.origin right after the admin account is
created, so an install that skips the rest still has a usable origin for
background jobs that have no request to derive one from, and offers it as an
editable "Public address" field. Settings -> General shows the field read-only
when FRONTEND_URL pins it, instead of silently ignoring edits.

Also drop the `|| 'mailhog'` fallback when seeding email_configs: that host only
exists in the dev compose profile (which does not even start by default), so a
fresh install came up with a live config pointing nowhere while the wizard
showed empty SMTP fields. With no row, blank fields are the truth and
emailProcessor logs "No email configuration found". Developers set
SMTP_HOST=mailhog explicitly.

backend/src/services/emailService.js is deleted: nothing in backend/ references
it, and it was the only consumer of the SMTP_* variables, which misrepresented
how mail is configured.

Refs #705

* fix(setup): keep FRONTEND_URL ahead of ADMIN_URL/APP_URL when resolving links

The previous commit routed two call sites through the resolver but put the
site-specific variable FIRST, silently reversing precedence:

  userManagementService  was: FRONTEND_URL || ADMIN_URL || localhost:3005
                    became: ADMIN_URL || resolver
  adminEvents/crud       was: FRONTEND_URL || APP_URL || ''
                    became: APP_URL || resolver

An install with both variables set would have flipped which one won. Call the
resolver first instead — it starts with FRONTEND_URL, so the original relative
order is preserved and only the final fallback changes: localhost:3005 (not
even the frontend's port) and '' (a relative link inside an email) both become
the resolved origin.

Refs #705

* fix(setup): unpin loopback FRONTEND_URL, keep ADMIN_URL/APP_URL reachable

Review feedback on #1104.

isEnvPinned() reported ANY FRONTEND_URL as authoritative, including the
loopback values getFrontendBaseUrl() deliberately demotes. An install
upgrading with the old compose default FRONTEND_URL=http://localhost:3000
therefore resolved its origin from general_site_url correctly, but got the
Site URL field rendered read-only in Settings and skipped by the wizard's
seeding - locking the exact operators this change exists to unblock out of
configuring a public address anywhere. The predicate now mirrors the
resolver, and the derived general_site_url_effective the General tab reads
comes from the same helper instead of re-normalising process.env inline.

APP_URL and ADMIN_URL had become dead code: getFrontendBaseUrl() only
returns falsy when NOTHING is configured, so `|| process.env.ADMIN_URL`
after it never ran once a site URL existed - which after this PR is the
normal case. A split-origin install pointing ADMIN_URL at a separate admin
host got invite links on the public gallery origin instead. They are now
passed as an explicit `override` that resolves directly below FRONTEND_URL,
preserving the historic FRONTEND_URL-before-ADMIN_URL order while beating
the database- and request-derived fallbacks.

general_site_url now feeds the CORS allowlist and the
Access-Control-Allow-Origin header, not just email links, so a schemeless
value is an allowlist entry no browser origin can match. Validate it
server-side in PUT /general (isURL with require_protocol, require_tld off
so LAN/NAS installs on http://nas:3000 still work) and client-side in both
surfaces that write it - type="url" never fires in either, since neither
input sits inside a form.

Two more wizard fixes: the General tab no longer reposts general_site_url
while it is env-pinned, because the field then holds the effective env
value rather than the stored one and the round-trip read as a change to a
protected key, 403ing a settings.edit-without-settings.domains admin on an
unrelated save. And SetupConfigStep validates the From address before
posting - /admin/email/config rejects a blank one, which used to surface as
a generic warning while the wizard advanced from its finally block anyway,
discarding every SMTP value the user had typed, password included. A failed
save now keeps them on the step.

* fix(setup): surface a rejected public address instead of swallowing it

Review round 2 follow-up on #1104, pushed onto the branch.

saveSiteUrl() caught and discarded every error. That was defensible before
round 2 added a server-side URL check, but PUT /general can now answer 400 —
and the two validators disagreed:

  http://my_nas.local      client: accepted   server: rejected
  http://foo_bar:3000      client: accepted   server: rejected

validate() let those through, the 400 was swallowed, `failed` stayed false and
onDone() ran. The operator finished the wizard believing the public address was
stored when nothing had been. That is the silent misconfiguration this whole
change exists to remove, landing on the LAN and NAS installs it targets.

Three parts:

- saveSiteUrl() throws. finish() resolves it before anything else is posted and
  puts the message on the address field rather than the generic "some settings
  could not be saved" warning. Skip for now still always leaves, by contract,
  but warns instead of dropping the value in silence.
- allow_underscores on the server check, for the same reason require_tld is
  off: browsers resolve http://my_nas.local and the client accepts it, so
  rejecting it server-side only produced the mismatch above. Both validators
  now agree across the LAN/NAS, IDN, bare-IP and scheme-less cases.
- LOOPBACK_BASE_RE anchors its host token. Bare prefix matching also demoted
  https://localhost-nas.example.com, and now that this predicate gates the
  whole resolver rather than just the slideshow QR, being demoted means a
  configured address is silently ignored. 127. stays a bare prefix on purpose:
  all of 127.0.0.0/8 is loopback.

Resolver suite 31 passing, up from 26. Mutation-checked: restoring the
unanchored regex fails the three new host-boundary cases.

* fix(settings): don't lock the General tab on a site URL nobody typed

Review follow-up on #1104, pushed onto the branch.

general_site_url was free-text until this PR added a server-side check, so an
upgraded install can hold something schemeless that predates it. The tab
flagged that on load, and `disabled={!!siteUrlError}` then killed Save for
EVERY General setting.

An admin holding settings.edit but not settings.domains could not clear it
either: correcting the address is a change to a protected key and 403s. The
tab has no permission gating, so that role was simply locked out of the tab
with no self-service way back.

That is the same role adminSettings.js:85-95 documents the no-op round-trip
allowance for. The allowance only helps if the request is made, and this
blocked it in the browser first.

Validation now waits until the field is actually edited, and an unchanged
value is dropped from the payload rather than reposted — matching what the
env-pinned case already does one line above, and for the same reason.

  stored value invalid, untouched   Save works, key not sent
  edited to something unusable      Save blocked
  edited to a usable absolute url   saved

Four tests, first coverage for this feature. Mutation-checked: removing the
dirty gate fails the untouched-value case.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-21 12:54:14 +02:00

857 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* User Management Service for Admin Users
* Handles invitations, user CRUD, and role management
*/
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateReadablePassword } = require('../utils/passwordGenerator');
const { getBcryptRounds } = require('../utils/passwordValidation');
const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { ConflictError, NotFoundError, ValidationError, ForbiddenError } = require('../utils/errors');
/**
* Create a new admin user invitation
* @param {object} params - { email, roleId, invitedById }
* @returns {Promise<object>} Created invitation details
*/
async function createInvitation({ email, roleId, invitedById, inviterRoleName }) {
// Check if email already exists
const existingUser = await db('admin_users').where('email', email).first();
if (existingUser) {
throw new ConflictError('User with this email already exists', 'email');
}
// Check for pending invitation
const pendingInvite = await db('admin_invitations')
.where('email', email)
.whereNull('accepted_at')
.where('expires_at', '>', new Date())
.first();
if (pendingInvite) {
throw new ConflictError('Pending invitation already exists for this email', 'email');
}
// Validate role exists
const role = await db('roles').where('id', roleId).first();
if (!role) {
throw new NotFoundError('Role', roleId);
}
// Role hierarchy: only super_admin can invite super_admin
if (role.name === 'super_admin' && inviterRoleName !== 'super_admin') {
throw new ValidationError('Only Super Admins can invite new Super Admins');
}
// Generate secure invitation token (64 characters hex = 32 bytes)
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const [invitationId] = await db('admin_invitations').insert({
email,
token,
role_id: roleId,
invited_by: invitedById,
expires_at: expiresAt,
created_at: new Date()
}).returning('id');
const id = invitationId?.id || invitationId;
// Queue invitation email
// Keeps the original FRONTEND_URL-before-ADMIN_URL order: ADMIN_URL is the
// resolver's override, so a split-origin install that points it at a
// separate admin host still wins over the general_site_url setting - it
// must not sit AFTER the resolver, which only returns falsy when nothing at
// all is configured (#1104). Only the terminal fallback changes, from
// localhost:3005 - not even the frontend's port, so an unconfigured install
// mailed admin invites pointing nowhere - to the resolved origin (#705).
const frontendUrl = await getAbsoluteFrontendUrl(null, { override: process.env.ADMIN_URL });
await queueEmail(null, email, 'admin_invitation', {
invite_link: `${frontendUrl}/invite/${token}`,
role_name: role.display_name,
expires_at: expiresAt.toISOString()
});
await logActivity('admin_invitation_created',
{ email, roleId, roleName: role.display_name },
null,
{ type: 'admin', id: invitedById, name: 'system' }
);
logger.info('Admin invitation created', { email, roleId, invitedById });
return { id, email, token, role: role.display_name, expiresAt };
}
/**
* Accept an invitation and create the admin user
* @param {object} params - { token, username, password }
* @returns {Promise<object>} Created user details
*/
async function acceptInvitation({ token, username, password }) {
const invitation = await db('admin_invitations')
.where('token', token)
.whereNull('accepted_at')
.where('expires_at', '>', new Date())
.first();
if (!invitation) {
throw new ValidationError('Invalid or expired invitation');
}
// Check username availability
const existingUsername = await db('admin_users').where('username', username).first();
if (existingUsername) {
throw new ConflictError('Username already taken', 'username');
}
// Check email not taken (race condition protection)
const existingEmail = await db('admin_users').where('email', invitation.email).first();
if (existingEmail) {
throw new ConflictError('Email already registered', 'email');
}
// Hash password
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
// Create user in transaction
const result = await db.transaction(async (trx) => {
const [userId] = await trx('admin_users').insert({
username,
email: invitation.email,
password_hash: passwordHash,
role_id: invitation.role_id,
created_by: invitation.invited_by,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
invite_accepted_at: new Date(),
created_at: new Date(),
updated_at: new Date()
}).returning('id');
const id = userId?.id || userId;
// Mark invitation as accepted
await trx('admin_invitations')
.where('id', invitation.id)
.update({
accepted_at: new Date(),
accepted_user_id: id
});
return id;
});
await logActivity('admin_invitation_accepted',
{ userId: result, email: invitation.email },
null,
{ type: 'system', id: null, name: 'system' }
);
logger.info('Admin invitation accepted', {
userId: result,
email: invitation.email,
invitationId: invitation.id
});
return { userId: result, email: invitation.email };
}
/**
* Get all admin users with their roles
* @returns {Promise<object[]>}
*/
async function getAllAdminUsers() {
return db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.leftJoin('admin_users as creator', 'creator.id', 'admin_users.created_by')
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.is_active',
'admin_users.last_login',
'admin_users.last_login_ip',
'admin_users.created_at',
'admin_users.updated_at',
'roles.id as role_id',
'roles.name as role_name',
'roles.display_name as role_display_name',
'creator.username as created_by_username'
)
.orderBy('admin_users.created_at', 'desc');
}
/**
* Get single admin user by ID
* @param {number} id - User ID
* @returns {Promise<object>}
*/
async function getAdminUserById(id) {
const user = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', id)
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.is_active',
'admin_users.last_login',
'admin_users.last_login_ip',
'admin_users.created_at',
'admin_users.updated_at',
'roles.id as role_id',
'roles.name as role_name',
'roles.display_name as role_display_name'
)
.first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
return user;
}
/**
* Update admin user
* @param {number} id - User ID to update
* @param {object} updates - Fields to update
* @param {number} updatedById - ID of user making the update
* @returns {Promise<object>} Updated user
*/
async function updateAdminUser(id, updates, updatedById, requestingAdmin = {}) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
const allowedUpdates = {};
if (updates.username !== undefined) {
const existing = await db('admin_users')
.where('username', updates.username)
.whereNot('id', id)
.first();
if (existing) {
throw new ConflictError('Username already taken', 'username');
}
allowedUpdates.username = updates.username;
}
if (updates.email !== undefined) {
const existing = await db('admin_users')
.where('email', updates.email)
.whereNot('id', id)
.first();
if (existing) {
throw new ConflictError('Email already in use', 'email');
}
allowedUpdates.email = updates.email;
}
if (updates.role_id !== undefined) {
const role = await db('roles').where('id', updates.role_id).first();
if (!role) {
throw new NotFoundError('Role', updates.role_id);
}
// Role hierarchy enforcement
const superAdminRole = await db('roles').where('name', 'super_admin').first();
const isSuperAdmin = requestingAdmin.roleName === 'super_admin';
// Only super_admin can assign super_admin role
if (superAdminRole && role.id === superAdminRole.id && !isSuperAdmin) {
throw new ValidationError('Only Super Admins can assign the Super Admin role');
}
// Prevent self-role-update
if (id === updatedById) {
throw new ValidationError('Cannot change your own role');
}
// Prevent downgrading the last super_admin
if (superAdminRole && user.role_id === superAdminRole.id && role.id !== superAdminRole.id) {
const superAdminCount = await db('admin_users')
.where('role_id', superAdminRole.id)
.where('is_active', formatBoolean(true))
.count('id as count')
.first();
if (Number(superAdminCount?.count) <= 1) {
throw new ValidationError('Cannot demote the last Super Admin');
}
}
allowedUpdates.role_id = updates.role_id;
}
if (updates.is_active !== undefined) {
allowedUpdates.is_active = formatBoolean(updates.is_active);
}
allowedUpdates.updated_at = new Date();
await db('admin_users').where('id', id).update(allowedUpdates);
await logActivity('admin_user_updated',
{ userId: id, changes: Object.keys(allowedUpdates) },
null,
{ type: 'admin', id: updatedById, name: 'system' }
);
return getAdminUserById(id);
}
/**
* Deactivate admin user
* @param {number} id - User ID to deactivate
* @param {number} deactivatedById - ID of user performing deactivation
*/
async function deactivateAdminUser(id, deactivatedById) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
// Prevent self-deactivation
if (id === deactivatedById) {
throw new ValidationError('Cannot deactivate your own account');
}
// Check if this is the last super_admin
const superAdminRole = await db('roles').where('name', 'super_admin').first();
if (user.role_id === superAdminRole?.id) {
const superAdminCount = await db('admin_users')
.where('role_id', superAdminRole.id)
.where('is_active', formatBoolean(true))
.count('id as count')
.first();
if (Number(superAdminCount?.count) <= 1) {
throw new ValidationError('Cannot deactivate the last Super Admin');
}
}
await db('admin_users').where('id', id).update({
is_active: formatBoolean(false),
updated_at: new Date()
});
await logActivity('admin_user_deactivated',
{ userId: id, username: user.username },
null,
{ type: 'admin', id: deactivatedById, name: 'system' }
);
logger.info('Admin user deactivated', { userId: id, deactivatedById });
}
/**
* Re-activate a previously deactivated admin user. Symmetric counterpart
* to deactivateAdminUser — flips is_active back to true so the account
* can log in again.
*
* Reported in #574 follow-up: once an admin was deactivated, the UI
* lost the only affordance to manage that record (no Reactivate, no
* Delete). This is the Reactivate half.
*
* @param {number} id - User ID to activate
* @param {number} activatedById - ID of the admin performing the action
*/
async function activateAdminUser(id, activatedById) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
// No "last super admin" guard needed — activate only ever ADDS an
// active super_admin, never removes one. No "can't activate
// yourself" guard either — by definition the actor is already
// logged in and active, so this can never be a self-activation.
if (user.is_active === true || user.is_active === 1) {
// Already active — short-circuit so the caller's UI doesn't have
// to special-case "no change" responses.
return;
}
await db('admin_users').where('id', id).update({
is_active: formatBoolean(true),
updated_at: new Date()
});
await logActivity('admin_user_activated',
{ userId: id, username: user.username },
null,
{ type: 'admin', id: activatedById, name: 'system' }
);
logger.info('Admin user activated', { userId: id, activatedById });
}
/**
* Permanently delete an admin user from the database. Use only on
* already-deactivated accounts (the UI nudges admins toward this
* order). All FK references to admin_users use ON DELETE SET NULL
* (created_by, recorded_by_admin_id, etc.) or ON DELETE CASCADE
* (api_tokens, pending invitations) — see migration audit in the
* #574-follow-up PR description for the full list.
*
* @param {number} id - User ID to delete
* @param {number} deletedById - ID of the admin performing the deletion
*/
async function deleteAdminUser(id, deletedById) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
// Self-delete would lock the actor out of their own session at the
// moment of commit. Refuse — same shape as the deactivate guard.
if (id === deletedById) {
throw new ValidationError('Cannot delete your own account');
}
// Last-super-admin guard — same logic as deactivate. Even if the
// target is currently is_active=false, deleting them would close
// the door on a super_admin role recovery (they could otherwise
// be reactivated). Counts ACTIVE super_admins so a deactivated
// user being deleted while one active super_admin exists is fine.
const superAdminRole = await db('roles').where('name', 'super_admin').first();
if (user.role_id === superAdminRole?.id) {
const activeSuperAdminCount = await db('admin_users')
.where('role_id', superAdminRole.id)
.where('is_active', formatBoolean(true))
.whereNot('id', id)
.count('id as count')
.first();
if (Number(activeSuperAdminCount?.count) < 1) {
throw new ValidationError('Cannot delete the last Super Admin');
}
}
// Hard delete. FK ON DELETE rules in core migrations handle cascade:
// SET NULL on created_by_admin_id everywhere (events, photos,
// quotes, invoices, contracts, etc.)
// CASCADE on api_tokens.user_id, admin_invitations.invited_by,
// customer_invitations.invited_by (drops pending tokens + invites
// this user issued)
await db('admin_users').where('id', id).del();
await logActivity('admin_user_deleted',
{ userId: id, username: user.username, email: user.email },
null,
{ type: 'admin', id: deletedById, name: 'system' }
);
logger.info('Admin user deleted', { userId: id, deletedById });
}
/**
* Reset admin user password (generates new password)
* @param {number} id - User ID
* @param {number} resetById - ID of user performing reset
* @returns {Promise<object>} Result with email and status
*/
async function resetAdminPassword(id, resetById) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
// OIDC-owned accounts (#798) have no usable local password by design —
// minting one here would hand out a login that bypasses the IdP's MFA
// and access policies.
if (user.auth_provider === 'oidc') {
throw new ValidationError('This account is managed by your identity provider (SSO) — reset the password there.');
}
const newPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
await db('admin_users').where('id', id).update({
password_hash: passwordHash,
must_change_password: formatBoolean(true),
password_changed_at: new Date(),
updated_at: new Date()
});
// Queue password reset email
await queueEmail(null, user.email, 'admin_password_reset', {
username: user.username,
new_password: newPassword
});
await logActivity('admin_password_reset',
{ userId: id, username: user.username },
null,
{ type: 'admin', id: resetById, name: 'system' }
);
logger.info('Admin password reset', { userId: id, resetById });
return { email: user.email, passwordSent: true };
}
/**
* Get all roles
* @returns {Promise<object[]>}
*/
async function getAllRoles() {
return db('roles')
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
.orderBy('priority', 'desc');
}
/**
* Get pending invitations
* @returns {Promise<object[]>}
*/
async function getPendingInvitations() {
return db('admin_invitations')
.join('roles', 'roles.id', 'admin_invitations.role_id')
.join('admin_users', 'admin_users.id', 'admin_invitations.invited_by')
.whereNull('admin_invitations.accepted_at')
.where('admin_invitations.expires_at', '>', new Date())
.select(
'admin_invitations.id',
'admin_invitations.email',
'admin_invitations.expires_at',
'admin_invitations.created_at',
'roles.display_name as role_name',
'admin_users.username as invited_by'
)
.orderBy('admin_invitations.created_at', 'desc');
}
/**
* Cancel/delete an invitation
* @param {number} id - Invitation ID
* @param {number} cancelledById - ID of user cancelling
*/
async function cancelInvitation(id, cancelledById) {
const invitation = await db('admin_invitations').where('id', id).first();
if (!invitation) {
throw new NotFoundError('Invitation', id);
}
await db('admin_invitations').where('id', id).del();
await logActivity('admin_invitation_cancelled',
{ invitationId: id, email: invitation.email },
null,
{ type: 'admin', id: cancelledById, name: 'system' }
);
logger.info('Admin invitation cancelled', { invitationId: id, cancelledById });
}
/**
* Validate an invitation token
* @param {string} token - Invitation token
* @returns {Promise<object|null>} Invitation details if valid
*/
async function validateInvitationToken(token) {
const invitation = await db('admin_invitations')
.join('roles', 'roles.id', 'admin_invitations.role_id')
.where('admin_invitations.token', token)
.whereNull('admin_invitations.accepted_at')
.where('admin_invitations.expires_at', '>', new Date())
.select(
'admin_invitations.email',
'admin_invitations.expires_at',
'roles.display_name as role_name'
)
.first();
return invitation || null;
}
// ---------------------------------------------------------------------------
// Role management (the role editor). Highly privileged — every mutation here is
// gated by `roles.manage` at the route layer. See project_permission_gating.
// ---------------------------------------------------------------------------
// System roles that ship with the app. Their `name` (the semantic key routes
// check) is immutable and they cannot be deleted; their display/description and
// (except super_admin) their permission set may be tweaked.
const RESERVED_ROLE_NAMES = ['super_admin', 'admin', 'editor', 'viewer', 'solo_photographer', 'team_photographer'];
function clearPermCache() {
// Bust the RBAC middleware cache so grant changes take effect immediately
// rather than waiting out its 60s TTL. Lazy-required to avoid a load cycle.
try { require('../middleware/permissions').clearPermissionCache(); } catch (_) { /* optional */ }
}
function normalizeRoleName(name) {
return String(name || '').trim().toLowerCase();
}
async function resolvePermissionIds(permissionNames) {
const names = Array.from(new Set((permissionNames || []).filter(Boolean)));
if (names.length === 0) return [];
const rows = await db('permissions').whereIn('name', names).select('id', 'name');
const found = new Set(rows.map((r) => r.name));
const missing = names.filter((n) => !found.has(n));
if (missing.length > 0) {
throw new ValidationError(`Unknown permission(s): ${missing.join(', ')}`);
}
return rows.map((r) => r.id);
}
// Contain the roles.manage blast radius (delegation, not root escalation): a
// non-super_admin managing roles may only grant permissions their OWN role
// already holds, so `roles.manage` can't be turned into "grant myself
// everything". super_admin bypasses (it holds the full catalog anyway).
async function assertActorMayGrant(actorId, permissionNames) {
const names = Array.from(new Set((permissionNames || []).filter(Boolean)));
if (names.length === 0) return;
const actor = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', actorId)
.select('roles.name as role_name')
.first();
if (actor && actor.role_name === 'super_admin') return;
const { userHasAllPermissions } = require('../middleware/permissions');
if (!(await userHasAllPermissions(actorId, names))) {
throw new ForbiddenError('You can only grant permissions your own role already holds.');
}
}
async function getRoleWithPermissionsById(id) {
const role = await db('roles')
.where('id', id)
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
.first();
if (!role) return null;
const permissions = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.where('role_permissions.role_id', id)
.pluck('permissions.name');
return { ...role, permissions: permissions.sort() };
}
/**
* All roles with their permission-name arrays and assigned-user counts. Backs
* the role-editor list.
*/
async function getRolesWithPermissions() {
const roles = await db('roles')
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
.orderBy('priority', 'desc');
const grants = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.select('role_permissions.role_id as role_id', 'permissions.name as name');
const userCounts = await db('admin_users')
.whereNotNull('role_id')
.select('role_id')
.count('* as count')
.groupBy('role_id');
const permsByRole = new Map();
for (const g of grants) {
if (!permsByRole.has(g.role_id)) permsByRole.set(g.role_id, []);
permsByRole.get(g.role_id).push(g.name);
}
const countByRole = new Map(userCounts.map((r) => [r.role_id, Number(r.count)]));
return roles.map((r) => ({
...r,
permissions: (permsByRole.get(r.id) || []).sort(),
user_count: countByRole.get(r.id) || 0,
}));
}
/**
* The full permission catalog (for the editor's matrix), ordered by category.
*/
async function getPermissionCatalog() {
return db('permissions')
.select('id', 'name', 'display_name', 'category', 'description')
.orderBy(['category', 'name']);
}
/**
* Create a custom role with an explicit permission set.
*/
async function createRole({ name, displayName, description, priority, permissions }, createdById) {
const normalized = normalizeRoleName(name);
if (!/^[a-z][a-z0-9_]{1,48}$/.test(normalized)) {
throw new ValidationError('Role name must be lowercase letters, numbers and underscores (249 chars, starting with a letter).');
}
if (RESERVED_ROLE_NAMES.includes(normalized)) {
throw new ConflictError(`"${normalized}" is a reserved system role name.`);
}
const existing = await db('roles').where('name', normalized).first();
if (existing) throw new ConflictError(`A role named "${normalized}" already exists.`);
await assertActorMayGrant(createdById, permissions);
const permIds = await resolvePermissionIds(permissions);
const rawPriority = Number(priority);
const safePriority = Number.isFinite(rawPriority) ? Math.max(0, Math.min(99, Math.floor(rawPriority))) : 50;
let roleId;
await db.transaction(async (trx) => {
const ins = await trx('roles').insert({
name: normalized,
display_name: displayName || normalized,
description: description || null,
is_system: false,
priority: safePriority,
created_at: trx.fn.now(),
updated_at: trx.fn.now(),
}).returning('id');
roleId = ins[0]?.id ?? ins[0];
if (permIds.length > 0) {
await trx('role_permissions').insert(permIds.map((pid) => ({ role_id: roleId, permission_id: pid })));
}
});
clearPermCache();
// logActivity AFTER commit — a global-db write inside the trx would deadlock
// on SQLite. See feedback_sqlite_global_write_in_transaction.
await logActivity('admin_role_created',
{ roleId, name: normalized, permissionCount: permIds.length },
null,
{ type: 'admin', id: createdById, name: 'system' });
logger.info('Role created', { roleId, name: normalized, createdById });
return getRoleWithPermissionsById(roleId);
}
/**
* Update a role's display/description/priority and/or replace its permission
* set. super_admin is fully protected; system roles keep their name + priority.
*/
async function updateRole(id, { displayName, description, priority, permissions }, updatedById) {
const role = await db('roles').where('id', id).first();
if (!role) throw new NotFoundError('Role', id);
if (role.name === 'super_admin') {
throw new ValidationError('The Super Admin role is protected — it always holds every permission and cannot be edited.');
}
// Self-amplification guard: a non-super_admin can't edit their OWN role (which
// would let a roles.manage holder grant their own role more), and can only
// grant permissions they already hold. See assertActorMayGrant.
const actor = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', updatedById)
.select('admin_users.role_id as role_id', 'roles.name as role_name')
.first();
const actorIsSuper = actor && actor.role_name === 'super_admin';
if (!actorIsSuper && actor && actor.role_id === Number(id)) {
throw new ForbiddenError('You cannot edit your own role.');
}
if (permissions !== undefined) {
await assertActorMayGrant(updatedById, permissions);
}
const patch = { updated_at: db.fn.now() };
if (displayName !== undefined) patch.display_name = displayName;
if (description !== undefined) patch.description = description;
if (priority !== undefined && !role.is_system) {
const p = Number(priority);
if (Number.isFinite(p)) patch.priority = Math.max(0, Math.min(99, Math.floor(p)));
}
let permIds = null;
if (permissions !== undefined) {
permIds = await resolvePermissionIds(permissions);
}
await db.transaction(async (trx) => {
await trx('roles').where('id', id).update(patch);
if (permIds !== null) {
await trx('role_permissions').where('role_id', id).del();
if (permIds.length > 0) {
await trx('role_permissions').insert(permIds.map((pid) => ({ role_id: id, permission_id: pid })));
}
}
});
clearPermCache();
await logActivity('admin_role_updated',
{ roleId: id, name: role.name, permissionsChanged: permIds !== null },
null,
{ type: 'admin', id: updatedById, name: 'system' });
logger.info('Role updated', { roleId: id, name: role.name, updatedById });
return getRoleWithPermissionsById(id);
}
/**
* Delete a custom role. System roles are protected; a role still assigned to
* users must be reassigned first (avoids leaving users permission-less).
*/
async function deleteRole(id, deletedById) {
const role = await db('roles').where('id', id).first();
if (!role) throw new NotFoundError('Role', id);
if (role.is_system) throw new ValidationError('System roles cannot be deleted.');
const assigned = await db('admin_users').where('role_id', id).count('* as count').first();
if (Number(assigned?.count) > 0) {
throw new ConflictError('Reassign the users holding this role before deleting it.');
}
await db.transaction(async (trx) => {
await trx('role_permissions').where('role_id', id).del();
await trx('roles').where('id', id).del();
});
clearPermCache();
await logActivity('admin_role_deleted',
{ roleId: id, name: role.name },
null,
{ type: 'admin', id: deletedById, name: 'system' });
logger.info('Role deleted', { roleId: id, name: role.name, deletedById });
}
/**
* Clone any role (including a preset) into a new custom role with the same
* permission set — the "start from a preset" flow.
*/
async function cloneRole(sourceId, { name, displayName, description }, createdById) {
const source = await db('roles').where('id', sourceId).first();
if (!source) throw new NotFoundError('Role', sourceId);
const permissions = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.where('role_permissions.role_id', sourceId)
.pluck('permissions.name');
return createRole({
name,
displayName: displayName || `${source.display_name} copy`,
description: description !== undefined ? description : source.description,
priority: source.is_system ? 50 : source.priority,
permissions,
}, createdById);
}
module.exports = {
createInvitation,
acceptInvitation,
getAllAdminUsers,
getAdminUserById,
updateAdminUser,
deactivateAdminUser,
activateAdminUser,
deleteAdminUser,
resetAdminPassword,
getAllRoles,
getRolesWithPermissions,
getPermissionCatalog,
getRoleWithPermissionsById,
createRole,
updateRole,
deleteRole,
cloneRole,
getPendingInvitations,
cancelInvitation,
validateInvitationToken
};