* 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]>
857 lines
29 KiB
JavaScript
857 lines
29 KiB
JavaScript
/**
|
||
* 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 (2–49 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
|
||
};
|