Release v2.20.1: migrate authentication to Better Auth

This commit is contained in:
lzstealth committed 2026-10-25 18:19:55 +00:00
1 parent e9a53f577c
commit fba4f99ff7
92 files changed
+5085 -1943

No files matched your search

+56
View File
@@ -5,6 +5,7 @@ const crypto = require('crypto');
const PASSWORD_ITERATIONS = 310000;
const PASSWORD_KEY_LENGTH = 32;
const PASSWORD_DIGEST = 'sha256';
const SCRYPT_OPTIONS = { N: 16384, r: 16, p: 1, keyLength: 64, maxmem: 128 * 16384 * 16 * 2 };
const SESSION_BYTES = 32;
function createOneTimeToken() {
@@ -71,6 +72,56 @@ function hashPassword(password, salt) {
};
}
function encodePasswordRecord(record) {
return ['pbkdf2-sha256', Number(record.iterations), record.salt, record.hash].join('$');
}
function verifyEncodedPassword(password, encodedPassword) {
const [algorithm, iterations, salt, hash] = String(encodedPassword || '').split('$');
if (algorithm !== 'pbkdf2-sha256' || !iterations || !salt || !hash) {
return false;
}
return verifyPassword(password, {
password_hash: hash,
password_salt: salt,
password_iterations: Number(iterations)
});
}
function deriveScryptKey(password, salt) {
return new Promise(function (resolve, reject) {
crypto.scrypt(String(password || '').normalize('NFKC'), salt, SCRYPT_OPTIONS.keyLength, SCRYPT_OPTIONS, function (error, key) {
if (error) return reject(error);
resolve(key);
});
});
}
async function hashScryptPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const key = await deriveScryptKey(password, salt);
return salt + ':' + key.toString('hex');
}
async function verifyScryptPassword(password, encodedPassword) {
const [salt, hash] = String(encodedPassword || '').split(':');
if (!/^[a-f0-9]{32}$/i.test(salt || '') || !/^[a-f0-9]{128}$/i.test(hash || '')) return false;
const expected = Buffer.from(hash, 'hex');
const actual = await deriveScryptKey(password, salt);
return crypto.timingSafeEqual(actual, expected);
}
async function verifyCredentialPassword(password, encodedPassword) {
if (String(encodedPassword || '').startsWith('pbkdf2-sha256$')) {
return verifyEncodedPassword(password, encodedPassword);
}
try {
return await verifyScryptPassword(password, encodedPassword);
} catch (_error) {
return false;
}
}
function verifyPassword(password, record) {
if (!record || !record.password_salt || !record.password_hash) {
return false;
@@ -93,6 +144,11 @@ function hashSessionToken(token) {
module.exports = {
hashPassword,
verifyPassword,
encodePasswordRecord,
verifyEncodedPassword,
hashScryptPassword,
verifyScryptPassword,
verifyCredentialPassword,
validatePasswordStrength,
createSessionToken,
hashSessionToken,
+15 -3
View File
@@ -1,8 +1,8 @@
const DEFAULT_ACCOUNT_EMAIL_TEMPLATES = {
verificationSubject: 'Verify your Pulse Signage email address',
verificationBody: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nConfirm this email address: [[url]]',
verificationBody: 'Hello [b][[display_name]][/b]!\nYour [u]verification code[/u] is [b][[otp]][/b].\nThis code expires in [i][[expiry_time]][/i].',
resetSubject: 'Reset your Pulse Signage password',
resetBody: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nChoose a new password: [[url]]'
resetBody: 'Hello [b][[display_name]][/b]!\nYour [u]password reset code[/u] is [b][[otp]][/b].\nThis code expires in [i][[expiry_time]][/i].'
};
function formatAccountEmailExpiry(value, unit) {
@@ -12,6 +12,18 @@ function formatAccountEmailExpiry(value, unit) {
return normalizedAmount + ' ' + normalizedUnit + (normalizedAmount === 1 ? '' : 's');
}
function normalizeOtpEmailBody(body) {
let normalizedBody = String(body || '')
.replace(/\[\[(?:action_button|action_link)\]\]/g, '')
.replace(/\[\[url\]\]/g, '[[otp]]')
.replace(/\b(?:verification )?links?\b/gi, 'verification code')
.replace(/\bURLs?\b/gi, 'code');
if (!normalizedBody.includes('[[otp]]')) {
normalizedBody += '\nYour one-time code is [[otp]].';
}
return normalizedBody;
}
function renderAccountEmailTemplate(subject, body, variables) {
const values = variables || {};
const replaceVariables = function (value) {
@@ -51,4 +63,4 @@ function renderAccountEmailTemplate(subject, body, variables) {
return { subject: renderedSubject, text: plainText, html: html };
}
module.exports = { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, renderAccountEmailTemplate };
module.exports = { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTemplate };
+3 -20
View File
@@ -7,20 +7,13 @@ const SETTING_DEFINITIONS = [
{ key: 'locale.timezone', type: 'string', defaultValue: 'Europe/London' },
{ key: 'locale.language', type: 'string', defaultValue: 'en' },
{ key: 'ui.theme', type: 'enum', values: ['dark', 'light', 'auto'], defaultValue: 'dark' },
{ key: 'security.session_lifetime_days', type: 'integer', min: 1, defaultValue: 14 },
{ key: 'security.allow_user_session_revocation', type: 'boolean', defaultValue: true },
{ key: 'security.max_active_sessions', type: 'integer', min: 0, defaultValue: 0 },
{ key: 'security.password_min_length', type: 'integer', min: 8, defaultValue: 10 },
{ key: 'security.password_min_categories', type: 'integer', min: 1, defaultValue: 3 },
{ key: 'security.password_require_lowercase', type: 'boolean', defaultValue: false },
{ key: 'security.password_require_uppercase', type: 'boolean', defaultValue: false },
{ key: 'security.password_require_number', type: 'boolean', defaultValue: false },
{ key: 'security.password_require_symbol', type: 'boolean', defaultValue: false },
{ key: 'security.require_password_change_for_new_users', type: 'boolean', defaultValue: true },
{ key: 'security.require_password_change_after_admin_reset', type: 'boolean', defaultValue: true },
{ key: 'security.login_max_attempts', type: 'integer', min: 1, defaultValue: 5 },
{ key: 'security.login_lockout_minutes', type: 'integer', min: 1, defaultValue: 15 },
{ key: 'security.login_rate_limit_scope', type: 'enum', values: ['both', 'username', 'ip'], defaultValue: 'both' },
{ key: 'security.allow_admin_email_verification_bypass', type: 'boolean', defaultValue: true },
{ key: 'email.smtp_enabled', type: 'boolean', defaultValue: false },
{ key: 'email.smtp_host', type: 'string', defaultValue: '' },
@@ -30,21 +23,11 @@ const SETTING_DEFINITIONS = [
{ key: 'email.smtp_password', type: 'string', defaultValue: '' },
{ key: 'email.from_address', type: 'string', defaultValue: '' },
{ key: 'email.from_name', type: 'string', defaultValue: 'Pulse Signage' },
{ key: 'email.verification_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 },
{ key: 'email.reset_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 },
{ key: 'email.invitation_expiry_hours', type: 'integer', min: 1, defaultValue: 24 },
{ key: 'email.otp_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 },
{ key: 'email.verification_subject', type: 'string', defaultValue: 'Verify your Pulse Signage email address' },
{ key: 'email.verification_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nConfirm this email address: [[url]]' },
{ key: 'email.verification_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\nYour [u]verification code[/u] is [b][[otp]][/b].\nThis code expires in [i][[expiry_time]][/i].' },
{ key: 'email.reset_subject', type: 'string', defaultValue: 'Reset your Pulse Signage password' },
{ key: 'email.reset_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nChoose a new password: [[url]]' },
{ key: 'email.verification_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
{ key: 'email.verification_button_text', type: 'string', defaultValue: 'Verify email address' },
{ key: 'email.reset_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
{ key: 'email.reset_button_text', type: 'string', defaultValue: 'Reset password' },
{ key: 'email.invitation_subject', type: 'string', defaultValue: 'You have been invited to Pulse Signage' },
{ key: 'email.invitation_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]invitation link[/u] expires in [i][[expiry_time]][/i].\nCreate your account: [[url]]' },
{ key: 'email.invitation_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
{ key: 'email.invitation_button_text', type: 'string', defaultValue: 'Accept invitation' },
{ key: 'email.reset_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\nYour [u]password reset code[/u] is [b][[otp]][/b].\nThis code expires in [i][[expiry_time]][/i].' },
{ key: 'audit.enabled', type: 'boolean', defaultValue: true },
{ key: 'audit.categories', type: 'string_array', defaultValue: ['authentication', 'security', 'sessions', 'users', 'roles', 'system-settings'] },
{ key: 'audit.screen_control_commands', type: 'string_array', defaultValue: [] },
+61 -10
View File
@@ -27,7 +27,7 @@ const AUDIT_CATEGORY_LABELS = Object.freeze({
sessions: 'Sessions',
users: 'Users',
roles: 'Roles',
'system-settings': 'System Settings',
'system-settings': 'System settings',
slides: 'Slides',
templates: 'Templates',
playlists: 'Playlists',
@@ -51,6 +51,7 @@ const SCREEN_CONTROL_COMMAND_LABELS = Object.freeze({
setclientname: 'Rename client'
});
const { fetchAppSettings } = require('#src/data/app-settings');
const { createHash } = require('node:crypto');
function formatUserAgentLabel(userAgent) {
const value = String(userAgent || '').trim();
@@ -81,20 +82,64 @@ function normalizeDetails(details) {
}
function buildAuditChanges(previousValues, nextValues) {
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
const changes = {};
const keys = new Set(Object.keys(previous).concat(Object.keys(next)));
keys.forEach(function (key) {
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
changes[key] = { from: previous[key], to: next[key] };
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function summarize(value, key) {
if (value === undefined) return null;
if ((key === 'qr_svg' || key === 'qr_preview') && typeof value === 'string' && value) {
return '[Generated asset: ' + Buffer.byteLength(value, 'utf8') + ' bytes; SHA-256 ' + createHash('sha256').update(value).digest('hex') + ']';
}
});
return value;
}
function compare(previous, next, path, key) {
if (Object.is(previous, next)) return;
if ((Array.isArray(previous) || previous == null) && (Array.isArray(next) || next == null) && (Array.isArray(previous) || Array.isArray(next))) {
const previousItems = previous || [];
const nextItems = next || [];
if (key === 'regions' && previousItems.concat(nextItems).every(function (item) { return isRecord(item) && typeof item.region_key === 'string'; }) &&
new Set(previousItems.map(function (item) { return item.region_key; })).size === previousItems.length &&
new Set(nextItems.map(function (item) { return item.region_key; })).size === nextItems.length) {
const previousRegions = new Map(previousItems.map(function (item) { return [item.region_key, item]; }));
const nextRegions = new Map(nextItems.map(function (item) { return [item.region_key, item]; }));
const regionKeys = new Set([...previousRegions.keys(), ...nextRegions.keys()]);
regionKeys.forEach(function (regionKey) {
compare(previousRegions.get(regionKey), nextRegions.get(regionKey), path + '.' + regionKey, regionKey);
});
return;
}
if (previousItems.concat(nextItems).some(function (item) { return item !== null && typeof item === 'object'; })) {
for (let index = 0; index < Math.max(previousItems.length, nextItems.length); index += 1) {
compare(previousItems[index], nextItems[index], path + '[' + index + ']', key);
}
return;
}
}
if ((isRecord(previous) || previous == null) && (isRecord(next) || next == null) && (isRecord(previous) || isRecord(next))) {
const previousRecord = isRecord(previous) ? previous : {};
const nextRecord = isRecord(next) ? next : {};
const keys = new Set(Object.keys(previousRecord).concat(Object.keys(nextRecord)));
if (keys.size) {
keys.forEach(function (childKey) {
compare(previousRecord[childKey], nextRecord[childKey], path ? path + '.' + childKey : childKey, childKey);
});
return;
}
}
if (JSON.stringify(previous) !== JSON.stringify(next)) {
Object.defineProperty(changes, path, {
value: { from: summarize(previous, key), to: summarize(next, key) },
enumerable: true
});
}
}
compare(previousValues || {}, nextValues || {}, '', '');
return changes;
}
const buildSlideAuditChanges = buildAuditChanges;
function getRequestMetadata(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return {
@@ -110,6 +155,11 @@ async function recordAuditEvent(pool, event) {
if (!category || !eventType) {
throw new Error('Audit events require a category and event type.');
}
const details = input.details;
if (eventType.endsWith('.updated') && details && Object.keys(details).length === 1 &&
details.changes && typeof details.changes === 'object' && !Array.isArray(details.changes) && !Object.keys(details.changes).length) {
return;
}
await pool.query(
`INSERT INTO o_audit_events
@@ -162,6 +212,7 @@ module.exports = {
formatUserAgentLabel,
getRequestMetadata,
buildAuditChanges,
buildSlideAuditChanges,
recordAuditEvent,
recordRequestAuditEvent
};
+58
View File
@@ -0,0 +1,58 @@
const crypto = require('crypto');
function createSyntheticAuthEmail(userId) {
const identifier = userId ? 'user-' + userId : crypto.randomUUID();
return 'app-' + identifier + '@identity.invalid';
}
function isSyntheticAuthEmail(value) {
return /@identity\.invalid$/i.test(String(value || '').trim());
}
function toVisibleEmail(value) {
const email = String(value || '').trim();
return isSyntheticAuthEmail(email) ? '' : email;
}
function mapAuthMethod(providerId) {
const id = String(providerId || '').trim();
if (!id) return null;
const knownLabels = {
credential: 'Password',
google: 'Google',
github: 'GitHub',
microsoft: 'Microsoft',
oidc: 'OpenID Connect'
};
const label = knownLabels[id] || id.split(/[._-]+/).filter(Boolean).map(function (part) {
return part.charAt(0).toUpperCase() + part.slice(1);
}).join(' ');
return { providerId: id, label: label, isCredential: id === 'credential' };
}
async function hashCredentialPassword(password) {
const { hashPassword } = await import('better-auth/crypto');
return hashPassword(String(password || ''));
}
async function setCredentialPassword(connection, userId, password) {
const encodedPassword = String(password || '').trim();
if (!encodedPassword) throw new Error('A credential password hash is required.');
const [users] = await connection.query(
'SELECT id, created_at, modified_at FROM a_users WHERE id = ? LIMIT 1',
[userId]
);
const user = users && users[0];
if (!user) return false;
const now = new Date();
await connection.query(
`INSERT INTO a_accounts (id, account_id, provider_id, user_id, password, created_at, updated_at)
VALUES (?, ?, 'credential', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE password = VALUES(password), updated_at = VALUES(updated_at)`,
['credential-' + user.id, String(user.id), user.id, encodedPassword, user.created_at || now, user.modified_at || now]
);
return true;
}
module.exports = { createSyntheticAuthEmail, isSyntheticAuthEmail, toVisibleEmail, mapAuthMethod, hashCredentialPassword, setCredentialPassword };
+9 -5
View File
@@ -1,5 +1,6 @@
const { hashPassword } = require('#src/auth');
// Startup seeding creates the default admin only for empty installs; migrations and user routes handle existing accounts.
const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
const { createSyntheticAuthEmail, hashCredentialPassword, setCredentialPassword } = require('#src/data/auth-accounts');
async function bootstrapDatabase(pool) {
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
@@ -36,11 +37,14 @@ async function bootstrapDatabase(pool) {
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT IGNORE INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
const encodedPassword = await hashCredentialPassword(password);
const [result] = await pool.query(
'INSERT IGNORE INTO a_users (name, username, email, email_verified, created_by, modified_by) VALUES (?, ?, ?, 0, NULL, NULL)',
[name, username, createSyntheticAuthEmail()]
);
if (result && result.insertId) {
await setCredentialPassword(pool, result.insertId, encodedPassword);
}
}
const [legacyRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', ['administrators']);
+63 -36
View File
@@ -420,20 +420,15 @@ async function ensureSchema(pool, options) {
await pool.query(`
CREATE TABLE IF NOT EXISTS a_users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL,
name VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(320) NULL,
email_verified_at DATETIME NULL,
email VARCHAR(320) NOT NULL UNIQUE,
email_verified TINYINT(1) NOT NULL DEFAULT 0,
image TEXT NULL,
pending_email VARCHAR(320) NULL,
pending_email_token_hash CHAR(64) NULL,
pending_email_expires_at DATETIME NULL,
password_hash CHAR(64) NOT NULL,
password_salt VARCHAR(64) NOT NULL,
password_iterations INT NOT NULL,
must_change_password TINYINT(1) NOT NULL DEFAULT 0,
account_locked TINYINT(1) NOT NULL DEFAULT 0,
last_login_at DATETIME NULL,
last_login_ip VARCHAR(255) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -442,33 +437,35 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_account_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
CREATE TABLE IF NOT EXISTS a_user_two_factors (
id VARCHAR(191) NOT NULL PRIMARY KEY,
user_id INT NOT NULL,
token_type VARCHAR(32) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_account_tokens_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
INDEX idx_account_tokens_lookup (token_type, token_hash, expires_at),
INDEX idx_account_tokens_user_type (user_id, token_type)
secret TEXT NOT NULL,
backup_codes TEXT NOT NULL,
verified TINYINT(1) NOT NULL DEFAULT 0,
failed_verification_count INT NOT NULL DEFAULT 0,
locked_until DATETIME NULL,
UNIQUE KEY uq_a_user_two_factors_user_id (user_id),
CONSTRAINT fk_a_user_two_factors_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_user_invitations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(320) NOT NULL,
CREATE TABLE IF NOT EXISTS a_user_passkeys (
id VARCHAR(191) NOT NULL PRIMARY KEY,
name VARCHAR(255) NULL,
role_ids_json TEXT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
INDEX idx_user_invitations_email (email, used_at, expires_at),
INDEX idx_user_invitations_created_by (created_by, created_at)
public_key TEXT NOT NULL,
user_id INT NOT NULL,
credential_id VARCHAR(512) NOT NULL,
counter BIGINT UNSIGNED NOT NULL DEFAULT 0,
device_type VARCHAR(32) NOT NULL,
backed_up TINYINT(1) NOT NULL DEFAULT 0,
transports TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
aaguid VARCHAR(36) NULL,
UNIQUE KEY uq_a_user_passkeys_credential_id (credential_id),
INDEX idx_a_user_passkeys_user_id (user_id),
CONSTRAINT fk_a_user_passkeys_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
@@ -478,6 +475,7 @@ async function ensureSchema(pool, options) {
role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
require_two_factor TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -540,7 +538,6 @@ async function ensureSchema(pool, options) {
user_agent VARCHAR(512) NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
@@ -548,15 +545,45 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_accounts (
id VARCHAR(191) NOT NULL PRIMARY KEY,
account_id VARCHAR(191) NOT NULL,
provider_id VARCHAR(191) NOT NULL,
user_id INT NOT NULL,
access_token TEXT NULL,
refresh_token TEXT NULL,
id_token TEXT NULL,
access_token_expires_at DATETIME NULL,
refresh_token_expires_at DATETIME NULL,
scope TEXT NULL,
password TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uq_accounts_provider_account (provider_id, account_id),
INDEX idx_accounts_user_id (user_id),
CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_verifications (
id VARCHAR(191) NOT NULL PRIMARY KEY,
identifier VARCHAR(191) NOT NULL,
value TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_verifications_identifier (identifier)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_login_attempts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
rate_key VARCHAR(600) NOT NULL UNIQUE,
failed_count INT NOT NULL DEFAULT 0,
last_failed_at DATETIME NULL,
locked_until DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
last_request BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
+153 -1
View File
@@ -1,6 +1,6 @@
// Ordered database migrations kept independent from the application version.
const { version: appVersion } = require('#root/package.json');
const { encodePasswordRecord } = require('#src/auth');
const { parseJsonSafe } = require('#src/data/utils');
const { collectContentReferences, insertContentReferences } = require('#src/data/content-references');
const TIMETABLE_TIME_ZONE = 'Europe/London';
@@ -874,6 +874,158 @@ const VERSIONED_MIGRATIONS = [
throw error;
}
}
},
{
version: '2.20.0',
label: 'v2.20.0 Better Auth account schema',
run: async function (pool) {
await ensureColumn(pool, 'a_users', 'email_verified', 'TINYINT(1) NOT NULL DEFAULT 0', 'email');
await ensureColumn(pool, 'a_users', 'image', 'TEXT NULL', 'email_verified');
await ensureColumn(pool, 'a_login_attempts', 'last_request', 'BIGINT NOT NULL DEFAULT 0', 'failed_count');
await pool.query("UPDATE a_users SET name = COALESCE(NULLIF(name, ''), username) WHERE name IS NULL OR name = ''");
await pool.query('ALTER TABLE a_users MODIFY COLUMN name VARCHAR(255) NOT NULL');
await pool.query('DELETE FROM a_sessions');
const obsoleteColumns = [
['a_users', 'pending_email_token_hash'],
['a_users', 'pending_email_expires_at'],
['a_users', 'last_login_at'],
['a_users', 'last_login_ip'],
['a_login_attempts', 'last_failed_at'],
['a_login_attempts', 'locked_until'],
['a_login_attempts', 'created_at'],
['a_login_attempts', 'modified_at']
];
for (const [tableName, columnName] of obsoleteColumns) {
if (await columnExists(pool, tableName, columnName)) {
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
}
}
if (await tableExists(pool, 'a_account_tokens')) {
await pool.query('DROP TABLE a_account_tokens');
}
await pool.query("DELETE FROM o_app_settings WHERE setting_key IN ('email.verification_button_alignment', 'email.verification_button_text', 'email.reset_button_alignment', 'email.reset_button_text')");
const oldExpiryKeys = ['email.verification_expiry_minutes', 'email.reset_expiry_minutes'];
const [expiryRows] = await pool.query(
'SELECT setting_key, setting_value FROM o_app_settings WHERE setting_key IN (?, ?, ?)',
['email.otp_expiry_minutes'].concat(oldExpiryKeys)
);
const hasOtpExpiry = (expiryRows || []).some(row => row.setting_key === 'email.otp_expiry_minutes');
if (!hasOtpExpiry) {
const oldExpiries = (expiryRows || [])
.filter(row => oldExpiryKeys.includes(row.setting_key))
.map(row => Number(parseJsonSafe(row.setting_value)))
.filter(value => Number.isInteger(value) && value > 0);
if (oldExpiries.length) {
await pool.query(
'INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by) VALUES (?, ?, NULL, NULL)',
['email.otp_expiry_minutes', JSON.stringify(Math.min.apply(null, oldExpiries))]
);
}
}
await pool.query('DELETE FROM o_app_settings WHERE setting_key IN (?, ?)', oldExpiryKeys);
if (await columnExists(pool, 'a_sessions', 'created_by')) {
await pool.query('ALTER TABLE a_sessions DROP COLUMN created_by');
}
await pool.query('DELETE FROM o_app_settings WHERE setting_key = ?', ['security.require_password_change_after_admin_reset']);
await pool.query('DELETE FROM o_app_settings WHERE setting_key = ?', ['security.require_password_change_for_new_users']);
const [duplicateEmailRows] = await pool.query(`
SELECT LOWER(TRIM(email)) AS email_key, COUNT(*) AS duplicate_count
FROM a_users
WHERE email IS NOT NULL AND TRIM(email) <> ''
GROUP BY LOWER(TRIM(email))
HAVING COUNT(*) > 1
LIMIT 1
`);
if (duplicateEmailRows && duplicateEmailRows.length) {
throw new Error('Cannot migrate to canonical Better Auth emails while duplicate user email addresses exist. Resolve duplicate emails and retry.');
}
const hasLegacyVerificationTimestamp = await columnExists(pool, 'a_users', 'email_verified_at');
const [canonicalEmailUsers] = await pool.query(hasLegacyVerificationTimestamp
? 'SELECT id, email, email_verified_at FROM a_users ORDER BY id ASC'
: 'SELECT id, email, email_verified FROM a_users ORDER BY id ASC');
for (const user of canonicalEmailUsers || []) {
const email = String(user.email || '').trim().toLowerCase() || 'app-user-' + user.id + '@identity.invalid';
const emailVerified = Boolean((hasLegacyVerificationTimestamp ? user.email_verified_at : user.email_verified) && !email.endsWith('@identity.invalid'));
await pool.query('UPDATE a_users SET email = ?, email_verified = ? WHERE id = ?', [email, emailVerified, user.id]);
}
if (await columnExists(pool, 'a_users', 'email_verified_at')) await pool.query('ALTER TABLE a_users DROP COLUMN email_verified_at');
await pool.query('ALTER TABLE a_users MODIFY COLUMN email VARCHAR(320) NOT NULL');
const [emailIndexRows] = await pool.query(`
SELECT COUNT(*) AS index_count
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'a_users' AND COLUMN_NAME = 'email' AND NON_UNIQUE = 0
`);
if (!Number(emailIndexRows && emailIndexRows[0] && emailIndexRows[0].index_count)) {
await pool.query('ALTER TABLE a_users ADD UNIQUE INDEX uq_a_users_email (email)');
}
const passwordColumns = await Promise.all([
columnExists(pool, 'a_users', 'password_hash'),
columnExists(pool, 'a_users', 'password_salt'),
columnExists(pool, 'a_users', 'password_iterations')
]);
if (passwordColumns.some(Boolean) && !passwordColumns.every(Boolean)) {
throw new Error('Cannot move password records to Better Auth accounts because the legacy password columns are incomplete.');
}
if (passwordColumns.every(Boolean)) {
const [passwordUsers] = await pool.query('SELECT id, password_hash, password_salt, password_iterations, created_at, modified_at FROM a_users ORDER BY id ASC');
for (const user of passwordUsers || []) {
const hash = String(user.password_hash || '').trim();
const salt = String(user.password_salt || '').trim();
const iterations = Number(user.password_iterations);
if (!hash && !salt && !iterations) {
continue;
}
if (!/^[a-f0-9]{64}$/i.test(hash) || !salt || !Number.isSafeInteger(iterations) || iterations <= 0) {
throw new Error('Cannot move an incomplete password record for user ' + user.id + '.');
}
const encodedPassword = encodePasswordRecord({ hash: hash, salt: salt, iterations: iterations });
const createdAt = user.created_at || new Date();
const updatedAt = user.modified_at || createdAt;
await pool.query(
`INSERT INTO a_accounts (id, account_id, provider_id, user_id, password, created_at, updated_at)
VALUES (?, ?, 'credential', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE password = VALUES(password), updated_at = VALUES(updated_at)`,
['credential-' + user.id, String(user.id), user.id, encodedPassword, createdAt, updatedAt]
);
}
await pool.query('ALTER TABLE a_users DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations');
}
await pool.query('DROP TABLE IF EXISTS a_user_invitations');
await pool.query(`
DELETE FROM a_role_permissions
WHERE permission_id IN (
SELECT id FROM a_permissions WHERE permission_key LIKE 'invitations.%'
)
`);
await pool.query("DELETE FROM a_permissions WHERE permission_key LIKE 'invitations.%'");
await pool.query("DELETE FROM o_app_settings WHERE setting_key LIKE 'email.invitation_%'");
await ensureColumn(pool, 'a_users', 'two_factor_enabled', 'TINYINT(1) NOT NULL DEFAULT 0', 'account_locked');
await ensureColumn(pool, 'a_roles', 'require_two_factor', 'TINYINT(1) NOT NULL DEFAULT 0', 'description');
}
},
{
version: '2.20.1',
label: 'v2.20.1 remove retired security settings',
run: async function (pool) {
await pool.query(
'DELETE FROM o_app_settings WHERE setting_key IN (?, ?, ?, ?, ?)',
[
'security.session_lifetime_days',
'security.max_active_sessions',
'security.login_max_attempts',
'security.login_lockout_minutes',
'security.login_rate_limit_scope'
]
);
}
}
];
+4 -5
View File
@@ -4,7 +4,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { verifyPassword, createSessionToken, hashSessionToken } = require('#src/auth');
const { verifyCredentialPassword, createSessionToken, hashSessionToken } = require('#src/auth');
const { verifyRequestAuth } = require('#src/request-auth');
const DEFAULT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -66,11 +66,10 @@ function createLocalControlService(options) {
return (Array.isArray(users) ? users : []).map(function (user) {
return {
username_hash: String(user && (user.username_hash || fingerprintUsername(user.username)) || '').trim(),
password_hash: String(user && user.password_hash || '').trim(),
password_salt: String(user && user.password_salt || '').trim()
password_hash: String(user && user.password_hash || '').trim()
};
}).filter(function (user) {
return Boolean(user.username_hash && user.password_hash && user.password_salt);
return Boolean(user.username_hash && user.password_hash);
});
}
@@ -509,7 +508,7 @@ function createLocalControlService(options) {
const user = cache.users.find(function (candidate) {
return candidate.username_hash === fingerprintUsername(username);
});
if (!user || !verifyPassword(String(req.body && req.body.password || ''), user)) {
if (!user || !(await verifyCredentialPassword(String(req.body && req.body.password || ''), user.password_hash))) {
recordLoginFailure(req, username);
return res.status(401).json({ error: 'Invalid local control credentials.' });
}
+8 -11
View File
@@ -201,17 +201,6 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
]
},
{
key: 'invitations',
order: 15,
name: 'Invitations',
permissions: [
{ key: 'read', name: 'Read', description: 'View pending user invitations.' },
{ key: 'create', name: 'Create', description: 'Send new user invitations.' },
{ key: 'delete', name: 'Delete', description: 'Delete pending user invitations.' },
{ key: 'allow', name: 'Allow', description: 'Resend pending user invitations.' }
]
},
{
key: 'impersonate',
order: 25,
@@ -220,6 +209,14 @@ const PERMISSION_SECTIONS = [
{ key: 'allow', name: 'Allow', description: 'Preview a user account in read-only mode.' }
]
},
{
key: 'login-lockouts',
order: 26,
name: 'Login lockouts',
permissions: [
{ key: 'allow', name: 'Allow', description: 'Reset sign-in and two-factor request lockouts for all clients.' }
]
},
{
key: 'rbac',
order: 20,
+43 -22
View File
@@ -6,7 +6,7 @@ const multer = require('multer');
const fs = require('fs');
const crypto = require('crypto');
const common = require('#src/common');
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength, createOneTimeToken } = require('#src/auth');
const { verifyPassword, hashSessionToken, hashPassword, validatePasswordStrength, createOneTimeToken } = require('#src/auth');
const pages = require('#src/web/pages/index');
const registerMiddleware = require('#src/web/middleware');
const registerNotFoundHandler = require('#src/web/middleware/not-found');
@@ -24,6 +24,7 @@ const { rbacData } = require('#src/web/lib/auth/index');
const { createPlayerActionService } = require('#src/web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index');
const { createBetterAuth, getRoleTwoFactorAuthError } = require('#src/web/lib/auth/better-auth');
const { fetchAppSettings } = require('#src/data/app-settings');
const { sendAccountEmail } = require('#src/data/mailer');
const { recordRequestAuditEvent } = require('#src/data/audit-log');
@@ -51,6 +52,20 @@ async function start() {
const server = http.createServer(app);
const pool = common.createPool();
const webConfig = createWebConfig();
fs.mkdirSync(webConfig.mediaDir, { recursive: true });
await ensureFontLibrary(webConfig.mediaDir);
await common.ensureSchema(pool, { mediaDir: webConfig.mediaDir });
await common.bootstrapDatabase(pool);
const betterAuthService = await createBetterAuth(pool, {
secret: process.env.AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL,
sessionCookieName: webConfig.sessionCookieName,
sendAccountEmail: function (settings, message) { return sendAccountEmail(settings, message); }
});
const betterAuth = betterAuthService.auth;
const toNodeHandler = betterAuthService.toNodeHandler;
const fromNodeHeaders = betterAuthService.fromNodeHeaders;
const betterAuthHandler = toNodeHandler(betterAuth);
// Background services and request forwarding helpers.
const backgroundTaskQueue = createBackgroundTaskQueue({
@@ -99,30 +114,38 @@ async function start() {
// Session and auth helpers for route registration.
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
const sessionService = createSessionService({
sessionCookieName: webConfig.sessionCookieName,
sessionMaxAgeMs: webConfig.sessionMaxAgeMs,
getConfiguredSessionMaxAgeMs: async function () {
const settings = await fetchAppSettings(pool);
return Number(settings['security.session_lifetime_days']) * 24 * 60 * 60 * 1000;
},
getConfiguredMaxActiveSessions: async function () {
const settings = await fetchAppSettings(pool);
return Number(settings['security.max_active_sessions']);
},
hashSessionToken: hashSessionToken,
createSessionToken: createSessionToken
sessionCookieName: webConfig.sessionCookieName
});
const parseCookies = sessionService.parseCookies;
const createUserSession = sessionService.createUserSession;
const clearSessionCookie = sessionService.clearSessionCookie;
const setSessionCookie = sessionService.setSessionCookie;
const getSessionMaxAgeMs = sessionService.getSessionMaxAgeMs;
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
const loadCurrentUser = sessionService.loadCurrentUser;
const loadCurrentUser = async function (pool, req) {
const authSession = await betterAuth.api.getSession({ headers: fromNodeHeaders(req.headers) });
if (!authSession || !authSession.session || !authSession.session.token) return null;
req.betterAuthSession = authSession;
return sessionService.loadCurrentUser(pool, req, authSession.session.token);
};
const requireAuth = sessionService.requireAuth;
const startViewAsUser = sessionService.startViewAsUser;
const stopViewAsUser = sessionService.stopViewAsUser;
app.all('/api/auth/*splat', async function (req, res, next) {
try {
const currentUser = await loadCurrentUser(pool, req);
const policyError = getRoleTwoFactorAuthError(currentUser, req.path);
if (policyError) return res.status(403).json(policyError);
const authPath = String(req.path || '');
if (currentUser && currentUser.isViewingAs && (authPath.startsWith('/api/auth/two-factor/') || authPath.startsWith('/api/auth/passkey/'))) {
return res.status(403).json({ message: 'Exit View as before changing authentication settings.' });
}
if (!req.headers['x-forwarded-for'] && req.socket && req.socket.remoteAddress) {
req.headers['x-forwarded-for'] = req.socket.remoteAddress;
}
return betterAuthHandler(req, res, next);
} catch (error) {
next(error);
}
});
// Middleware and route registration.
registerMiddleware(app, {
pool: pool,
@@ -137,16 +160,15 @@ async function start() {
registerRoutes(app, {
pool: pool,
betterAuth: betterAuth,
fromNodeHeaders: fromNodeHeaders,
common: common,
pages: pages,
upload: upload,
mediaDir: webConfig.mediaDir,
uploadDir: webConfig.uploadsDir,
createUserSession: createUserSession,
startViewAsUser: startViewAsUser,
stopViewAsUser: stopViewAsUser,
setSessionCookie: setSessionCookie,
getSessionMaxAgeMs: getSessionMaxAgeMs,
clearSessionCookie: clearSessionCookie,
setAuthMessageCookie: setAuthMessageCookie,
consumeAuthMessageCookie: consumeAuthMessageCookie,
@@ -235,11 +257,10 @@ async function start() {
});
// Bootstrap and startup tasks.
fs.mkdirSync(webConfig.mediaDir, { recursive: true });
await ensureFontLibrary(webConfig.mediaDir);
await initializeWebServer({
common: common,
pool: pool,
databaseInitialized: true,
mediaDir: webConfig.mediaDir,
backgroundTaskQueue: backgroundTaskQueue,
webBootstrap: webBootstrap,
+320
View File
@@ -0,0 +1,320 @@
const crypto = require('crypto');
const { isIP } = require('node:net');
const { verifyEncodedPassword } = require('#src/auth');
const { fetchAppSettings } = require('#src/data/app-settings');
const { formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const LOGIN_RATE_LIMIT_PATHS = ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code'];
function getRoleTwoFactorAuthError(currentUser, requestPath) {
const actor = currentUser && (currentUser.authenticatedUser || currentUser);
if (!actor || !actor.requiresTwoFactor) return null;
const path = String(requestPath || '').replace(/\/+$/, '');
if (path === '/api/auth/two-factor/disable') {
return { code: 'TWO_FACTOR_REQUIRED_BY_ROLE', message: 'Two-factor authentication is required by one of your roles and cannot be disabled.' };
}
const setupPaths = ['/api/auth/get-session', '/api/auth/sign-out', '/api/auth/verify-password', '/api/auth/two-factor/enable', '/api/auth/two-factor/get-totp-uri', '/api/auth/two-factor/verify-totp', '/api/auth/two-factor/generate-backup-codes'];
if (!actor.twoFactorEnabled && !setupPaths.includes(path)) {
return { code: 'TWO_FACTOR_ENROLLMENT_REQUIRED', message: 'Set up two-factor authentication in My account to continue.' };
}
return null;
}
function createLoginRateLimitResetHook(pool) {
return async function (ctx) {
const newSession = ctx.context.newSession;
if (!LOGIN_RATE_LIMIT_PATHS.includes(ctx.path) || !newSession || !newSession.session) return;
const ipAddress = newSession.session.ipAddress || 'no-trusted-ip';
await pool.query(
'DELETE FROM a_login_attempts WHERE rate_key IN (?, ?, ?, ?)',
LOGIN_RATE_LIMIT_PATHS.map(function (path) { return ipAddress + '|' + path; })
);
};
}
function createPasskeyAuthenticationGuard(pool, APIError) {
return async function ({ clientData }) {
const [rows] = await pool.query(
`SELECT u.two_factor_enabled,
EXISTS (
SELECT 1
FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = u.id AND r.require_two_factor = 1
) AS role_requires_two_factor
FROM a_user_passkeys p
JOIN a_users u ON u.id = p.user_id
WHERE p.credential_id = ?
LIMIT 1`,
[String(clientData && clientData.id || '')]
);
const user = rows && rows[0];
if (user && (Number(user.two_factor_enabled) || Number(user.role_requires_two_factor))) {
throw new APIError('FORBIDDEN', { message: 'This account requires authenticator verification. Sign in with your username and password.' });
}
};
}
function resolvePasskeyRpID(baseURL) {
const url = String(baseURL || '').trim() ? new URL(String(baseURL).trim()) : null;
if (!url) return 'localhost';
return url.protocol === 'http:' && isIP(url.hostname) ? 'localhost' : url.hostname;
}
function createAccountLockSessionHook(pool, APIError) {
return async function (session) {
const [users] = await pool.query('SELECT account_locked FROM a_users WHERE id = ? LIMIT 1', [Number(session.userId)]);
if (users && users[0] && users[0].account_locked) {
throw new APIError('FORBIDDEN', { message: 'This account is locked. Contact an administrator.' });
}
};
}
function createPasswordCallbacks(pool, passwordCrypto) {
return {
hash: function (password) {
return passwordCrypto.hashPassword(password);
},
verify: async function ({ password, hash }) {
const storedHash = String(hash || '');
if (storedHash.startsWith('pbkdf2-sha256$')) {
if (!verifyEncodedPassword(password, storedHash)) return false;
const upgradedHash = await passwordCrypto.hashPassword(password);
await pool.query(
'UPDATE a_accounts SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE provider_id = ? AND password = ?',
[upgradedHash, 'credential', storedHash]
);
return true;
}
try {
return await passwordCrypto.verifyPassword({ hash: storedHash, password: password });
} catch (_error) {
return false;
}
}
};
}
async function createBetterAuth(pool, options) {
const [betterAuthModule, pluginsModule, passkeyModule, nodeModule, apiModule, passwordCryptoModule] = await Promise.all([
import('better-auth'),
import('better-auth/plugins'),
import('@better-auth/passkey'),
import('better-auth/node'),
import('better-auth/api'),
import('better-auth/crypto')
]);
const secret = String(options && options.secret || '').trim();
if (!secret) {
throw new Error('AUTH_SECRET is required.');
}
const baseURL = String(options && options.baseURL || '').trim();
const authUrl = baseURL ? new URL(baseURL) : null;
const additionalTrustedOrigins = String(options && options.trustedOrigins || process.env.AUTH_TRUSTED_ORIGINS || '')
.split(',')
.map(function (origin) { return origin.trim().replace(/\/$/, ''); })
.filter(Boolean);
const trustedOrigins = Array.from(new Set([baseURL].concat(additionalTrustedOrigins).filter(Boolean)));
const settings = await fetchAppSettings(pool);
const sendAccountEmail = options && options.sendAccountEmail;
const otpExpiryMinutes = Number(settings['email.otp_expiry_minutes']) || 30;
const auth = betterAuthModule.betterAuth({
database: pool,
secret: secret,
appName: 'Pulse Signage',
baseURL: baseURL || undefined,
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
user: {
modelName: 'a_users',
fields: {
email: 'email',
emailVerified: 'email_verified',
twoFactorEnabled: 'two_factor_enabled',
createdAt: 'created_at',
updatedAt: 'modified_at',
}
},
session: {
modelName: 'a_sessions',
expiresIn: 14 * 24 * 60 * 60,
fields: {
expiresAt: 'expires_at',
token: 'session_hash',
createdAt: 'created_at',
updatedAt: 'last_used_at',
ipAddress: 'ip_address',
userAgent: 'user_agent',
userId: 'user_id'
}
},
account: {
modelName: 'a_accounts',
fields: {
accountId: 'account_id',
providerId: 'provider_id',
userId: 'user_id',
accessToken: 'access_token',
refreshToken: 'refresh_token',
idToken: 'id_token',
accessTokenExpiresAt: 'access_token_expires_at',
refreshTokenExpiresAt: 'refresh_token_expires_at',
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
verification: {
modelName: 'a_verifications',
fields: {
expiresAt: 'expires_at',
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
emailAndPassword: {
enabled: true,
password: createPasswordCallbacks(pool, passwordCryptoModule)
},
rateLimit: {
enabled: true,
storage: 'database',
modelName: 'a_login_attempts',
fields: {
key: 'rate_key',
count: 'failed_count',
lastRequest: 'last_request'
},
window: 900,
max: 30,
customRules: {
'/sign-in/username': { window: 900, max: 5 }
}
},
plugins: [
pluginsModule.username({
maxUsernameLength: 255,
displayUsername: false,
usernameValidator: function (username) {
return typeof username === 'string' && username.trim().length > 0 && username.length <= 255;
}
}),
pluginsModule.emailOTP({
disableSignUp: true,
expiresIn: otpExpiryMinutes * 60,
allowedAttempts: 5,
storeOTP: 'hashed',
changeEmail: { enabled: true, verifyCurrentEmail: false },
sendVerificationOTP: async function ({ email, otp, type }) {
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') {
throw new Error('Email delivery is not configured.');
}
const reset = type === 'forget-password';
const configuredBody = String(settings[reset ? 'email.reset_body' : 'email.verification_body'] || '');
const body = normalizeOtpEmailBody(configuredBody);
const expiryText = formatAccountEmailExpiry(otpExpiryMinutes, 'minutes');
const message = renderAccountEmailTemplate(settings[reset ? 'email.reset_subject' : 'email.verification_subject'], body, {
display_name: email,
email: email,
otp: otp,
expiry_time: expiryText
});
await sendAccountEmail(settings, Object.assign({ to: email }, message));
}
}),
pluginsModule.twoFactor({
issuer: 'Pulse Signage',
twoFactorTable: 'a_user_two_factors',
schema: {
user: { fields: { twoFactorEnabled: 'two_factor_enabled' } },
twoFactor: {
fields: {
userId: 'user_id',
backupCodes: 'backup_codes',
failedVerificationCount: 'failed_verification_count',
lockedUntil: 'locked_until'
}
}
}
}),
passkeyModule.passkey({
rpID: resolvePasskeyRpID(baseURL),
rpName: 'Pulse Signage',
schema: {
passkey: {
modelName: 'a_user_passkeys',
fields: {
name: 'name',
publicKey: 'public_key',
userId: 'user_id',
credentialID: 'credential_id',
counter: 'counter',
deviceType: 'device_type',
backedUp: 'backed_up',
transports: 'transports',
createdAt: 'created_at',
aaguid: 'aaguid'
}
}
},
authentication: {
afterVerification: createPasskeyAuthenticationGuard(pool, apiModule.APIError)
}
}),
{
id: 'pulse-login-rate-limit-reset',
hooks: {
after: [{
matcher: function (ctx) { return LOGIN_RATE_LIMIT_PATHS.includes(ctx.path); },
handler: apiModule.createAuthMiddleware(createLoginRateLimitResetHook(pool))
}]
}
}
],
disabledPaths: ['/sign-up/email', '/forget-password', '/reset-password', '/change-password', '/send-verification-email', '/verify-email'],
advanced: {
ipAddress: {
ipAddressHeaders: ['x-forwarded-for']
},
useSecureCookies: false,
defaultCookieAttributes: {
secure: baseURL.startsWith('https://')
},
database: {
generateId: function (options) {
const model = String(options && options.model || '');
if (model === 'user' || model === 'a_users' || model === 'session' || model === 'a_sessions' || model === 'rateLimit' || model === 'a_login_attempts') {
return false;
}
return crypto.randomUUID();
}
},
cookies: {
session_token: {
name: String(options && options.sessionCookieName || 'digital_signage_session')
}
}
},
databaseHooks: {
session: {
create: {
before: createAccountLockSessionHook(pool, apiModule.APIError),
}
}
}
});
return {
auth: auth,
toNodeHandler: nodeModule.toNodeHandler,
fromNodeHeaders: nodeModule.fromNodeHeaders
};
}
module.exports = {
createBetterAuth,
createPasswordCallbacks,
createAccountLockSessionHook,
createLoginRateLimitResetHook,
createPasskeyAuthenticationGuard,
resolvePasskeyRpID,
getRoleTwoFactorAuthError
};
+23 -30
View File
@@ -2,6 +2,7 @@
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
const { fetchPagedRows } = require('#src/data/utils');
const { toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
function parseCsvIds(value) {
return String(value || '')
@@ -57,35 +58,9 @@ async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDir
return Object.assign({ roles: paged.rows }, paged);
}
async function fetchInvitationsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT i.id, i.email, i.name, i.role_ids_json, i.created_at, i.expires_at, u.username AS created_by_username
FROM a_user_invitations i
LEFT JOIN a_users u ON u.id = i.created_by
WHERE i.used_at IS NULL AND i.expires_at > NOW()
ORDER BY i.created_at DESC`,
countSql: 'SELECT COUNT(*) AS count FROM a_user_invitations WHERE used_at IS NULL AND expires_at > NOW()',
searchColumns: ['i.email', 'i.name', 'u.username'],
searchTerm: searchTerm,
sortColumns: {
email: 'i.email',
name: 'i.name',
created: 'i.created_at',
expires: 'i.expires_at',
createdBy: 'u.username'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ invitations: paged.rows }, paged);
}
async function fetchRoleById(pool, roleId) {
const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
`SELECT r.id, r.role_key, r.name, r.description, r.require_two_factor, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM a_roles r
@@ -168,7 +143,7 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT u.id, u.name, u.username, u.email, u.email_verified_at, u.account_locked, u.created_at, u.modified_at,
selectSql: `SELECT u.id, u.name, u.username, u.email, u.email_verified, u.account_locked, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM a_users u
@@ -202,6 +177,8 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
return {
users: (paged.rows || []).map(function (row) {
return Object.assign({}, row, {
email: toVisibleEmail(row.email),
email_verified: Boolean(row.email_verified),
roleIds: parseCsvIds(row.role_ids_csv),
roleNames: String(row.role_names || '').trim()
});
@@ -215,10 +192,12 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
async function fetchUserWithRoles(pool, userId) {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.username, u.email, u.email_verified_at, u.account_locked, u.created_at, u.modified_at,
`SELECT u.id, u.name, u.username, u.email, u.email_verified, u.account_locked, u.must_change_password, u.two_factor_enabled, u.created_at, u.modified_at,
tf.id AS two_factor_id, tf.verified AS two_factor_verified,
COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM a_users u
LEFT JOIN a_user_two_factors tf ON tf.user_id = u.id
LEFT JOIN (
SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
@@ -236,7 +215,22 @@ async function fetchUserWithRoles(pool, userId) {
return null;
}
const [accountRows] = await pool.query('SELECT provider_id FROM a_accounts WHERE user_id = ? ORDER BY created_at ASC', [userId]);
const authMethods = (accountRows || []).map(function (account) {
return mapAuthMethod(account && account.provider_id);
}).filter(Boolean);
const [passkeyRows] = await pool.query('SELECT id, name, created_at FROM a_user_passkeys WHERE user_id = ? ORDER BY created_at ASC', [userId]);
const twoFactorEnabled = Boolean(rows[0].two_factor_enabled);
const twoFactorSetupPending = Boolean(rows[0].two_factor_id && !rows[0].two_factor_verified);
return Object.assign({}, rows[0], {
email: toVisibleEmail(rows[0].email),
email_verified: Boolean(rows[0].email_verified),
twoFactorEnabled: twoFactorEnabled,
twoFactorSetupPending: twoFactorSetupPending,
canUnlinkTwoFactor: twoFactorEnabled || twoFactorSetupPending,
authMethods: authMethods,
passkeys: passkeyRows || [],
roleIds: parseCsvIds(rows[0].role_ids_csv),
roleNames: String(rows[0].role_names || '').trim()
});
@@ -325,7 +319,6 @@ module.exports = {
fetchPermissions,
fetchRoles,
fetchRolesPage,
fetchInvitationsPage,
fetchRoleById,
fetchRolePermissionKeys,
fetchRoleUserIds,
+32 -74
View File
@@ -1,6 +1,7 @@
// Session cookie and current-user helpers for the web auth flow.
const { hasPermission, normalizePermissionKeys } = require('#src/rbac');
const { toVisibleEmail } = require('#src/data/auth-accounts');
function getRequestOrigin(req) {
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
@@ -33,15 +34,10 @@ function normalizeReturnToPath(value, baseUrl) {
function createSessionService(options) {
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
const getConfiguredSessionMaxAgeMs = options && options.getConfiguredSessionMaxAgeMs;
const getConfiguredMaxActiveSessions = options && options.getConfiguredMaxActiveSessions;
const hashSessionToken = options && options.hashSessionToken;
const createSessionToken = options && options.createSessionToken;
const authMessageCookieName = 'pulse_auth_message';
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
throw new Error('createSessionService requires the session dependencies.');
if (!sessionCookieName) {
throw new Error('createSessionService requires a session cookie name.');
}
function parseCookies(cookieHeader) {
@@ -89,23 +85,6 @@ function createSessionService(options) {
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
}
function setSessionCookie(res, token) {
const hasRequestedMaxAge = arguments.length > 2;
const requestedMaxAgeMs = hasRequestedMaxAge ? Number(arguments[2]) : sessionMaxAgeMs;
const cookieOptions = hasRequestedMaxAge && arguments[2] === null
? {}
: { maxAge: Number.isFinite(requestedMaxAgeMs) && requestedMaxAgeMs > 0 ? requestedMaxAgeMs : sessionMaxAgeMs };
appendCookieHeader(res, serializeCookie(sessionCookieName, token, cookieOptions));
}
async function getSessionMaxAgeMs() {
const configuredValue = typeof getConfiguredSessionMaxAgeMs === 'function'
? await getConfiguredSessionMaxAgeMs()
: sessionMaxAgeMs;
const normalizedValue = Number(configuredValue);
return Number.isFinite(normalizedValue) && normalizedValue > 0 ? normalizedValue : sessionMaxAgeMs;
}
function setAuthMessageCookie(res, message) {
appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 }));
}
@@ -119,27 +98,35 @@ function createSessionService(options) {
return message;
}
async function loadCurrentUser(pool, req) {
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[sessionCookieName];
function getSessionToken(req, resolvedSessionToken) {
if (resolvedSessionToken) return String(resolvedSessionToken);
const betterAuthSessionToken = req && req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token;
if (betterAuthSessionToken) return String(betterAuthSessionToken);
const cookies = parseCookies(req && req.headers && req.headers.cookie || '');
return cookies[sessionCookieName] || '';
}
async function loadCurrentUser(pool, req, resolvedSessionToken) {
const token = getSessionToken(req, resolvedSessionToken);
if (!token) {
return null;
}
const tokenHash = hashSessionToken(token);
const sessionToken = token;
const [rows] = await pool.query(
`SELECT s.user_id, s.view_as_user_id, s.view_as_expires_at,
u.id, u.name, u.username, u.email, u.email_verified_at, u.pending_email, u.must_change_password,
u.id, u.name, u.username, u.email, u.email_verified, u.pending_email, u.must_change_password, u.two_factor_enabled,
preview.id AS preview_id, preview.name AS preview_name, preview.username AS preview_username,
preview.email AS preview_email, preview.email_verified_at AS preview_email_verified_at,
preview.pending_email AS preview_pending_email, preview.must_change_password AS preview_must_change_password
preview.email AS preview_email, preview.email_verified AS preview_email_verified,
preview.pending_email AS preview_pending_email, preview.must_change_password AS preview_must_change_password,
preview.two_factor_enabled AS preview_two_factor_enabled
FROM a_sessions s
JOIN a_users u ON u.id = s.user_id
LEFT JOIN a_users preview ON preview.id = s.view_as_user_id AND s.view_as_expires_at > NOW()
WHERE s.session_hash = ?
AND s.expires_at > NOW()
LIMIT 1`,
[tokenHash]
[sessionToken]
);
if (!rows.length) {
return null;
@@ -148,7 +135,7 @@ function createSessionService(options) {
const userId = Number(rows[0].id);
async function loadAccess(userId) {
const [roleRows] = await pool.query(
`SELECT r.role_key
`SELECT r.role_key, r.require_two_factor
FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = ?
@@ -165,6 +152,7 @@ function createSessionService(options) {
[userId]
);
return {
requiresTwoFactor: roleRows.some(function (row) { return Boolean(row.require_two_factor); }),
roleKeys: roleRows.map(function (row) {
return String(row.role_key || '').trim();
}).filter(Boolean),
@@ -176,7 +164,11 @@ function createSessionService(options) {
const actorAccess = await loadAccess(userId);
const authenticatedUser = Object.assign({}, rows[0], {
email: toVisibleEmail(rows[0].email),
email_verified: Boolean(rows[0].email_verified),
mustChangePassword: Boolean(rows[0].must_change_password),
twoFactorEnabled: Boolean(rows[0].two_factor_enabled),
requiresTwoFactor: actorAccess.requiresTwoFactor,
roleKeys: actorAccess.roleKeys,
permissionKeys: actorAccess.permissionKeys
});
@@ -188,11 +180,11 @@ function createSessionService(options) {
if (Number(rows[0].view_as_user_id) > 0 && !canContinuePreview) {
await pool.query(
'UPDATE a_sessions SET view_as_user_id = NULL, view_as_expires_at = NULL WHERE session_hash = ? AND user_id = ?',
[tokenHash, userId]
[sessionToken, userId]
);
}
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, sessionToken]);
if (!canContinuePreview) {
return authenticatedUser;
@@ -203,10 +195,12 @@ function createSessionService(options) {
id: previewUserId,
name: rows[0].preview_name,
username: rows[0].preview_username,
email: rows[0].preview_email,
email_verified_at: rows[0].preview_email_verified_at,
email: toVisibleEmail(rows[0].preview_email),
email_verified: Boolean(rows[0].preview_email_verified),
pending_email: rows[0].preview_pending_email,
mustChangePassword: Boolean(rows[0].preview_must_change_password),
twoFactorEnabled: Boolean(rows[0].preview_two_factor_enabled),
requiresTwoFactor: previewAccess.requiresTwoFactor,
roleKeys: previewAccess.roleKeys,
permissionKeys: previewAccess.permissionKeys,
authenticatedUser: authenticatedUser,
@@ -216,9 +210,7 @@ function createSessionService(options) {
}
function getSessionHash(req) {
const cookies = parseCookies(req && req.headers && req.headers.cookie || '');
const token = cookies[sessionCookieName];
return token ? hashSessionToken(token) : '';
return getSessionToken(req);
}
async function startViewAsUser(pool, req, actorUserId, targetUserId) {
@@ -249,37 +241,6 @@ function createSessionService(options) {
return Number(result && result.affectedRows) === 1;
}
async function createUserSession(pool, userId, configuredMaxAgeMs, metadata) {
const token = createSessionToken();
const tokenHash = hashSessionToken(token);
const maxAgeMs = Number.isFinite(Number(configuredMaxAgeMs)) && Number(configuredMaxAgeMs) > 0
? Number(configuredMaxAgeMs)
: await getSessionMaxAgeMs();
const expiresAt = new Date(Date.now() + maxAgeMs);
const sessionMetadata = metadata && typeof metadata === 'object' ? metadata : {};
await pool.query(
'INSERT INTO a_sessions (session_hash, user_id, ip_address, user_agent, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[tokenHash, userId, String(sessionMetadata.ipAddress || '').slice(0, 255) || null, String(sessionMetadata.userAgent || '').slice(0, 512) || null, expiresAt, userId, userId]
);
if (typeof getConfiguredMaxActiveSessions === 'function') {
const maxActiveSessions = Number(await getConfiguredMaxActiveSessions());
if (Number.isInteger(maxActiveSessions) && maxActiveSessions > 0) {
const [sessionRows] = await pool.query(
'SELECT id, session_hash FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at ASC, created_at ASC, id ASC',
[userId]
);
const sessionsToRemove = (sessionRows || []).filter(function (session) {
return session.session_hash !== tokenHash;
}).slice(0, Math.max(0, sessionRows.length - maxActiveSessions));
for (const session of sessionsToRemove) {
await pool.query('DELETE FROM a_sessions WHERE id = ? AND user_id = ?', [session.id, userId]);
}
}
}
return token;
}
function requireAuth(req, res, next) {
if (req.currentUser) {
return next();
@@ -293,14 +254,11 @@ function createSessionService(options) {
parseCookies: parseCookies,
serializeCookie: serializeCookie,
clearSessionCookie: clearSessionCookie,
setSessionCookie: setSessionCookie,
setAuthMessageCookie: setAuthMessageCookie,
consumeAuthMessageCookie: consumeAuthMessageCookie,
loadCurrentUser: loadCurrentUser,
startViewAsUser: startViewAsUser,
stopViewAsUser: stopViewAsUser,
createUserSession: createUserSession,
getSessionMaxAgeMs: getSessionMaxAgeMs,
requireAuth: requireAuth,
getRequestOrigin: getRequestOrigin
};
+7 -4
View File
@@ -2,24 +2,27 @@ const crypto = require('crypto');
async function collectLocalControlUsers(pool) {
const [rows] = await pool.query(
`SELECT DISTINCT u.username, u.password_hash, u.password_salt
`SELECT DISTINCT u.username, a.password AS password_hash
FROM a_users u
JOIN a_accounts a ON a.user_id = u.id AND a.provider_id = 'credential'
JOIN a_user_roles ur ON ur.user_id = u.id
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
JOIN a_permissions p ON p.id = rp.permission_id
WHERE u.account_locked = 0
AND u.must_change_password = 0
AND a.password IS NOT NULL
AND a.password <> ''
AND a.password NOT LIKE 'app-user:%'
AND p.permission_key = 'clients.allow'
ORDER BY u.username ASC`
);
return (rows || []).map(function (user) {
return {
username_hash: crypto.createHash('sha256').update(String(user && user.username || '').trim()).digest('hex'),
password_hash: String(user && user.password_hash || '').trim(),
password_salt: String(user && user.password_salt || '').trim()
password_hash: String(user && user.password_hash || '').trim()
};
}).filter(function (user) {
return Boolean(user.username_hash && user.password_hash && user.password_salt);
return Boolean(user.username_hash && user.password_hash);
});
}
+4 -2
View File
@@ -18,8 +18,10 @@ async function initializeWebServer(options) {
throw new Error('initializeWebServer requires the web startup dependencies.');
}
await common.ensureSchema(pool, { mediaDir: mediaDir });
await common.bootstrapDatabase(pool);
if (!(options && options.databaseInitialized)) {
await common.ensureSchema(pool, { mediaDir: mediaDir });
await common.bootstrapDatabase(pool);
}
await backgroundTaskQueue.initialize();
await initializeBackgroundTasks({
+9 -1
View File
@@ -43,7 +43,7 @@ module.exports = function registerMiddleware(app, deps) {
return next(error);
}
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/verify-email' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
return next();
}
@@ -55,6 +55,14 @@ module.exports = function registerMiddleware(app, deps) {
return res.redirect('/account/password');
}
const actor = req.authenticatedUser || req.currentUser;
if (actor && actor.requiresTwoFactor && !actor.twoFactorEnabled && req.path !== '/account' && req.path !== '/account/password' && req.path !== '/settings/view-as/stop') {
if (req.path.indexOf('/api/') === 0) {
return res.status(403).json({ code: 'TWO_FACTOR_ENROLLMENT_REQUIRED', message: 'Set up two-factor authentication in My account to continue.' });
}
return res.redirect('/account');
}
next();
});
});
-5
View File
@@ -10,19 +10,14 @@ module.exports = {
renderLoginPage: require(routePath('auth', 'login')),
renderForgotPasswordPage: require(routePath('auth', 'forgot-password')),
renderResetPasswordPage: require(routePath('auth', 'reset-password')),
renderAcceptInvitePage: require(routePath('auth', 'accept-invite')),
renderNoAccessPage: require(routePath('auth', 'no-access')),
renderEmailVerifiedPage: require(routePath('auth', 'email-verified')),
renderEmailVerificationErrorPage: require(routePath('auth', 'email-verification-error')),
renderAccountPage: require(routePath('account', 'password')),
renderRequiredPasswordPage: require(routePath('account', 'password-required')),
renderSettingsPage: require(routePath('settings', 'index')),
renderAboutPage: require(routePath('settings', 'about', 'index')),
renderChangelogPage: require(routePath('settings', 'changelog', 'index')),
renderUsersPage: require(routePath('settings', 'users', 'list')),
renderInvitationsPage: require(routePath('settings', 'invitations', 'list')),
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
renderUsersInvitePage: require(routePath('settings', 'users', 'invite')),
renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
renderDashboardPage: require(routePath('signage', 'dashboard', 'index')),
renderConnectedClientsPage: require(routePath('signage', 'clients', 'list')),
+43
View File
@@ -447,6 +447,49 @@
overflow: hidden;
}
.login-card .login-box-msg {
margin: 0 0 1.25rem;
padding: 0;
text-align: center;
}
.login-card .form-label {
margin-bottom: 0.35rem;
}
.login-card .input-group-text {
min-width: 2.75rem;
justify-content: center;
}
.login-or-divider {
align-items: center;
color: var(--bs-secondary-color);
display: flex;
gap: 0.75rem;
justify-content: center;
margin: 0 0 1rem;
}
.login-or-divider::before,
.login-or-divider::after {
background: var(--bs-border-color);
content: "";
flex: 1;
height: 1px;
}
@media (max-width: 359.98px) {
.login-card [data-login-actions] {
align-items: flex-start !important;
flex-direction: column;
}
.login-card [data-login-submit] {
width: 100%;
}
}
.email-verified-card {
max-width: 26rem;
}
-56
View File
@@ -1,56 +0,0 @@
(function () {
var currentPasswordInput = document.querySelector('[data-account-current-password]');
var profileFormInput = document.querySelector('[data-account-profile-current-password]');
var passwordFormInput = document.querySelector('[data-account-password-current-password]');
var profileForm = profileFormInput && profileFormInput.form;
var passwordForm = passwordFormInput && passwordFormInput.form;
if (!currentPasswordInput || !profileFormInput || !passwordFormInput || !profileForm || !passwordForm) {
return;
}
function syncCurrentPassword() {
profileFormInput.value = currentPasswordInput.value;
passwordFormInput.value = currentPasswordInput.value;
}
function clearCurrentPassword() {
currentPasswordInput.value = '';
currentPasswordInput.setCustomValidity('');
currentPasswordInput.removeAttribute('required');
profileFormInput.value = '';
passwordFormInput.value = '';
passwordForm.querySelectorAll('input[type="password"]').forEach(function (input) {
input.value = '';
input.setCustomValidity('');
});
profileForm.classList.remove('was-validated');
passwordForm.classList.remove('was-validated');
}
currentPasswordInput.addEventListener('input', syncCurrentPassword);
currentPasswordInput.addEventListener('input', function () {
currentPasswordInput.setCustomValidity('');
});
document.addEventListener('submit', function (event) {
if (event.target !== profileForm && event.target !== passwordForm) {
return;
}
if (!currentPasswordInput.value) {
event.preventDefault();
event.stopImmediatePropagation();
currentPasswordInput.setCustomValidity('Current password is required.');
currentPasswordInput.reportValidity();
return;
}
currentPasswordInput.setCustomValidity('');
syncCurrentPassword();
}, true);
passwordForm.addEventListener('submit', syncCurrentPassword);
document.addEventListener('web-async-save:success', function (event) {
if (event.detail && event.detail.form !== profileForm && event.detail.form !== passwordForm) {
return;
}
clearCurrentPassword();
});
})();
+70
View File
@@ -0,0 +1,70 @@
(function () {
var client = window.pulsePasskeyAuth;
var addForm = document.querySelector('[data-passkey-add]');
var addTrigger = document.querySelector('[data-passkey-add-trigger]');
var feedback = document.querySelector('[data-passkey-feedback]');
var progress = document.querySelector('[data-passkey-progress]');
var unavailable = document.querySelector('[data-passkey-unavailable]');
var passkeyConfig = document.querySelector('[data-passkey-rp-id]');
var passkeyRpID = passkeyConfig && passkeyConfig.dataset && String(passkeyConfig.dataset.passkeyRpId || '').toLowerCase();
var currentHostname = String(window.location.hostname || '').toLowerCase();
var passkeyHostnameMatches = passkeyRpID && (currentHostname === passkeyRpID || currentHostname.endsWith('.' + passkeyRpID));
var deleteButtons = document.querySelectorAll('[data-passkey-delete]');
if (!window.isSecureContext || !navigator.credentials || !client || !passkeyHostnameMatches) {
if (addForm) addForm.hidden = true;
if (addTrigger) addTrigger.disabled = true;
if (unavailable) unavailable.hidden = false;
deleteButtons.forEach(function (button) { button.disabled = true; });
return;
}
function showError(error) {
if (!feedback) return;
feedback.textContent = error && error.message || 'The passkey request could not be completed.';
feedback.hidden = false;
}
if (addForm) {
var setupModal = document.querySelector('#passkey-setup-modal');
if (setupModal) {
setupModal.addEventListener('shown.bs.modal', function () {
addForm.elements.name.focus();
});
}
addForm.addEventListener('submit', async function (event) {
event.preventDefault();
var button = addForm.querySelector('button[type="submit"]');
button.disabled = true;
if (feedback) feedback.hidden = true;
if (progress) progress.hidden = false;
try {
var name = addForm.elements.name.value.trim();
var result = await client.passkey.addPasskey(name ? { name: name } : {});
if (result.error) throw new Error(result.error.message || 'Unable to add this passkey.');
window.location.reload();
} catch (error) {
showError(error);
if (progress) progress.hidden = true;
button.disabled = false;
}
});
}
deleteButtons.forEach(function (button) {
button.addEventListener('click', async function () {
if (!window.confirm('Remove this passkey?')) return;
button.disabled = true;
if (feedback) feedback.hidden = true;
try {
var result = await client.passkey.deletePasskey({ id: button.dataset.passkeyDelete });
if (result.error) throw new Error(result.error.message || 'Unable to remove this passkey.');
window.location.reload();
} catch (error) {
showError(error);
button.disabled = false;
}
});
});
})();
+146
View File
@@ -0,0 +1,146 @@
(function () {
function getFeedback(form, selector) {
return form.querySelector(selector);
}
function showError(target, message) {
if (!target) return;
target.textContent = message;
target.hidden = false;
}
async function request(endpoint, body) {
const response = await fetch('/api/auth/two-factor/' + endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
});
const data = await response.json().catch(function () { return {}; });
if (!response.ok) {
throw new Error(data.message || data.code || 'Two-factor request failed.');
}
return data;
}
function renderBackupCodes(container, codes) {
const list = container && container.querySelector('[data-two-factor-codes-list]');
if (!container || !list) return;
list.replaceChildren();
(Array.isArray(codes) ? codes : []).forEach(function (code) {
const item = document.createElement('li');
item.textContent = code;
list.appendChild(item);
});
container.hidden = false;
}
document.querySelectorAll('[data-two-factor-download]').forEach(function (button) {
button.addEventListener('click', function () {
const container = button.closest('[data-two-factor-codes]');
const list = container && container.querySelector('[data-two-factor-codes-list]');
const codes = list ? Array.from(list.querySelectorAll('li')).map(function (item) {
return item.textContent.trim();
}).filter(Boolean) : [];
if (!codes.length) return;
const file = new Blob(['Pulse Signage recovery codes\n\n' + codes.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = url;
link.download = 'pulse-signage-recovery-codes.txt';
link.click();
URL.revokeObjectURL(url);
});
});
const enableForm = document.querySelector('[data-two-factor-enable]');
if (enableForm) {
const setupPanel = document.querySelector('[data-two-factor-setup]');
const feedback = getFeedback(enableForm, '[data-two-factor-enable-feedback]');
enableForm.addEventListener('submit', async function (event) {
event.preventDefault();
const button = enableForm.querySelector('button[type="submit"]');
const password = enableForm.elements.password.value;
feedback.hidden = true;
button.disabled = true;
try {
const result = await request('enable', { password: password, method: 'totp', issuer: 'Pulse Signage' });
if (result.method !== 'totp' || !result.totpURI) throw new Error('Authenticator setup could not be started.');
const qrContainer = document.querySelector('[data-two-factor-qr]');
qrContainer.replaceChildren();
const qrCode = new window.QRCodeStyling({
width: 256,
height: 256,
type: 'svg',
data: result.totpURI,
dotsOptions: { color: '#17212b', type: 'rounded' },
backgroundOptions: { color: '#ffffff' },
cornersSquareOptions: { color: '#17212b', type: 'extra-rounded' }
});
qrCode.append(qrContainer);
const secret = new URL(result.totpURI).searchParams.get('secret');
document.querySelector('[data-two-factor-secret]').textContent = secret || '';
renderBackupCodes(document.querySelector('[data-two-factor-codes]'), result.backupCodes);
setupPanel.hidden = false;
enableForm.hidden = true;
} catch (error) {
showError(feedback, error.message);
button.disabled = false;
}
});
const verifyForm = document.querySelector('[data-two-factor-verify]');
const verifyFeedback = getFeedback(verifyForm, '[data-two-factor-verify-feedback]');
verifyForm.addEventListener('submit', async function (event) {
event.preventDefault();
const button = verifyForm.querySelector('button[type="submit"]');
verifyFeedback.hidden = true;
button.disabled = true;
try {
await request('verify-totp', { code: verifyForm.elements.code.value.trim() });
window.location.reload();
} catch (error) {
showError(verifyFeedback, error.message);
button.disabled = false;
}
});
}
const disableForm = document.querySelector('[data-two-factor-disable]');
if (disableForm) {
const feedback = getFeedback(disableForm, '[data-two-factor-disable-feedback]');
disableForm.addEventListener('submit', async function (event) {
event.preventDefault();
const button = disableForm.querySelector('button[type="submit"]');
feedback.hidden = true;
button.disabled = true;
try {
await request('disable', { password: disableForm.elements.password.value });
window.location.reload();
} catch (error) {
showError(feedback, error.message);
button.disabled = false;
}
});
}
const recoveryForm = document.querySelector('[data-two-factor-regenerate-codes]');
if (recoveryForm) {
const feedback = document.querySelector('[data-two-factor-codes-feedback]');
recoveryForm.addEventListener('submit', async function (event) {
event.preventDefault();
const button = recoveryForm.querySelector('button[type="submit"]');
feedback.hidden = true;
button.disabled = true;
try {
const result = await request('generate-backup-codes', { password: recoveryForm.elements.password.value });
renderBackupCodes(document.querySelector('[data-two-factor-codes]'), result.backupCodes);
recoveryForm.elements.password.value = '';
} catch (error) {
showError(feedback, error.message);
} finally {
button.disabled = false;
}
});
}
})();
+137
View File
@@ -0,0 +1,137 @@
(function () {
var form = document.querySelector('[data-better-auth-login]');
if (!form) return;
var feedback = form.querySelector('[data-login-feedback]');
var submitButton = form.querySelector('[data-login-submit]');
var credentialsPanel = form.querySelector('[data-login-credentials]');
var rememberPanel = form.querySelector('[data-login-remember]');
var twoFactorPanel = form.querySelector('[data-login-two-factor]');
var codeInput = form.elements.two_factor_code;
var codeLabel = form.querySelector('[data-two-factor-code-label]');
var toggleMethodButton = form.querySelector('[data-two-factor-toggle-method]');
var backButton = form.querySelector('[data-two-factor-back]');
var passkeyPanel = form.querySelector('[data-passkey-login-panel]');
var passkeyButton = form.querySelector('[data-passkey-login]');
var passkeyUnavailable = form.querySelector('[data-passkey-unavailable]');
var passkeyRpID = passkeyPanel && passkeyPanel.dataset && String(passkeyPanel.dataset.passkeyRpId || '').toLowerCase();
var currentHostname = String(window.location.hostname || '').toLowerCase();
var passkeyHostnameMatches = passkeyRpID && (currentHostname === passkeyRpID || currentHostname.endsWith('.' + passkeyRpID));
var challengeMethods = [];
var challengeMode = 'totp';
function showChallenge(methods) {
challengeMethods = Array.isArray(methods) ? methods : [];
challengeMode = 'totp';
credentialsPanel.hidden = true;
rememberPanel.hidden = true;
twoFactorPanel.hidden = false;
if (passkeyPanel) passkeyPanel.hidden = true;
codeInput.disabled = false;
form.elements.trust_device.disabled = false;
codeInput.value = '';
codeLabel.textContent = 'Authenticator code';
codeInput.inputMode = 'numeric';
toggleMethodButton.hidden = false;
toggleMethodButton.textContent = 'Use recovery code';
submitButton.textContent = 'Verify code';
submitButton.disabled = false;
codeInput.focus();
}
function resetChallenge() {
challengeMethods = [];
challengeMode = 'totp';
credentialsPanel.hidden = false;
rememberPanel.hidden = false;
twoFactorPanel.hidden = true;
if (passkeyPanel) passkeyPanel.hidden = false;
codeInput.disabled = true;
form.elements.trust_device.disabled = true;
form.elements.trust_device.checked = false;
codeInput.value = '';
toggleMethodButton.textContent = 'Use recovery code';
submitButton.textContent = 'Sign in';
feedback.hidden = true;
}
toggleMethodButton.addEventListener('click', function () {
challengeMode = challengeMode === 'totp' ? 'backup' : 'totp';
codeLabel.textContent = challengeMode === 'backup' ? 'Recovery code' : 'Authenticator code';
codeInput.inputMode = challengeMode === 'backup' ? 'text' : 'numeric';
codeInput.autocomplete = challengeMode === 'backup' ? 'off' : 'one-time-code';
codeInput.value = '';
toggleMethodButton.textContent = challengeMode === 'backup' ? 'Use authenticator code' : 'Use recovery code';
codeInput.focus();
});
backButton.addEventListener('click', resetChallenge);
if (passkeyButton) {
if (!window.isSecureContext || !navigator.credentials || !window.pulsePasskeyAuth || !passkeyHostnameMatches) {
passkeyButton.disabled = true;
if (passkeyUnavailable) passkeyUnavailable.hidden = false;
} else {
passkeyButton.addEventListener('click', async function () {
feedback.hidden = true;
passkeyButton.disabled = true;
try {
var result = await window.pulsePasskeyAuth.signIn.passkey({ autoFill: false });
if (result.error) throw new Error(result.error.message || 'Unable to sign in with a passkey.');
var returnTo = new URLSearchParams(window.location.search).get('returnTo') || '/';
window.location.assign(returnTo.charAt(0) === '/' && returnTo.charAt(1) !== '/' ? returnTo : '/');
} catch (error) {
feedback.textContent = error.message || 'Unable to sign in with a passkey.';
feedback.hidden = false;
passkeyButton.disabled = false;
}
});
}
}
form.addEventListener('submit', async function (event) {
event.preventDefault();
var initialMessage = document.querySelector('[data-login-initial-message]');
if (initialMessage) initialMessage.remove();
feedback.hidden = true;
submitButton.disabled = true;
try {
var inChallenge = challengeMethods.length > 0;
var identity = inChallenge ? '' : form.elements.username.value.trim();
var useEmail = identity.indexOf('@') !== -1;
var endpoint = inChallenge
? '/api/auth/two-factor/' + (challengeMode === 'backup' ? 'verify-backup-code' : 'verify-totp')
: form.action;
var payload = inChallenge
? { code: codeInput.value.trim(), trustDevice: form.elements.trust_device.checked }
: {
username: useEmail ? undefined : identity,
email: useEmail ? identity : undefined,
password: form.elements.password.value,
rememberMe: form.elements.remember_me.checked
};
var response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
});
var result = await response.json().catch(function () { return {}; });
if (!response.ok) {
throw new Error(result.message || (inChallenge ? 'Unable to verify the two-factor code.' : 'Unable to sign in. Check your username and password.'));
}
if (!inChallenge && result.twoFactorRedirect) {
showChallenge(result.twoFactorMethods);
return;
}
var returnTo = new URLSearchParams(window.location.search).get('returnTo') || '/';
window.location.assign(returnTo.charAt(0) === '/' && returnTo.charAt(1) !== '/' ? returnTo : '/');
} catch (error) {
feedback.textContent = error.message || 'Unable to sign in. Check your username and password.';
feedback.hidden = false;
submitButton.disabled = false;
}
});
})();
@@ -0,0 +1,6 @@
import { createAuthClient } from 'better-auth/client';
import { passkeyClient } from '@better-auth/passkey/client';
window.pulsePasskeyAuth = createAuthClient({
plugins: [passkeyClient()]
});
+4
View File
@@ -0,0 +1,4 @@
(()=>{var le=Symbol.for("better-auth:broadcast-channel"),ft=()=>Math.floor(Date.now()/1e3),dt=class{listeners=new Set;name;constructor(e="better-auth.message"){this.name=e}subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}post(e){if(!(typeof window>"u"))try{localStorage.setItem(this.name,JSON.stringify({...e,timestamp:ft()}))}catch{}}setup(){if(typeof window>"u"||typeof window.addEventListener>"u")return()=>{};let e=t=>{if(t.key!==this.name)return;let r=JSON.parse(t.newValue??"{}");r?.event!=="session"||!r?.data||this.listeners.forEach(n=>n(r))};return window.addEventListener("storage",e),()=>{window.removeEventListener("storage",e)}}};function J(e="better-auth.message"){return globalThis[le]||(globalThis[le]=new dt(e)),globalThis[le]}var O=[],H=0,C=null,ce=4,ht=globalThis.nanostoresGlobal||={epoch:0},pt=()=>{let e,t;for(;H<O.length;){t=H,H+=ce;let r=C?.get(O[t]);if(r){if(!r.has(O[t+1]))continue;r.clear()}try{O[t](O[t+1].value,O[t+2],O[t+3])}catch(n){e=n}}if(O.length=H=0,e)throw e};var D=e=>{let t=[],r={eq:Object.is,get(){return r.lc||r.listen(()=>{})(),r.value},init:e,lc:0,listen(n){return r.lc=t.push(n),()=>{C?.get(n)?.delete(r);for(let s=H;s<O.length;)O[s]===n&&O[s+1]===r?O.splice(s,ce):s+=ce;let o=t.indexOf(n);~o&&(t.splice(o,1),--r.lc||r.off())}},notify(n,o){ht.epoch++;let s=!O.length&&!C;for(let l of t){if(C){let a=C.get(l);if(a||C.set(l,a=new Set),a.has(r))continue;a.add(r)}O.push(l,r,n,C?void 0:o)}s&&pt()},off(){},set(n){let o=r.value;r.eq(o,n)||(r.value=n,r.notify(o))},subscribe(n){let o=r.listen(n);return n(r.value),o},value:e};return r};var mt=2;var Et=5,$=6,z=10,_e=(e,t,r,n)=>(e.events=e.events||{},e.events[r+z]||(e.events[r+z]=n(o=>{e.events[r].reduceRight((s,l)=>(l(s),s),{shared:{},...o})})),e.events[r]=e.events[r]||[],e.events[r].push(t),()=>{let o=e.events[r],s=o.indexOf(t);~s&&(o.splice(s,1),o.length||(e.events[r+z](),delete e.events[r+z]))});var fe=(e,t)=>_e(e,t,mt,r=>{let n=e.set,o=e.setKey;return e.setKey&&(e.setKey=(s,l)=>{let a;if(r({abort:()=>{a=!0},changed:s,newValue:{...e.value,[s]:l}}),!a)return o(s,l)}),e.set=s=>{let l;if(r({abort:()=>{l=!0},newValue:s}),!l)return n(s)},()=>{e.set=n,e.setKey=o}});var Q=1e3,j=(e,t)=>_e(e,n=>{let o=t(n);o&&e.events[$].push(o)},Et,n=>{let o=e.listen;e.listen=(...l)=>(!e.lc&&!e.active&&(e.active=!0,n()),o(...l));let s=e.off;return e.events[$]=[],e.off=()=>{s(),setTimeout(()=>{if(e.active&&!e.lc){e.active=!1;for(let l of e.events[$])l();e.events[$]=[]}},Q)},()=>{e.listen=o,e.off=s}});function Pe(e){if(typeof e!="object"||e===null)return!1;let t=Object.getPrototypeOf(e);return t===Object.prototype||t===null}function L(e,t){if(e===t)return!0;if(Array.isArray(e)&&Array.isArray(t)){if(e.length!==t.length)return!1;for(let r=0;r<e.length;r++)if(!L(e[r],t[r]))return!1;return!0}if(Pe(e)&&Pe(t)){let r=Object.keys(e),n=Object.keys(t);if(r.length!==n.length)return!1;for(let o of r)if(!(o in t)||!L(e[o],t[o]))return!1;return!0}return!1}function X(e,t){return fe(e,({newValue:r,abort:n})=>{t(e.value,r)&&n()})}var de=Symbol.for("better-auth:focus-manager"),yt=class{listeners=new Set;subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}setFocused(e){this.listeners.forEach(t=>t(e))}setup(){if(typeof window>"u"||typeof document>"u"||typeof window.addEventListener>"u")return()=>{};let e=()=>{document.visibilityState==="visible"&&this.setFocused(!0)};return document.addEventListener("visibilitychange",e,!1),()=>{document.removeEventListener("visibilitychange",e,!1)}}};function he(){return globalThis[de]||(globalThis[de]=new yt),globalThis[de]}var pe=Symbol.for("better-auth:online-manager"),gt=class{listeners=new Set;isOnline=typeof navigator<"u"?navigator.onLine:!0;subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}setOnline(e){this.isOnline=e,this.listeners.forEach(t=>t(e))}setup(){if(typeof window>"u"||typeof window.addEventListener>"u")return()=>{};let e=()=>this.setOnline(!0),t=()=>this.setOnline(!1);return window.addEventListener("online",e,!1),window.addEventListener("offline",t,!1),()=>{window.removeEventListener("online",e,!1),window.removeEventListener("offline",t,!1)}}};function Z(){return globalThis[pe]||(globalThis[pe]=new gt),globalThis[pe]}var Rt={proto:/"(?:_|\\u0{2}5[Ff]){2}(?:p|\\u0{2}70)(?:r|\\u0{2}72)(?:o|\\u0{2}6[Ff])(?:t|\\u0{2}74)(?:o|\\u0{2}6[Ff])(?:_|\\u0{2}5[Ff]){2}"\s*:/,constructor:/"(?:c|\\u0063)(?:o|\\u006[Ff])(?:n|\\u006[Ee])(?:s|\\u0073)(?:t|\\u0074)(?:r|\\u0072)(?:u|\\u0075)(?:c|\\u0063)(?:t|\\u0074)(?:o|\\u006[Ff])(?:r|\\u0072)"\s*:/,protoShort:/"__proto__"\s*:/,constructorShort:/"constructor"\s*:/},bt=/^\s*["[{]|^\s*-?\d{1,16}(\.\d{1,17})?([Ee][+-]?\d+)?\s*$/,Ue={true:!0,false:!1,null:null,undefined:void 0,nan:NaN,infinity:Number.POSITIVE_INFINITY,"-infinity":Number.NEGATIVE_INFINITY},vt=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,7}))?(?:Z|([+-])(\d{2}):(\d{2}))$/;function Tt(e){return e instanceof Date&&!isNaN(e.getTime())}function St(e){let t=vt.exec(e);iLine truncated
at `);return t.length<=1?e:(t.splice(1,1),t.join(`
at `))}function Ct(e,t){class r extends e{#e;constructor(...o){if(Lt()){let l=Error.stackTraceLimit;Error.stackTraceLimit=0,super(...o),Error.stackTraceLimit=l}else super(...o);let s=new Error().stack;s&&(this.#e=Nt(s.replace(/^Error/,this.name)))}get errorStack(){return this.#e}}return Object.defineProperty(r.prototype,"constructor",{get(){return t},enumerable:!1,configurable:!0}),r}var Dt={OK:200,CREATED:201,ACCEPTED:202,NO_CONTENT:204,MULTIPLE_CHOICES:300,MOVED_PERMANENTLY:301,FOUND:302,SEE_OTHER:303,NOT_MODIFIED:304,TEMPORARY_REDIRECT:307,BAD_REQUEST:400,UNAUTHORIZED:401,PAYMENT_REQUIRED:402,FORBIDDEN:403,NOT_FOUND:404,METHOD_NOT_ALLOWED:405,NOT_ACCEPTABLE:406,PROXY_AUTHENTICATION_REQUIRED:407,REQUEST_TIMEOUT:408,CONFLICT:409,GONE:410,LENGTH_REQUIRED:411,PRECONDITION_FAILED:412,PAYLOAD_TOO_LARGE:413,URI_TOO_LONG:414,UNSUPPORTED_MEDIA_TYPE:415,RANGE_NOT_SATISFIABLE:416,EXPECTATION_FAILED:417,"I'M_A_TEAPOT":418,MISDIRECTED_REQUEST:421,UNPROCESSABLE_ENTITY:422,LOCKED:423,FAILED_DEPENDENCY:424,TOO_EARLY:425,UPGRADE_REQUIRED:426,PRECONDITION_REQUIRED:428,TOO_MANY_REQUESTS:429,REQUEST_HEADER_FIELDS_TOO_LARGE:431,UNAVAILABLE_FOR_LEGAL_REASONS:451,INTERNAL_SERVER_ERROR:500,NOT_IMPLEMENTED:501,BAD_GATEWAY:502,SERVICE_UNAVAILABLE:503,GATEWAY_TIMEOUT:504,HTTP_VERSION_NOT_SUPPORTED:505,VARIANT_ALSO_NEGOTIATES:506,INSUFFICIENT_STORAGE:507,LOOP_DETECTED:508,NOT_EXTENDED:510,NETWORK_AUTHENTICATION_REQUIRED:511},xt=class extends Error{status;body;headers;statusCode;constructor(e="INTERNAL_SERVER_ERROR",t=void 0,r={},n=typeof e=="number"?e:Dt[e]){if(super(t?.message,t&&"cause"in t?{cause:t.cause}:void 0),this.status=e,this.body=t,this.headers=r,this.statusCode=n,this.name="APIError",this.status=e,this.headers=r,this.statusCode=n,t&&"cause"in t){let{cause:o,...s}=t;this.body=Object.defineProperty(s,"cause",{value:o,writable:!0,configurable:!0});return}this.body=t}};var kt=Ct(xt,Error);var F=class extends Error{constructor(e,t){super(e,t),this.name="BetterAuthError",this.message=e,this.stack=""}};var Mt=47;function De(e){let t=e.length;for(;t>0&&e.charCodeAt(t-1)===Mt;)t--;return t===e.length?e:e.slice(0,t)}function Bt(e){try{return(De(new URL(e).pathname)||"/")!=="/"}catch{throw new F(`Invalid base URL: ${e}. Please provide a valid base URL.`)}}function qt(e){try{let t=new URL(e);if(t.protocol!=="http:"&&t.protocol!=="https:")throw new F(`Invalid base URL: ${e}. URL must include 'http://' or 'https://'`)}catch(t){throw t instanceof F?t:new F(`Invalid base URL: ${e}. Please provide a valid base URL.`,{cause:t})}}function W(e,t="/api/auth"){if(qt(e),Bt(e))return e;let r=De(e);return!t||t==="/"?r:(t=t.startsWith("/")?t:`/${t}`,`${r}${t}`)}function Ce(e,t){return!e||e.trim()===""?!1:t==="proto"?e==="http"||e==="https":t==="host"?[/\.\./,/\0/,/[\s]/,/^[.]/,/[<>'"]/,/javascript:/i,/file:/i,/data:/i].some(r=>r.test(e))?!1:/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(:[0-9]{1,5})?$/.test(e)||/^(\d{1,3}\.){3}\d{1,3}(:[0-9]{1,5})?$/.test(e)||/^\[[0-9a-fA-F:]+\](:[0-9]{1,5})?$/.test(e)||/^localhost(:[0-9]{1,5})?$/i.test(e):!1}function xe(e,t,r,n,o){if(e)return W(e,t);if(n!==!1){let a=I.BETTER_AUTH_URL||I.NEXT_PUBLIC_BETTER_AUTH_URL||I.PUBLIC_BETTER_AUTH_URL||I.NUXT_PUBLIC_BETTER_AUTH_URL||I.NUXT_PUBLIC_AUTH_URL||(I.BASE_URL!=="/"?I.BASE_URL:void 0);if(a)return W(a,t)}let s=r?.headers.get("x-forwarded-host"),l=r?.headers.get("x-forwarded-proto");if(s&&l&&o&&Ce(l,"proto")&&Ce(s,"host"))try{return W(`${l}://${s}`,t)}catch{}if(r){let a=jt(r.url);if(!a)throw new F("Could not get origin from request. Please provide a valid base URL.");return W(a,t)}if(typeof window<"u"&&window.location)return W(window.location.origin,t)}function jt(e){try{let t=new URL(e);return t.origin==="null"?null:t.origin}catch{return null}}var Ft=["javascript:","data:","vbscript:"];function ke(e){let t;try{t=new URL(e)}catch{return!0}return!Ft.includes(t.protocol)}var Me={id:"redirect",name:"Redirect",hooks:{onSuccess(e){if(e.data?.url&&e.data?.redirect&&ke(e.data.url)&&typeof window<"u"&&window.location&&window.location)try{window.location.href=e.data.url}catch{}}}};var Ht=()=>typeof window>"u",Vt=Q;function qe(e,t){if(typeof window>"u")return;let r=e.get();r.data!==null||t===null||e.set({...r,data:t,error:null,isPending:!1})}function Be(e){return typeof e=="object"&&e!==null&&"data"in e&&"error"in e?e:{data:e,error:null}}function Wt(e){return!e||e.session===null&&e.user===null?null:e}function Gt(e,t){return L(e.data,t.data)&&e.error===t.error&&e.isPending===t.isPending&&e.isRefetching===t.isRefetching&&e.refetch===t.refetch}function je(e,t){let r=D(!1),n,o=0,s=0;r.listen(()=>{s++,o=0});let l=m=>u(m),a=ee({data:null,error:null,isPending:!0,isRefetching:!1,refetch:l});X(a,Gt);let i=async(m,E)=>{let f=a.value;if(a.set({...f,isPending:f.data===null,isRefetching:!0,error:null,refetch:l}),m.aborted)return"aborted";try{let g=await e("/get-session",{method:"GET",query:E?.query,signal:m});if(m.aborted)return"aborted";l Line truncated
`,t)}function it(){if(!M())return Oe.stubThis(new Promise(t=>t(!1)));let e=globalThis.PublicKeyCredential;return e?.isConditionalMediationAvailable===void 0?Oe.stubThis(new Promise(t=>t(!1))):Oe.stubThis(e.isConditionalMediationAvailable())}var Oe={stubThis:e=>e};function at({error:e,options:t}){let{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new v({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if(e.name==="NotAllowedError")return new v({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="SecurityError"){let n=globalThis.location.hostname;if(ne(n)){if(r.rpId!==n)return new v({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new v({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="UnknownError")return new v({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}async function ut(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});let{optionsJSON:t,useBrowserAutofill:r=!1,verifyBrowserAutofillInput:n=!0}=e;if(!M())throw new Error("WebAuthn is not supported in this browser");let o;t.allowCredentials?.length!==0&&(o=t.allowCredentials?.map(re));let s={...t,challenge:k(t.challenge),allowCredentials:o},l={};if(r){if(!await it())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&n)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');l.mediation="conditional",s.allowCredentials=[]}l.publicKey=s,l.signal=oe.createNewAbortSignal();let a;try{a=await navigator.credentials.get(l)}catch(m){throw at({error:m,options:l})}if(!a)throw new Error("Authentication was not completed");let{id:i,rawId:c,response:u,type:d}=a,p;return u.userHandle&&(p=P(u.userHandle)),{id:i,rawId:P(c),response:{authenticatorData:P(u.authenticatorData),clientDataJSON:P(u.clientDataJSON),signature:P(u.signature),userHandle:p},type:d,clientExtensionResults:a.getClientExtensionResults(),authenticatorAttachment:se(a.authenticatorAttachment)}}var Lr=(e,{$listPasskeys:t,$store:r})=>({signIn:{passkey:async(s,l)=>{let a=await e("/passkey/generate-authenticate-options",{method:"GET",throw:!1});if(!a.data)return a;let i=a.data.extensions||s?.extensions?{...a.data.extensions||{},...s?.extensions||{}}:void 0,c;try{c=await ut({optionsJSON:{...a.data,extensions:i},useBrowserAutofill:s?.autoFill})}catch(u){return{data:null,error:{code:u instanceof v?u.code:"AUTH_CANCELLED",message:x.AUTH_CANCELLED.message,status:400,statusText:"BAD_REQUEST"}}}try{let{clientExtensionResults:u,...d}=c,p=await e("/passkey/verify-authentication",{body:{response:d},...s?.fetchOptions,...l,method:"POST",throw:!1});return t.set(Math.random()),r.notify("$sessionSignal"),s?.returnWebAuthnResponse?{...p,webauthn:{response:c,clientExtensionResults:u}}:p}catch(u){return console.error("[Better Auth] Error verifying passkey",u),{data:null,error:{code:"AUTH_CANCELLED",message:x.AUTH_CANCELLED.message,status:400,statusText:"BAD_REQUEST"}}}}},passkey:{addPasskey:async(s,l)=>{let a=await e("/passkey/generate-register-options",{method:"GET",query:{...s?.authenticatorAttachment&&{authenticatorAttachment:s.authenticatorAttachment},...s?.name&&{name:s.name},...s?.context&&{context:s.context}},throw:!1});if(!a.data)return a;try{let i=a.data.extensions||s?.extensions?{...a.data.extensions||{},...s?.extensions||{}}:void 0,c=await st({optionsJSON:{...a.data,extensions:i},useAutoRegister:s?.useAutoRegister}),{clientExtensionResults:u,...d}=c,p=await e("/passkey/verify-registration",{...s?.fetchOptions,...l,body:{response:d,name:s?.name,...s?.createSession&&{createSession:!0}},method:"POST",throw:!1});return p.data?(t.set(Math.random()),p.data.session&&r.notify("$sessionSignal"),s?.returnWebAuthnResponse?{...p,webauthn:{response:c,clientExtensionResults:u}}:p):p}catch(i){return i instanceof v?i.code==="ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED"?{data:null,error:{code:i.code,message:x.PREVIOUSLY_REGISTERED.message,status:400,statusText:"BAD_REQUEST"}}:i.code==="ERROR_CEREMONY_ABORTED"?{data:null,error:{code:i.code,message:x.REGISTRATION_CANCELLED.message,status:400,statusText:"BAD_REQUEST"}}:{data:null,error:{code:i.code,message:i.message,status:400,statusText:"BAD_REQUEST"}}:{data:null,error:{code:"UN Line truncated
+5 -43
View File
@@ -390,20 +390,13 @@
username: previewUser ? previewUser.getAttribute('data-username') : '',
display_name: previewUser ? previewUser.getAttribute('data-display-name') : '',
email: previewUser ? previewUser.getAttribute('data-email') : '',
verification_expiry: previewUser ? previewUser.getAttribute('data-verification-expiry') : '30',
reset_expiry: previewUser ? previewUser.getAttribute('data-reset-expiry') : '30',
invitation_expiry: previewUser ? previewUser.getAttribute('data-invitation-expiry') : '24'
otp_expiry: previewUser ? previewUser.getAttribute('data-otp-expiry') : '30'
};
document.querySelectorAll('[data-email-template-editor]').forEach(function (editor) {
var subject = editor.querySelector('[data-email-template-subject]');
var body = editor.querySelector('[data-email-template-body]');
var alignment = editor.querySelector('[data-email-template-alignment]');
var buttonText = editor.querySelector('[data-email-template-button-text]');
var preview = editor.querySelector('[data-email-template-preview]');
if (!subject || !body || !preview) return;
var isVerificationTemplate = Boolean(editor.querySelector('[name="verification_subject"]'));
var isResetTemplate = Boolean(editor.querySelector('[name="reset_subject"]'));
var isInvitationTemplate = Boolean(editor.querySelector('[name="invitation_subject"]'));
function render() {
preview.innerHTML = '';
@@ -418,45 +411,16 @@
username: signedInUser.username || 'username',
display_name: signedInUser.display_name || signedInUser.username || 'Your name',
email: signedInUser.email || 'your-email@example.com',
expiry_time: isInvitationTemplate ? signedInUser.invitation_expiry + ' hours' : (isResetTemplate ? signedInUser.reset_expiry : signedInUser.verification_expiry) + ' minutes'
otp: '123456',
expiry_time: signedInUser.otp_expiry + ' minutes'
};
var button = document.createElement('div');
button.className = 'email-template-preview__button';
var buttonAlignment = alignment ? alignment.value : 'center';
var buttonWrap = document.createElement('div');
buttonWrap.className = 'email-template-preview__button-wrap';
buttonWrap.style.textAlign = buttonAlignment;
buttonWrap.appendChild(button);
var previewActionPlaceholder = '[[url]]';
button.textContent = buttonText && buttonText.value ? buttonText.value : (isVerificationTemplate ? 'Verify email address' : (isInvitationTemplate ? 'Accept invitation' : 'Reset password'));
var previewOrigin = window.location.origin || 'https://pulse-signage.example';
var previewVerificationUrl = previewOrigin + '/verify-email?token=preview-token';
var previewResetUrl = previewOrigin + '/reset-password?token=preview-token';
var previewActionUrl = isVerificationTemplate ? previewVerificationUrl : (isInvitationTemplate ? previewOrigin + '/accept-invite?token=preview-token' : previewResetUrl);
var hasExplicitButton = body.value.indexOf('[[action_button]]') !== -1;
body.value.split(/\r?\n(?:[ \t]*\r?\n)+/).forEach(function (sourceParagraph) {
var line = sourceParagraph.replace(/\[\[(username|display_name|email|expiry_time)\]\]/g, function (_match, key) { return sampleValues[key]; }).replace(previewActionPlaceholder, previewActionUrl);
var hasButtonToken = sourceParagraph.indexOf('[[action_button]]') !== -1;
var hasUrlToken = !hasExplicitButton && sourceParagraph.indexOf(previewActionPlaceholder) !== -1;
if (hasButtonToken) {
var buttonParts = line.split('[[action_button]]');
if (buttonParts[0]) appendPreviewParagraph(buttonParts[0]);
message.appendChild(buttonWrap);
if (buttonParts[1]) appendPreviewParagraph(buttonParts[1]);
return;
}
if (hasUrlToken) {
var legacyParts = sourceParagraph.split(previewActionPlaceholder);
if (legacyParts[0]) appendPreviewParagraph(legacyParts[0]);
message.appendChild(buttonWrap);
appendPreviewParagraph(previewActionUrl + legacyParts[1]);
return;
}
var line = sourceParagraph.replace(/\[\[(username|display_name|email|expiry_time|otp)\]\]/g, function (_match, key) { return sampleValues[key]; });
appendPreviewParagraph(line);
});
function appendPreviewParagraph(line) {
var paragraph = document.createElement('p');
paragraph.innerHTML = (line || '\u00a0').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\r?\n/g, '<br>').replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1">$1</a>').replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '<strong>$1</strong>').replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '<em>$1</em>').replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '<u>$1</u>');
paragraph.innerHTML = (line || '\u00a0').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\r?\n/g, '<br>').replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '<strong>$1</strong>').replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '<em>$1</em>').replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '<u>$1</u>');
message.appendChild(paragraph);
}
preview.appendChild(brand);
@@ -466,8 +430,6 @@
subject.addEventListener('input', render);
body.addEventListener('input', render);
if (alignment) alignment.addEventListener('change', render);
if (buttonText) buttonText.addEventListener('input', render);
editor.querySelectorAll('[data-email-format]').forEach(function (formatButton) {
formatButton.addEventListener('click', function () {
var tag = formatButton.getAttribute('data-email-format');
+11 -3
View File
@@ -1,6 +1,7 @@
// Account password page renderer and return URL normalization.
const { renderView } = require('#src/web/view');
const { resolvePasskeyRpID } = require('#src/web/lib/auth/better-auth');
function buildPasswordRequirementsText(requirements) {
const requiredCategories = [];
@@ -26,7 +27,7 @@ function normalizeReturnUrl(value) {
return url;
}
module.exports = function renderAccountPage(currentUser, message, returnUrl, allowUserSessionRevocation, sessions, passwordRequirements) {
module.exports = function renderAccountPage(currentUser, message, returnUrl, allowUserSessionRevocation, sessions, passwordRequirements, authMethods, passkeys) {
const requirements = passwordRequirements || {
minimumLength: 10,
minimumCategories: 3,
@@ -45,13 +46,20 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl, all
email: currentUser ? String(currentUser.email || '') : '',
emailPending: Boolean(currentUser && currentUser.pending_email),
pendingEmail: currentUser ? String(currentUser.pending_email || '') : '',
emailVerified: Boolean(currentUser && currentUser.email_verified_at),
pendingEmailChange: Boolean(currentUser && currentUser.pending_email && String(currentUser.pending_email).trim().toLowerCase() !== String(currentUser.email || '').trim().toLowerCase()),
emailVerified: Boolean(currentUser && currentUser.email_verified),
authMethods: Array.isArray(authMethods) ? authMethods : [],
passkeys: Array.isArray(passkeys) ? passkeys : [],
passkeyRpID: resolvePasskeyRpID(process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL),
returnUrl: normalizeReturnUrl(returnUrl),
allowUserSessionRevocation: Boolean(allowUserSessionRevocation),
sessions: Array.isArray(sessions) ? sessions : [],
passwordMinimumLength: requirements.minimumLength,
passwordRequirementsText: buildPasswordRequirementsText(requirements),
scripts: ['js/account/account-page.js']
twoFactorEnabled: Boolean(currentUser && currentUser.twoFactorEnabled),
requiresTwoFactor: Boolean(currentUser && currentUser.requiresTwoFactor),
canManageTwoFactor: Boolean(currentUser && !currentUser.isViewingAs),
scripts: ['vendor/qr-code-styling/qr-code-styling-loader.js', 'js/auth/passkey-client.js', 'js/account/two-factor.js', 'js/account/passkeys.js']
});
};
+149 -89
View File
@@ -1,33 +1,50 @@
// Admin account route registration and profile helpers.
const { fetchAppSettings } = require('#src/data/app-settings');
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { formatUserAgentLabel } = require('#src/data/audit-log');
const { createSyntheticAuthEmail, toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const betterAuth = deps.betterAuth;
const fromNodeHeaders = deps.fromNodeHeaders;
const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId;
const verifyPassword = deps.verifyPassword;
const hashPassword = deps.hashPassword;
const validatePasswordStrength = deps.validatePasswordStrength;
const createUserSession = deps.createUserSession;
const setSessionCookie = deps.setSessionCookie;
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
const clearSessionCookie = deps.clearSessionCookie;
const parseCookies = deps.parseCookies;
const hashSessionToken = deps.hashSessionToken;
const sessionCookieName = deps.sessionCookieName;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const sendAccountEmail = deps.sendAccountEmail;
const createOneTimeToken = deps.createOneTimeToken;
async function verifyCurrentPassword(req, password) {
try {
const result = await betterAuth.api.verifyPassword({
body: { password: password },
headers: fromNodeHeaders(req.headers)
});
return !result || result.status !== false;
} catch (_error) {
return false;
}
}
async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool);
return buildPasswordRequirements(settings);
}
async function getAuthMethods(userId) {
const [rows] = await pool.query('SELECT provider_id FROM a_accounts WHERE user_id = ? ORDER BY created_at ASC', [userId]);
return (rows || []).map(function (row) { return mapAuthMethod(row.provider_id); }).filter(Boolean);
}
async function getPasskeys(userId) {
const [rows] = await pool.query('SELECT id, name, created_at FROM a_user_passkeys WHERE user_id = ? ORDER BY created_at ASC', [userId]);
return rows || [];
}
function isPasswordChangeRequired(req) {
return Boolean(req && (req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword));
}
@@ -51,67 +68,112 @@ module.exports = function registerAccountRoutes(app, deps) {
};
}
function getSessionMetadata(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return {
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim(),
userAgent: req && req.headers && req.headers['user-agent']
};
}
async function clearPendingEmailWithoutActiveOtp(user) {
const pendingEmail = String(user && user.pending_email || '').trim().toLowerCase();
if (!pendingEmail) return;
const email = String(user.email || '').trim().toLowerCase();
const identifier = pendingEmail === email
? 'email-verification-otp-' + email
: 'change-email-otp-' + email + '-' + pendingEmail;
function getRequestOrigin(req) {
const forwardedProto = String(req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
return forwardedHost ? forwardedProto + '://' + forwardedHost : '';
await pool.query('DELETE FROM a_verifications WHERE identifier = ? AND expires_at <= NOW()', [identifier]);
const [result] = await pool.query(
`UPDATE a_users
SET pending_email = NULL
WHERE id = ?
AND pending_email = ?
AND NOT EXISTS (
SELECT 1 FROM a_verifications
WHERE identifier = ? AND expires_at > NOW()
)`,
[user.id, user.pending_email, identifier]
);
if (result && result.affectedRows) user.pending_email = null;
}
app.post('/account/email', async function (req, res, next) {
try {
const email = String(req.body.email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
const [rows] = await pool.query('SELECT pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const email = String(rows && rows[0] && rows[0].pending_email || '').trim().toLowerCase();
if (!email) return res.redirect('/account?message=' + encodeURIComponent('There is no pending email change.'));
const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.');
const token = createOneTimeToken();
const expiryMinutes = Number(settings['email.verification_expiry_minutes']);
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE) WHERE id = ?', [email, hashSessionToken(token), req.currentUser.id]);
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.email_change_requested', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: email });
res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.'));
if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your new email address.'));
} catch (error) {
next(error);
}
});
app.get('/account', function (req, res) {
fetchAppSettings(pool).then(function (settings) {
app.post('/account/email/send-verification', async function (req, res, next) {
try {
const email = String(req.currentUser.email || '').trim().toLowerCase();
if (!email) return res.redirect('/account?message=' + encodeURIComponent('Add an email address before requesting a verification code.'));
const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, req.currentUser.id]);
await betterAuth.api.sendVerificationOTP({ body: { email: email, type: 'email-verification' } });
res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your email address.'));
} catch (error) {
next(error);
}
});
app.post('/account/email/verify', async function (req, res, next) {
try {
const otp = String(req.body.otp || '').trim();
const [rows] = await pool.query('SELECT email, email_verified, pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows && rows[0];
if (!user) return res.status(404).send('User not found.');
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
if (pendingEmail) {
if (pendingEmail === String(user.email || '').trim().toLowerCase()) {
await betterAuth.api.verifyEmailOTP({ body: { email: pendingEmail, otp: otp } });
} else {
await betterAuth.api.changeEmailEmailOTP({ body: { newEmail: pendingEmail, otp: otp }, headers: fromNodeHeaders(req.headers) });
}
await pool.query('UPDATE a_users SET email = ?, email_verified = 1, pending_email = NULL WHERE id = ?', [pendingEmail, req.currentUser.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_completed', targetType: 'user', targetId: req.currentUser.id, targetLabel: pendingEmail });
return res.redirect('/account?message=' + encodeURIComponent('Email address verified.'));
}
res.status(400).send('No email change is awaiting verification. Request a new code and try again.');
} catch (error) {
res.status(400).send('The email code is invalid or expired. Request a new code and try again.');
}
});
app.get('/account', async function (req, res) {
try {
await clearPendingEmailWithoutActiveOtp(req.currentUser);
const settings = await fetchAppSettings(pool);
const passwordRequirements = buildPasswordRequirements(settings);
const authMethods = await getAuthMethods(req.currentUser.id);
const passkeys = await getPasskeys(req.currentUser.id);
if (!settings['security.allow_user_session_revocation']) {
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements));
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements, authMethods, passkeys));
}
const cookies = parseCookies(req.headers.cookie || '');
const tokenHash = cookies[sessionCookieName] ? hashSessionToken(cookies[sessionCookieName]) : '';
return pool.query(
const sessionToken = String(req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName] || '');
const result = await pool.query(
'SELECT id, session_hash, ip_address, user_agent, created_at, last_used_at, expires_at FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at DESC',
[req.currentUser.id]
).then(function (result) {
const rows = result[0] || [];
const sessions = rows.map(function (session) {
return {
id: Number(session.id),
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash),
ipAddress: String(session.ip_address || 'Unknown'),
userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
createdAtLabel: formatDashboardDate(session.created_at),
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
expiresAtLabel: formatDashboardDate(session.expires_at)
};
});
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements));
);
const rows = result[0] || [];
const sessions = rows.map(function (session) {
return {
id: Number(session.id),
isCurrent: Boolean(sessionToken && session.session_hash === sessionToken),
ipAddress: String(session.ip_address || 'Unknown'),
userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
createdAtLabel: formatDashboardDate(session.created_at),
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
expiresAtLabel: formatDashboardDate(session.expires_at)
};
});
}).catch(function (error) {
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements, authMethods, passkeys));
} catch (error) {
res.status(500).send(error.message || 'Unable to load account settings.');
});
}
});
app.get('/account/password', async function (req, res, next) {
@@ -137,37 +199,39 @@ module.exports = function registerAccountRoutes(app, deps) {
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
if (!currentPassword) return res.status(400).send('Current password is required.');
const [rows] = await pool.query('SELECT id, username, name, email, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const [rows] = await pool.query('SELECT id, username, name, email, pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows[0] || null;
if (!user) return res.status(404).send('User not found.');
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
if (!(await verifyCurrentPassword(req, currentPassword))) return res.status(400).send('Current password is incorrect.');
if (username !== user.username && await common.fetchDuplicateName(pool, 'a_users', username, user.id, 'username')) return res.status(400).send('That username already exists.');
if (name !== user.name && await common.fetchDuplicateName(pool, 'a_users', name, user.id)) return res.status(400).send('That name already exists.');
const confirmedEmail = String(user.email || '').trim().toLowerCase();
const confirmedEmail = toVisibleEmail(user.email).trim().toLowerCase();
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
const emailChanged = email !== confirmedEmail && email !== pendingEmail;
const pendingEmailCleared = Boolean(pendingEmail && email === confirmedEmail);
const settings = emailChanged ? await fetchAppSettings(pool) : null;
if (emailChanged && email && (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function')) return res.status(400).send('Email delivery is not configured.');
if (emailChanged && email && !settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
const actorId = getAuditUserId(req);
await pool.query('UPDATE a_users SET username = ?, name = ?, modified_by = ? WHERE id = ?', [username, name, actorId, user.id]);
if (emailChanged || pendingEmailCleared) {
if (!email) {
await pool.query('UPDATE a_users SET email = NULL, email_verified_at = NULL, pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
await pool.query('UPDATE a_users SET email = ?, email_verified = 0, pending_email = NULL WHERE id = ?', [createSyntheticAuthEmail(user.id), user.id]);
} else if (pendingEmailCleared) {
await pool.query('UPDATE a_users SET pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
await pool.query('UPDATE a_users SET pending_email = NULL WHERE id = ?', [user.id]);
} else {
const token = createOneTimeToken();
const expiryMinutes = Number(settings['email.verification_expiry_minutes']);
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE) WHERE id = ?', [email, hashSessionToken(token), user.id]);
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, user.id]);
try {
await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
} catch (error) {
await pool.query('UPDATE a_users SET pending_email = NULL WHERE id = ?', [user.id]);
throw error;
}
}
}
if (emailChanged && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: email ? 'user.email_change_requested' : 'user.email_cleared', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: email || user.email, details: { previousEmail: confirmedEmail || null, email: email || null, verificationRequired: Boolean(email) } });
res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. Check your new email address for a verification link.' : 'Account details updated.'));
res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. A verification code was sent to your new email address.' : 'Account details updated.'));
} catch (error) {
next(error);
}
@@ -184,13 +248,13 @@ module.exports = function registerAccountRoutes(app, deps) {
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
}
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[sessionCookieName];
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
if (!token) {
return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.'));
}
const [result] = await pool.query(
'DELETE FROM a_sessions WHERE id = ? AND user_id = ? AND session_hash <> ?',
[sessionId, req.currentUser.id, hashSessionToken(token)]
[sessionId, req.currentUser.id, token]
);
if (result && result.affectedRows && typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
@@ -216,9 +280,9 @@ module.exports = function registerAccountRoutes(app, deps) {
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
}
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[sessionCookieName];
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
if (token) {
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, hashSessionToken(token)]);
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, token]);
}
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
@@ -266,10 +330,10 @@ module.exports = function registerAccountRoutes(app, deps) {
const currentPassword = String(req.body.current_password || '');
if (!username) return res.status(400).send('Username is required.');
if (!currentPassword) return res.status(400).send('Current password is required.');
const [rows] = await pool.query('SELECT id, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const [rows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows[0] || null;
if (!user) return res.status(404).send('User not found.');
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
if (!(await verifyCurrentPassword(req, currentPassword))) return res.status(400).send('Current password is incorrect.');
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, user.id]);
if (existingRows.length) return res.status(400).send('That username already exists.');
await pool.query('UPDATE a_users SET username = ?, modified_by = ? WHERE id = ?', [username, getAuditUserId(req), user.id]);
@@ -285,13 +349,7 @@ module.exports = function registerAccountRoutes(app, deps) {
const currentPassword = String(req.body.current_password || '');
const newPassword = String(req.body.new_password || '');
const confirmPassword = String(req.body.confirm_password || '');
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows[0] || null;
if (!user) {
return res.status(404).send('User not found.');
}
if (!verifyPassword(currentPassword, user)) {
if (!currentPassword) {
return sendPasswordError(req, res, 'Current password is incorrect.');
}
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
@@ -302,28 +360,30 @@ module.exports = function registerAccountRoutes(app, deps) {
return sendPasswordError(req, res, 'New passwords do not match.');
}
const passwordRecord = hashPassword(newPassword);
await pool.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
try {
await betterAuth.api.changePassword({
body: { currentPassword: currentPassword, newPassword: newPassword, revokeOtherSessions: true },
headers: fromNodeHeaders(req.headers)
});
} catch (_error) {
return sendPasswordError(req, res, 'Current password is incorrect.');
}
await pool.query('UPDATE a_users SET must_change_password = 0, modified_by = ? WHERE id = ?', [getAuditUserId(req), req.currentUser.id]);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [req.currentUser.id]);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'password.changed',
actorUserId: user.id,
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: user.id,
targetLabel: user.username,
targetId: req.currentUser.id,
targetLabel: req.currentUser.username,
details: { source: 'account' }
});
}
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, getSessionMetadata(req));
setSessionCookie(res, token, sessionMaxAgeMs);
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
if (typeof clearSessionCookie === 'function') clearSessionCookie(res);
res.redirect('/login?message=' + encodeURIComponent('Password updated. Sign in again.'));
} catch (error) {
next(error);
}
+7 -5
View File
@@ -6,7 +6,7 @@ const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library');
const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log');
const { buildAuditChanges, buildSlideAuditChanges } = require('#src/data/audit-log');
const { replaceContentReferences } = require('#src/data/content-references');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
@@ -644,7 +644,7 @@ module.exports = function registerContentRoutes(app, deps) {
}
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req);
const changes = buildAuditChanges({
const changes = buildSlideAuditChanges({
title: slide.title,
templateId: Number(slide.template_id),
content: slide.content
@@ -683,7 +683,7 @@ module.exports = function registerContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
if (Object.keys(changes).length && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
console.warn('Unable to queue slide thumbnail refresh:', error);
});
@@ -921,7 +921,7 @@ module.exports = function registerContentRoutes(app, deps) {
details: { changes: buildAuditChanges(
{ regions: normalizeTemplateRegionsForAudit(template.regions) },
{ regions: normalizeTemplateRegionsForAudit(regions) }
) }
), ...(slideId ? { promotedSlideId: slideId } : {}) }
});
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
} catch (error) {
@@ -1005,7 +1005,7 @@ module.exports = function registerContentRoutes(app, deps) {
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, {
category: 'templates', eventType: 'template.updated', actorUserId: actorId,
targetType: 'template', targetId: templateId, targetLabel: template.name,
details: { changes: changes }
details: { changes: changes, ...(slideId ? { promotedSlideId: slideId } : {}) }
});
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
} catch (error) {
@@ -1047,12 +1047,14 @@ module.exports = function registerContentRoutes(app, deps) {
canvasSizeId: Number(template.canvas_size_id),
backgroundImagePath: template.background_image_path,
backgroundColor: template.background_color,
backgroundGradient: template.background_gradient,
regions: normalizeTemplateRegionsForAudit(template.regions)
}, {
name: payload.name,
canvasSizeId: Number(payload.canvasSizeId),
backgroundImagePath: payload.backgroundImagePath,
backgroundColor: payload.backgroundColor,
backgroundGradient: payload.backgroundGradient,
regions: normalizeTemplateRegionsForAudit(payload.regions)
});
connection = await pool.getConnection();
+33 -2
View File
@@ -19,6 +19,33 @@ module.exports = function registerPlaylistRoutes(app, deps) {
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const requirePermission = deps.requirePermission;
async function fetchPlaylistAuditItems(connection, playlistId) {
const [items] = await connection.query(
`SELECT id, slide_id, duration_seconds, use_video_duration, disable_audio,
group_key, group_name, group_pick_count, group_shuffle, group_mode,
group_repeat_count, group_schedule_rules_json
FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC`,
[playlistId]
);
const [rules] = await connection.query(
`SELECT r.playlist_slide_id, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json
FROM c_playlist_slide_schedule_rules r
INNER JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
WHERE ps.playlist_id = ? ORDER BY r.position ASC, r.id ASC`,
[playlistId]
);
const rulesByItem = new Map();
rules.forEach(function ({ playlist_slide_id: itemId, ...rule }) {
if (!rulesByItem.has(Number(itemId))) rulesByItem.set(Number(itemId), []);
if (typeof rule.schedule_days_json === 'string') rule.schedule_days_json = JSON.parse(rule.schedule_days_json);
rulesByItem.get(Number(itemId)).push(rule);
});
return items.map(function ({ id, ...item }) {
if (typeof item.group_schedule_rules_json === 'string') item.group_schedule_rules_json = JSON.parse(item.group_schedule_rules_json);
return Object.assign(item, { scheduleRules: rulesByItem.get(Number(id)) || [] });
});
}
function readRawFieldArray(reqBody, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
@@ -456,7 +483,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
}
}
const previousItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
const nextItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState();
@@ -465,12 +494,14 @@ module.exports = function registerPlaylistRoutes(app, deps) {
name: playlist.name,
fadeBetweenSlides: Boolean(playlist.fade_between_slides),
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id)
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id),
items: previousItems
}, {
name: name,
fadeBetweenSlides: Boolean(fadeBetweenSlides),
skipUnavailableRtmp: Boolean(skipUnavailableRtmp),
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId)
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId),
items: nextItems
});
await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } });
}
+14 -8
View File
@@ -167,7 +167,8 @@
return {
formValues: {
name: common.validateMaxLength(formValues && formValues.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'),
description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description')
description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description'),
require_two_factor: Boolean(formValues && formValues.require_two_factor)
},
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(data.users, selectedUserIds),
@@ -247,7 +248,8 @@
const permissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
const viewModel = await buildRoleCreateViewModel({
name: buildDuplicateRoleName(role.name),
description: buildDuplicateRole(role).description
description: buildDuplicateRole(role).description,
require_two_factor: buildDuplicateRole(role).require_two_factor
}, permissionKeys, [], page, search, sort, direction, currentUserId);
res.send(pages.renderRbacAddPage('Review the copied values and save when ready.', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
@@ -260,6 +262,7 @@
try {
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
const requireTwoFactor = req.body.require_two_factor === '1';
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
: [];
@@ -277,7 +280,7 @@
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description, require_two_factor: requireTwoFactor }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
@@ -308,8 +311,8 @@
try {
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO a_roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[roleKey, name, description || null, actorId, actorId]
'INSERT INTO a_roles (role_key, name, description, require_two_factor, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
[roleKey, name, description || null, requireTwoFactor ? 1 : 0, actorId, actorId]
);
insertedRoleId = Number(result.insertId);
if (selectedPermissionKeys.length) {
@@ -331,7 +334,7 @@
targetType: 'role',
targetId: insertedRoleId,
targetLabel: name,
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds }
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds, requireTwoFactor: requireTwoFactor }
});
}
res.redirect('/settings/roles/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
@@ -383,6 +386,7 @@
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
const requireTwoFactor = req.body.require_two_factor === '1';
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
const selectedPermissionKeys = shouldSyncPermissions
@@ -440,8 +444,8 @@
try {
await connection.beginTransaction();
await connection.query(
'UPDATE a_roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
[name, description || null, getAuditUserId(req), roleId]
'UPDATE a_roles SET name = ?, description = ?, require_two_factor = ?, modified_by = ? WHERE id = ?',
[name, description || null, requireTwoFactor ? 1 : 0, getAuditUserId(req), roleId]
);
if (shouldSyncPermissions) {
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
@@ -460,11 +464,13 @@
const changes = buildAuditChanges({
name: role.name,
description: role.description,
requireTwoFactor: Boolean(role.require_two_factor),
permissionKeys: existingRolePermissionKeys,
userIds: existingRoleUserIds
}, {
name: name,
description: description || null,
requireTwoFactor: requireTwoFactor,
permissionKeys: normalizedPermissionKeys,
userIds: normalizedUserIds
});
+119 -171
View File
@@ -1,8 +1,8 @@
// Admin user route registration and user-role management.
const { fetchAppSettings } = require('#src/data/app-settings');
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { buildAuditChanges, formatUserAgentLabel } = require('#src/data/audit-log');
const { createSyntheticAuthEmail, toVisibleEmail, hashCredentialPassword, setCredentialPassword } = require('#src/data/auth-accounts');
module.exports = function registerUsersRoutes(app, deps) {
const pool = deps.pool;
@@ -11,11 +11,6 @@
const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const sendAccountEmail = deps.sendAccountEmail;
const createOneTimeToken = deps.createOneTimeToken;
const hashSessionToken = deps.hashSessionToken;
const getRequestOrigin = deps.getRequestOrigin;
const hashPassword = deps.hashPassword;
const validatePasswordStrength = deps.validatePasswordStrength;
const readArrayField = deps.readArrayField;
const rbacData = deps.rbacData;
@@ -34,11 +29,6 @@
return rbacData.fetchRoles(pool);
}
async function shouldRequirePasswordChange(settingKey) {
const settings = await fetchAppSettings(pool);
return Boolean(settings[settingKey]);
}
async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool);
return {
@@ -85,23 +75,6 @@
return { ok: true, roleIds: normalizedRoleIds };
}
function mapInvitationForView(invitation, roleNamesById) {
let roleIds = [];
try {
roleIds = JSON.parse(invitation.role_ids_json || '[]');
} catch (error) {
roleIds = [];
}
return Object.assign({}, invitation, {
roleNames: roleIds.map(function (roleId) {
return roleNamesById.get(Number(roleId));
}).filter(Boolean).join(', ') || 'No roles assigned',
createdAtLabel: formatDashboardDate(invitation.created_at),
expiresAtLabel: formatDashboardDate(invitation.expires_at),
createdByLabel: invitation.created_by_username || 'System'
});
}
app.get('/settings/users', requirePermission('users.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
@@ -126,77 +99,6 @@
}
});
app.get('/settings/invitations', requirePermission('invitations.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await rbacData.fetchInvitationsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const roles = await fetchRoleOptions();
const roleNamesById = new Map((roles || []).map(function (role) {
return [Number(role.id), role.name];
}));
const invitations = (data.invitations || []).map(function (invitation) {
return mapInvitationForView(invitation, roleNamesById);
});
res.send(pages.renderInvitationsPage({
invitations: invitations,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'invitations', 'Invitation pages'),
message: req.query.message ? String(req.query.message) : ''
}, req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/settings/invitations/:id/delete', requirePermission('invitations.delete'), async function (req, res, next) {
try {
const invitationId = Number(req.params.id);
if (!Number.isInteger(invitationId) || invitationId <= 0) {
return res.status(400).send('Invalid invitation.');
}
const [result] = await pool.query('DELETE FROM a_user_invitations WHERE id = ? AND used_at IS NULL', [invitationId]);
if (!result || !result.affectedRows) {
return res.status(404).send('Invitation not found.');
}
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_deleted', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId });
res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation deleted.'));
} catch (error) {
next(error);
}
});
app.post('/settings/invitations/:id/resend', requirePermission('invitations.allow'), async function (req, res, next) {
try {
const invitationId = Number(req.params.id);
if (!Number.isInteger(invitationId) || invitationId <= 0) {
return res.status(400).send('Invalid invitation.');
}
if (typeof sendAccountEmail !== 'function' || typeof createOneTimeToken !== 'function' || typeof hashSessionToken !== 'function') {
return res.status(503).send('Email delivery is not available.');
}
const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled']) {
return res.status(503).send('Email delivery is not configured.');
}
const [rows] = await pool.query('SELECT email, name, role_ids_json FROM a_user_invitations WHERE id = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [invitationId]);
if (!rows.length) {
return res.status(404).send('Invitation not found.');
}
const invitation = rows[0];
const token = createOneTimeToken();
const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token);
const expiryHours = Number(settings['email.invitation_expiry_hours']);
await sendAccountEmail(settings, Object.assign({ to: invitation.email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: invitation.name || 'there', email: invitation.email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] })));
await pool.query('UPDATE a_user_invitations SET token_hash = ?, expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), created_at = NOW(), created_by = ? WHERE id = ? AND used_at IS NULL', [hashSessionToken(token), getAuditUserId(req), invitationId]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_resent', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId, targetLabel: invitation.email });
res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation resent.'));
} catch (error) {
next(error);
}
});
app.get('/settings/users/new', requirePermission('users.create'), function (req, res) {
fetchRoleOptions().then(function (roles) {
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
@@ -205,54 +107,6 @@
});
});
app.get('/settings/users/invite', requirePermission('invitations.create'), async function (req, res, next) {
try {
const roles = await fetchRoleOptions();
res.send(pages.renderUsersInvitePage('', req.currentUser, mapRolesForForm(roles, []), {}, 'success'));
} catch (error) {
next(error);
}
});
app.post('/settings/users/invite', requirePermission('invitations.create'), async function (req, res, next) {
try {
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
const formValues = { email: email, name: name };
async function renderInviteError(message) {
const roles = await fetchRoleOptions();
return res.status(400).send(pages.renderUsersInvitePage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
}
if (!email || !EMAIL_PATTERN.test(email)) return renderInviteError('Email address is invalid.');
if (!name) return renderInviteError('Display name is required.');
if (!roleCheck.ok) return renderInviteError(roleCheck.message);
if (typeof sendAccountEmail !== 'function' || typeof createOneTimeToken !== 'function' || typeof hashSessionToken !== 'function') return renderInviteError('Email delivery is not available.');
const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled']) return renderInviteError('Email delivery is not configured.');
const [existingUsers] = await pool.query('SELECT id FROM a_users WHERE email = ? OR pending_email = ? LIMIT 1', [email, email]);
if (existingUsers.length) return renderInviteError('That email address is already associated with an account.');
const [recentInvites] = await pool.query('SELECT COUNT(*) AS invite_count FROM a_user_invitations WHERE created_by = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)', [getAuditUserId(req)]);
if (Number(recentInvites[0] && recentInvites[0].invite_count) >= 25) return renderInviteError('Invitation sending is temporarily limited. Try again later.');
const token = createOneTimeToken();
await pool.query('UPDATE a_user_invitations SET used_at = NOW() WHERE email = ? AND used_at IS NULL', [email]);
const expiryHours = Number(settings['email.invitation_expiry_hours']);
await pool.query('INSERT INTO a_user_invitations (email, name, role_ids_json, token_hash, expires_at, created_by) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), ?)', [email, name || null, JSON.stringify(roleCheck.roleIds), hashSessionToken(token), getAuditUserId(req)]);
const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token);
try {
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: name || 'there', email: email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] })));
} catch (mailError) {
await pool.query('DELETE FROM a_user_invitations WHERE token_hash = ?', [hashSessionToken(token)]);
throw mailError;
}
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_sent', actorUserId: getAuditUserId(req), targetType: 'email', targetLabel: email, details: { roleIds: roleCheck.roleIds } });
res.redirect('/settings/users?message=' + encodeURIComponent('Invitation sent.'));
} catch (error) {
next(error);
}
});
app.get('/settings/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
@@ -360,6 +214,91 @@
}
});
app.post('/settings/users/:id/two-factor/unlink', requirePermission('users.update'), async function (req, res, next) {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) return res.status(400).send('Invalid user.');
if (Number(req.currentUser.id) === userId) return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own two-step verification.'));
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [userRows] = await connection.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!userRows.length) {
await connection.rollback();
return res.status(404).send('User not found.');
}
const [factorRows] = await connection.query('SELECT id FROM a_user_two_factors WHERE user_id = ? LIMIT 1', [userId]);
if (!factorRows.length) {
await connection.rollback();
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('No authenticator is linked to this user.'));
}
await connection.query('DELETE FROM a_user_two_factors WHERE user_id = ?', [userId]);
await connection.query('UPDATE a_users SET two_factor_enabled = 0, modified_by = ? WHERE id = ?', [getAuditUserId(req), userId]);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
await connection.commit();
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'user.two_factor_unlinked',
actorUserId: getAuditUserId(req),
targetType: 'user',
targetId: userId,
targetLabel: String(userId)
});
}
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Two-step verification removed. The user must sign in again.'));
} catch (error) {
try { await connection.rollback(); } catch (_rollbackError) {}
next(error);
} finally {
connection.release();
}
});
app.post('/settings/users/:id/passkeys/:passkeyId/unlink', requirePermission('users.update'), async function (req, res, next) {
const userId = Number(req.params.id);
const passkeyId = String(req.params.passkeyId || '').trim();
if (!Number.isInteger(userId) || userId <= 0 || !passkeyId || passkeyId.length > 191) return res.status(400).send('Invalid passkey.');
if (Number(req.currentUser.id) === userId) return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own passkeys.'));
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [userRows] = await connection.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!userRows.length) {
await connection.rollback();
return res.status(404).send('User not found.');
}
const [passkeyRows] = await connection.query('SELECT id FROM a_user_passkeys WHERE id = ? AND user_id = ? LIMIT 1', [passkeyId, userId]);
if (!passkeyRows.length) {
await connection.rollback();
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Passkey was not found for this user.'));
}
await connection.query('DELETE FROM a_user_passkeys WHERE id = ? AND user_id = ?', [passkeyId, userId]);
await connection.query('UPDATE a_users SET modified_by = ? WHERE id = ?', [getAuditUserId(req), userId]);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
await connection.commit();
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'user.passkey_unlinked',
actorUserId: getAuditUserId(req),
targetType: 'user',
targetId: userId,
targetLabel: String(userId)
});
}
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Passkey removed. The user must sign in again.'));
} catch (error) {
try { await connection.rollback(); } catch (_rollbackError) {}
next(error);
} finally {
connection.release();
}
});
app.post('/settings/users', requirePermission('users.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
@@ -368,13 +307,15 @@
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const mustChangePassword = req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'));
const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase();
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
const formValues = {
username: username,
name: name,
email: email
email: email,
must_change_password: mustChangePassword
};
async function renderValidationError(message) {
@@ -410,14 +351,14 @@
return renderValidationError('That username already exists.');
}
const passwordRecord = hashPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_for_new_users');
const encodedPassword = await hashCredentialPassword(password);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO a_users (name, username, email, password_hash, password_salt, password_iterations, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[name, username, email || null, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, actorId, actorId]
'INSERT INTO a_users (name, username, email, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
[name, username, email || createSyntheticAuthEmail(), mustChangePassword ? 1 : 0, actorId, actorId]
);
await setCredentialPassword(connection, result.insertId, encodedPassword);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
if (typeof recordRequestAuditEvent === 'function') {
@@ -515,6 +456,8 @@
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
const accountLocked = req.body.account_locked === '1' || (Array.isArray(req.body.account_locked) && req.body.account_locked.includes('1'));
const emailVerified = req.body.email_verified === '1' || (Array.isArray(req.body.email_verified) && req.body.email_verified.includes('1'));
const mustChangePassword = req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'));
const storedEmail = email || createSyntheticAuthEmail(userId);
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
@@ -525,6 +468,7 @@
return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, {
name: name || user.name,
username: username || user.username,
must_change_password: mustChangePassword,
roleIds: selectedRoleIds,
inUse: !canDelete
}), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds)));
@@ -559,6 +503,10 @@
if (existingRows.length) {
return renderValidationError('That username already exists.');
}
if (email) {
const [existingEmailRows] = await pool.query('SELECT id FROM a_users WHERE email = ? AND id <> ? LIMIT 1', [email, userId]);
if (existingEmailRows.length) return renderValidationError('That email address is already associated with another account.');
}
const changes = buildAuditChanges({
name: user.name,
@@ -566,6 +514,7 @@
email: user.email || '',
roleIds: user.roleIds,
accountLocked: Boolean(user.account_locked),
mustChangePassword: Boolean(user.must_change_password),
passwordReset: false
}, {
name: name,
@@ -573,29 +522,28 @@
email: email,
roleIds: roleCheck.roleIds,
accountLocked: accountLocked,
mustChangePassword: mustChangePassword,
passwordReset: shouldUpdatePassword
});
await connection.beginTransaction();
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, must_change_password = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
if (!result.affectedRows) {
await connection.rollback();
return res.status(404).send('User not found.');
}
await connection.query('UPDATE a_users SET email = ?, email_verified_at = CASE WHEN email = ? THEN email_verified_at ELSE NULL END, modified_by = ? WHERE id = ?', [email || null, email || null, getAuditUserId(req), userId]);
if (emailVerified && email && (await fetchAppSettings(pool))['security.allow_admin_email_verification_bypass']) {
await connection.query('UPDATE a_users SET email_verified_at = NOW() WHERE id = ? AND email = ?', [userId, email]);
}
const emailIsUnchanged = Boolean(email && email === user.email);
let verifiedEmail = Boolean(emailIsUnchanged && user.email_verified);
if (emailVerified && email && (await fetchAppSettings(pool))['security.allow_admin_email_verification_bypass']) verifiedEmail = true;
if (!email) verifiedEmail = false;
await connection.query('UPDATE a_users SET email = ?, email_verified = ?, modified_by = ? WHERE id = ?', [storedEmail, verifiedEmail ? 1 : 0, getAuditUserId(req), userId]);
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
if (accountLocked) {
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
}
if (shouldUpdatePassword) {
const passwordRecord = hashPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
await connection.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
);
const encodedPassword = await hashCredentialPassword(password);
await setCredentialPassword(connection, userId, encodedPassword);
await connection.query('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?', [mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
}
await connection.commit();
@@ -630,7 +578,7 @@
details: { source: 'administrator' }
});
}
if (emailVerified && !user.email_verified_at && email) {
if (emailVerified && !user.email_verified && verifiedEmail && email) {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'email.verification_bypassed',
@@ -679,17 +627,17 @@
return res.redirect(editUrl + '?message=' + encodeURIComponent('Passwords do not match.'));
}
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
const [rows] = await pool.query('SELECT id, username, must_change_password FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
const passwordRecord = hashPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
await pool.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
);
const encodedPassword = await hashCredentialPassword(password);
const mustChangePassword = Object.prototype.hasOwnProperty.call(req.body || {}, 'must_change_password')
? req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'))
: Boolean(rows[0].must_change_password);
await setCredentialPassword(pool, userId, encodedPassword);
await pool.query('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?', [mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
-27
View File
@@ -1,27 +0,0 @@
const { renderView } = require('#src/web/view');
module.exports = function renderAcceptInvitePage(message, token, email, name, passwordRequirements, isValidInvitation) {
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
const requiredCategories = [];
if (requirements.requireLowercase) requiredCategories.push('lowercase');
if (requirements.requireUppercase) requiredCategories.push('uppercase');
if (requirements.requireNumber) requiredCategories.push('number');
if (requirements.requireSymbol) requiredCategories.push('symbol');
const passwordRequirementsText = requiredCategories.length
? 'Use at least ' + requirements.minimumLength + ' characters and include ' + requiredCategories.join(', ') + '.'
: requirements.minimumCategories === 4
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
return renderView('auth/accept-invite', {
title: 'Accept invitation',
authShell: true,
bodyClass: 'login-page-body',
message: message || '',
isValidInvitation: Boolean(isValidInvitation),
token: token || '',
email: email || '',
name: name || '',
passwordMinimumLength: requirements.minimumLength,
passwordRequirementsText: passwordRequirementsText
});
};
@@ -1,5 +0,0 @@
const { renderView } = require('#src/web/view');
module.exports = function renderEmailVerificationErrorPage(message) {
return renderView('auth/email-verification-error', { title: 'Verification link unavailable', authShell: true, bodyClass: 'login-page-body', message: message || 'This email verification link is invalid or has expired.' });
};
-5
View File
@@ -1,5 +0,0 @@
const { renderView } = require('#src/web/view');
module.exports = function renderEmailVerifiedPage() {
return renderView('auth/email-verified', { title: 'Email verified', authShell: true, bodyClass: 'login-page-body' });
};
+21 -265
View File
@@ -2,7 +2,6 @@
const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session');
const { fetchAppSettings } = require('#src/data/app-settings');
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { hasPermission } = require('#src/rbac');
const DEFAULT_LANDING_ROUTES = [
@@ -21,7 +20,6 @@ const DEFAULT_LANDING_ROUTES = [
['weather.read', '/data-sources/weather'],
['users.read', '/settings/users'],
['rbac.read', '/settings/roles'],
['invitations.read', '/settings/invitations'],
['fonts.read', '/settings/fonts'],
['weather-icons.read', '/settings/weather-icons'],
['background-tasks.read', '/settings/tasks-background'],
@@ -41,81 +39,18 @@ function getDefaultLandingPath(currentUser) {
module.exports = function registerAuthRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
const createUserSession = deps.createUserSession;
const setSessionCookie = deps.setSessionCookie;
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
const betterAuth = deps.betterAuth;
const clearSessionCookie = deps.clearSessionCookie;
const parseCookies = deps.parseCookies;
const consumeAuthMessageCookie = deps.consumeAuthMessageCookie;
const setAuthMessageCookie = deps.setAuthMessageCookie;
const hashSessionToken = deps.hashSessionToken;
const verifyPassword = deps.verifyPassword;
const sessionCookieName = deps.sessionCookieName;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const sendAccountEmail = deps.sendAccountEmail;
const createOneTimeToken = deps.createOneTimeToken;
const getConnection = deps.getConnection;
const rbacData = deps.rbacData;
function getReturnTo(req) {
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(req));
}
function buildLoginRedirect(returnTo, username) {
const query = [];
if (returnTo) query.push('returnTo=' + encodeURIComponent(returnTo));
if (username) query.push('username=' + encodeURIComponent(username));
return query.length ? '/login?' + query.join('&') : '/login';
}
function getClientAddress(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim() || 'unknown';
}
function getRateLimitKey(username, address, scope) {
if (scope === 'username') return 'username:' + username;
if (scope === 'ip') return 'ip:' + address;
return 'both:' + JSON.stringify([username, address]);
}
async function getLoginRateLimitSettings() {
const settings = await fetchAppSettings(pool);
return {
maxAttempts: Number(settings['security.login_max_attempts']) || 5,
lockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
scope: String(settings['security.login_rate_limit_scope'] || 'both')
};
}
async function getLoginRateLimitStatus(rateKey) {
const [rows] = await pool.query('SELECT locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
const lockedUntil = rows && rows[0] && rows[0].locked_until ? new Date(rows[0].locked_until).getTime() : 0;
return {
limited: lockedUntil > Date.now(),
remainingMinutes: Math.max(1, Math.ceil((lockedUntil - Date.now()) / 60000))
};
}
async function recordFailedLogin(rateKey, settings) {
const [rows] = await pool.query('SELECT failed_count, locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
const existing = rows && rows[0] ? rows[0] : null;
const existingLockedUntil = existing && existing.locked_until ? new Date(existing.locked_until).getTime() : 0;
const currentCount = existingLockedUntil && existingLockedUntil <= Date.now() ? 0 : Number(existing && existing.failed_count) || 0;
const failedCount = currentCount + 1;
const lockedUntil = failedCount >= settings.maxAttempts ? new Date(Date.now() + settings.lockoutMinutes * 60 * 1000) : null;
if (existing) {
await pool.query('UPDATE a_login_attempts SET failed_count = ?, last_failed_at = ?, locked_until = ? WHERE rate_key = ?', [failedCount, new Date(), lockedUntil, rateKey]);
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
}
await pool.query('INSERT INTO a_login_attempts (rate_key, failed_count, last_failed_at, locked_until) VALUES (?, ?, ?, ?)', [rateKey, failedCount, new Date(), lockedUntil]);
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
}
async function clearFailedLogins(rateKey) {
await pool.query('DELETE FROM a_login_attempts WHERE rate_key = ?', [rateKey]);
}
async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool);
return {
@@ -128,55 +63,6 @@ module.exports = function registerAuthRoutes(app, deps) {
};
}
app.get('/accept-invite', async function (req, res, next) {
try {
const token = String(req.query.token || '').trim();
const [rows] = await pool.query('SELECT email, name FROM a_user_invitations WHERE token_hash = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [hashSessionToken(token)]);
const invitation = rows[0] || null;
if (!invitation) return res.status(400).send(pages.renderAcceptInvitePage('This invitation is invalid or has expired.', token, '', '', await getPasswordRequirements(), false));
res.send(pages.renderAcceptInvitePage('', token, invitation.email, invitation.name, await getPasswordRequirements(), true));
} catch (error) {
next(error);
}
});
app.post('/accept-invite', async function (req, res, next) {
const connection = typeof getConnection === 'function' ? await getConnection() : pool;
try {
const token = String(req.body.token || '').trim();
const username = String(req.body.username || '').trim();
const name = String(req.body.name || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const [rows] = await connection.query('SELECT id, email, name, role_ids_json FROM a_user_invitations WHERE token_hash = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [hashSessionToken(token)]);
const invitation = rows[0] || null;
const requirements = await getPasswordRequirements();
const renderError = function (message) { return res.status(400).send(pages.renderAcceptInvitePage(message, token, invitation ? invitation.email : '', name || (invitation && invitation.name) || '', requirements, Boolean(invitation))); };
if (!invitation) return renderError('This invitation is invalid or has expired.');
if (!username) return renderError('Username is required.');
if (!name) return renderError('Name is required.');
const [existingUsers] = await connection.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
if (existingUsers.length) return renderError('That username already exists.');
const strengthMessage = deps.validatePasswordStrength(password, requirements);
if (strengthMessage) return renderError(strengthMessage);
if (password !== confirmPassword) return renderError('Passwords do not match.');
const passwordRecord = deps.hashPassword(password);
const roleIds = JSON.parse(invitation.role_ids_json || '[]');
await connection.beginTransaction();
const [result] = await connection.query('INSERT INTO a_users (name, username, email, email_verified_at, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, NOW(), ?, ?, ?, NULL, NULL)', [name, username, invitation.email, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations]);
if (rbacData && typeof rbacData.syncUserRoles === 'function') await rbacData.syncUserRoles(connection, result.insertId, roleIds);
await connection.query('UPDATE a_user_invitations SET used_at = NOW() WHERE id = ? AND used_at IS NULL', [invitation.id]);
await connection.commit();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_accepted', targetType: 'user', targetId: result.insertId, targetLabel: username, details: { invitationId: invitation.id } });
res.redirect('/login?message=' + encodeURIComponent('Account created. You can now sign in.'));
} catch (error) {
try { await connection.rollback(); } catch (_rollbackError) {}
next(error);
} finally {
if (connection !== pool && connection && typeof connection.release === 'function') connection.release();
}
});
app.get('/forgot-password', function (req, res) {
res.send(pages.renderForgotPasswordPage(''));
});
@@ -184,24 +70,15 @@ module.exports = function registerAuthRoutes(app, deps) {
app.post('/forgot-password', async function (req, res, next) {
try {
const identity = String(req.body.identity || '').trim();
const genericMessage = 'If that account has a verified email address, a reset link has been sent.';
const [rows] = await pool.query('SELECT id, name, username, email FROM a_users WHERE username = ? OR (email = ? AND email_verified_at IS NOT NULL) LIMIT 1', [identity, identity.toLowerCase()]);
const genericMessage = 'If that account has a verified email address, a reset code has been sent.';
const [rows] = await pool.query('SELECT id, email, email_verified FROM a_users WHERE username = ? OR (email = ? AND email_verified = 1) LIMIT 1', [identity, identity.toLowerCase()]);
const user = rows[0] || null;
const settings = await fetchAppSettings(pool);
if (user && user.email && user.email_verified_at && typeof sendAccountEmail === 'function' && typeof createOneTimeToken === 'function') {
const token = createOneTimeToken();
await pool.query('DELETE FROM a_account_tokens WHERE user_id = ? AND token_type = ?', [user.id, 'password-reset']);
const expiryMinutes = Number(settings['email.reset_expiry_minutes']);
await pool.query('INSERT INTO a_account_tokens (user_id, token_type, token_hash, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE))', [user.id, 'password-reset', hashSessionToken(token)]);
const resetUrl = getRequestOrigin(req) + '/reset-password?token=' + encodeURIComponent(token);
try {
await sendAccountEmail(settings, Object.assign({ to: user.email }, renderAccountEmailTemplate(settings['email.reset_subject'], settings['email.reset_body'], { url: resetUrl, username: user.username, display_name: user.name, email: user.email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.reset_button_alignment'], action_label: settings['email.reset_button_text'] })));
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset_requested', targetType: 'user', targetId: user.id, targetLabel: user.username });
} catch (_mailError) {
await pool.query('DELETE FROM a_account_tokens WHERE token_hash = ?', [hashSessionToken(token)]);
}
if (user && user.email && user.email_verified && settings['email.smtp_enabled']) {
await betterAuth.api.requestPasswordResetEmailOTP({ body: { email: user.email } });
}
res.send(pages.renderForgotPasswordPage(genericMessage));
const resetEmail = identity.indexOf('@') !== -1 ? identity.toLowerCase() : '';
res.send(pages.renderResetPasswordPage(genericMessage, resetEmail, await getPasswordRequirements()));
} catch (error) {
next(error);
}
@@ -209,9 +86,7 @@ module.exports = function registerAuthRoutes(app, deps) {
app.get('/reset-password', async function (req, res, next) {
try {
const token = String(req.query.token || '').trim();
if (!token) return res.redirect('/forgot-password');
res.send(pages.renderResetPasswordPage('', token, await getPasswordRequirements()));
res.send(pages.renderResetPasswordPage('', String(req.query.email || ''), await getPasswordRequirements()));
} catch (error) {
next(error);
}
@@ -219,34 +94,19 @@ module.exports = function registerAuthRoutes(app, deps) {
app.post('/reset-password', async function (req, res, next) {
try {
const token = String(req.body.token || '');
const email = String(req.body.email || '').trim().toLowerCase();
const otp = String(req.body.otp || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const [rows] = await pool.query('SELECT t.id AS token_id, t.user_id, u.username, u.email FROM a_account_tokens t JOIN a_users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.token_type = ? AND t.used_at IS NULL AND t.expires_at > NOW() LIMIT 1', [hashSessionToken(token), 'password-reset']);
const record = rows[0] || null;
if (!record) return res.status(400).send(pages.renderResetPasswordPage('This reset link is invalid or has expired.', token, await getPasswordRequirements()));
const strengthMessage = deps.validatePasswordStrength(password, await getPasswordRequirements());
if (strengthMessage || password !== confirmPassword) return res.status(400).send(pages.renderResetPasswordPage(strengthMessage || 'Passwords do not match.', token, await getPasswordRequirements()));
const passwordRecord = deps.hashPassword(password);
await pool.query('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_at = CURRENT_TIMESTAMP WHERE id = ?', [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, record.user_id]);
await pool.query('UPDATE a_account_tokens SET used_at = NOW() WHERE id = ?', [record.token_id]);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [record.user_id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset', targetType: 'user', targetId: record.user_id, targetLabel: record.username, details: { source: 'email' } });
res.redirect('/login?message=' + encodeURIComponent('Password updated. You can now sign in.'));
} catch (error) {
next(error);
}
});
app.get('/verify-email', async function (req, res, next) {
try {
const tokenHash = hashSessionToken(String(req.query.token || ''));
const [rows] = await pool.query('SELECT id FROM a_users WHERE pending_email_token_hash = ? AND pending_email_expires_at > NOW() LIMIT 1', [tokenHash]);
const user = rows[0] || null;
if (!user) return res.status(400).send(pages.renderEmailVerificationErrorPage('This email verification link is invalid or has expired.'));
await pool.query('UPDATE a_users SET email = pending_email, email_verified_at = NOW(), pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_completed', targetType: 'user', targetId: user.id });
res.send(pages.renderEmailVerifiedPage());
const requirements = await getPasswordRequirements();
const strengthMessage = deps.validatePasswordStrength(password, requirements);
if (strengthMessage || password !== confirmPassword) return res.status(400).send(pages.renderResetPasswordPage(strengthMessage || 'Passwords do not match.', email, requirements));
try {
await betterAuth.api.resetPasswordEmailOTP({ body: { email: email, otp: otp, password: password } });
} catch (error) {
return res.status(400).send(pages.renderResetPasswordPage('The email code is invalid or expired. Request a new code and try again.', email, requirements));
}
res.send(pages.renderForgotPasswordPage('Password updated. You can now sign in.'));
} catch (error) {
next(error);
}
@@ -279,116 +139,12 @@ module.exports = function registerAuthRoutes(app, deps) {
});
});
app.post('/login', async function (req, res, next) {
try {
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
if (!username || !password) {
return res.status(400).send('Username and password are required.');
}
const loginRateLimitSettings = await getLoginRateLimitSettings();
const rateKey = getRateLimitKey(username.toLowerCase(), getClientAddress(req), loginRateLimitSettings.scope);
const rateLimitStatus = await getLoginRateLimitStatus(rateKey);
if (rateLimitStatus.limited) {
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'authentication',
eventType: 'login.locked_out',
targetType: 'user',
targetLabel: username,
details: { reason: 'rate_limit' }
});
}
const message = 'Too many failed login attempts. Try again in ' + rateLimitStatus.remainingMinutes + ' minute' + (rateLimitStatus.remainingMinutes === 1 ? '' : 's') + '.';
const returnTo = getReturnTo(req);
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, message);
return res.redirect(buildLoginRedirect(returnTo, username));
}
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
}
const [usernameRows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations, account_locked FROM a_users WHERE username = ? LIMIT 1', [username]);
let user = usernameRows[0] || null;
if (!user) {
const [emailRows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations, account_locked FROM a_users WHERE email = ? AND email_verified_at IS NOT NULL LIMIT 1', [username.toLowerCase()]);
user = emailRows[0] || null;
}
if (user && user.account_locked) {
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'login.blocked_account_locked',
targetType: 'user',
targetId: user.id,
targetLabel: user.username
});
}
const returnTo = getReturnTo(req);
const message = 'This account is locked. Contact an administrator.';
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, message);
return res.redirect(buildLoginRedirect(returnTo, username));
}
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
}
if (!user || !verifyPassword(password, user)) {
const lockout = await recordFailedLogin(rateKey, loginRateLimitSettings);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'authentication',
eventType: lockout ? 'login.lockout' : 'login.failed',
targetType: user ? 'user' : 'username',
targetId: user ? user.id : null,
targetLabel: user ? user.username : username,
details: { reason: user ? 'invalid_password' : 'unknown_username' }
});
}
const message = lockout
? 'Too many failed login attempts. Try again in ' + lockout.remainingMinutes + ' minute' + (lockout.remainingMinutes === 1 ? '' : 's') + '.'
: 'Invalid username or password.';
const returnTo = getReturnTo(req);
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, message);
return res.redirect(buildLoginRedirect(returnTo, username));
}
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
}
await clearFailedLogins(rateKey);
const returnTo = getReturnTo(req);
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, {
ipAddress: getClientAddress(req),
userAgent: req.headers && req.headers['user-agent']
});
const rememberMe = req.body.remember_me === '1' || req.body.remember_me === 'true' || req.body.remember_me === true;
setSessionCookie(res, token, rememberMe ? sessionMaxAgeMs : null);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'authentication',
eventType: 'login.success',
actorUserId: user.id,
targetType: 'user',
targetId: user.id,
targetLabel: user.username,
details: { rememberMe: rememberMe }
});
}
res.redirect(returnTo || '/');
} catch (error) {
next(error);
}
});
app.get('/logout', async function (req, res, next) {
try {
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[sessionCookieName];
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
if (token) {
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [token]);
}
const actor = req.authenticatedUser || req.currentUser;
if (typeof recordRequestAuditEvent === 'function' && actor) {
+4 -1
View File
@@ -1,6 +1,7 @@
// Sign-in page renderer for the authentication flow.
const { renderView } = require('#src/web/view');
const { resolvePasskeyRpID } = require('#src/web/lib/auth/better-auth');
module.exports = function renderLoginPage(message, returnTo, username, showForgotPassword) {
return renderView('auth/login', {
@@ -11,6 +12,8 @@ module.exports = function renderLoginPage(message, returnTo, username, showForgo
returnTo: returnTo || '',
username: username || '',
showForgotPassword: Boolean(showForgotPassword),
messageVariant: 'warning'
passkeyRpID: resolvePasskeyRpID(process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL),
messageVariant: 'warning',
scripts: ['js/auth/passkey-client.js', 'js/auth/login.js']
});
};
+2 -2
View File
@@ -1,6 +1,6 @@
const { renderView } = require('#src/web/view');
module.exports = function renderResetPasswordPage(message, token, passwordRequirements) {
module.exports = function renderResetPasswordPage(message, email, passwordRequirements) {
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
const requiredCategories = [];
if (requirements.requireLowercase) requiredCategories.push('lowercase');
@@ -12,5 +12,5 @@ module.exports = function renderResetPasswordPage(message, token, passwordRequir
: requirements.minimumCategories === 4
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
return renderView('auth/reset-password', { title: 'Choose a new password', authShell: true, bodyClass: 'login-page-body', message: message || '', token: token || '', passwordMinimumLength: requirements.minimumLength, passwordRequirementsText: passwordRequirementsText });
return renderView('auth/reset-password', { title: 'Choose a new password', authShell: true, bodyClass: 'login-page-body', message: message || '', email: email || '', passwordMinimumLength: requirements.minimumLength, passwordRequirementsText: passwordRequirementsText });
};
+4 -21
View File
@@ -86,25 +86,16 @@ function registerAuthAndAccountRoutes(app, deps) {
// Auth, profile, and user management routes share the same session helpers.
registerAuthRoutes(app, {
pool: deps.pool,
betterAuth: deps.betterAuth,
fromNodeHeaders: deps.fromNodeHeaders,
pages: deps.pages,
createUserSession: deps.createUserSession,
setSessionCookie: deps.setSessionCookie,
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
getConfiguredMaxActiveSessions: deps.getConfiguredMaxActiveSessions,
clearSessionCookie: deps.clearSessionCookie,
setAuthMessageCookie: deps.setAuthMessageCookie,
consumeAuthMessageCookie: deps.consumeAuthMessageCookie,
parseCookies: deps.parseCookies,
hashSessionToken: deps.hashSessionToken,
verifyPassword: deps.verifyPassword,
hashPassword: deps.hashPassword,
validatePasswordStrength: deps.validatePasswordStrength,
sessionCookieName: deps.sessionCookieName,
recordRequestAuditEvent: deps.recordRequestAuditEvent
,sendAccountEmail: deps.sendAccountEmail
,createOneTimeToken: deps.createOneTimeToken
,getConnection: deps.pool.getConnection ? deps.pool.getConnection.bind(deps.pool) : null
,rbacData: deps.rbacData
});
registerPagesRoutes(app, {
@@ -120,22 +111,18 @@ function registerAuthAndAccountRoutes(app, deps) {
registerAccountRoutes(app, {
pool: deps.pool,
betterAuth: deps.betterAuth,
fromNodeHeaders: deps.fromNodeHeaders,
common: deps.common,
pages: deps.pages,
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
verifyPassword: deps.verifyPassword,
hashPassword: deps.hashPassword,
validatePasswordStrength: deps.validatePasswordStrength,
createUserSession: deps.createUserSession,
setSessionCookie: deps.setSessionCookie,
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
parseCookies: deps.parseCookies,
hashSessionToken: deps.hashSessionToken,
sessionCookieName: deps.sessionCookieName,
recordRequestAuditEvent: deps.recordRequestAuditEvent
,sendAccountEmail: deps.sendAccountEmail
,createOneTimeToken: deps.createOneTimeToken
});
registerUsersRoutes(app, {
@@ -150,10 +137,6 @@ function registerAuthAndAccountRoutes(app, deps) {
rbacData: deps.rbacData,
requirePermission: deps.requirePermission,
recordRequestAuditEvent: deps.recordRequestAuditEvent
,sendAccountEmail: deps.sendAccountEmail
,createOneTimeToken: deps.createOneTimeToken
,hashSessionToken: deps.hashSessionToken
,getRequestOrigin: deps.getRequestOrigin
});
}
@@ -1,15 +0,0 @@
// Pending invitations page renderer.
const { renderView } = require('#src/web/view');
module.exports = function renderInvitationsPage(data, currentUser) {
return renderView('settings/invitations/list', {
title: 'Pending invitations',
active: 'users-invitations',
userManagementMenuOpen: true,
currentUser: currentUser || null,
message: data.message || '',
invitations: data.invitations || [],
pagination: data.pagination || null
});
};
+2 -1
View File
@@ -10,7 +10,8 @@ function buildDuplicateRole(role, duplicateName) {
return {
id: null,
name: String(duplicateName || buildDuplicateRoleName(source.name)).trim(),
description: String(source.description || '').trim()
description: String(source.description || '').trim(),
require_two_factor: Boolean(source.require_two_factor)
};
}
+35 -70
View File
@@ -3,6 +3,7 @@
const { ANNOUNCEMENT_ICON_CATALOG } = require('#src/data/announcement-icons');
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, SCREEN_CONTROL_COMMAND_KEYS, SCREEN_CONTROL_COMMAND_LABELS } = require('#src/data/audit-log');
const { fetchAppSettings, saveAppSettings } = require('#src/data/app-settings');
const { normalizeOtpEmailBody } = require('#src/data/account-email-templates');
const { appVersion, detectSchemaVersion } = require('#src/db/migrations');
const UPLOAD_MIME_TYPE_OPTIONS = [
@@ -42,24 +43,17 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
'data-sources.api_default_interval_unit'
],
security: [
'security.session_lifetime_days',
'security.allow_user_session_revocation',
'security.max_active_sessions',
'security.login_max_attempts',
'security.login_lockout_minutes',
'security.login_rate_limit_scope',
'security.password_min_length',
'security.password_min_categories',
'security.password_require_lowercase',
'security.password_require_uppercase',
'security.password_require_number',
'security.password_require_symbol',
'security.require_password_change_for_new_users',
'security.require_password_change_after_admin_reset',
'security.allow_admin_email_verification_bypass'
],
email: ['email.smtp_enabled', 'email.smtp_host', 'email.smtp_port', 'email.smtp_security', 'email.smtp_username', 'email.from_address', 'email.from_name'],
'email-templates': ['email.verification_subject', 'email.verification_body', 'email.verification_button_alignment', 'email.verification_button_text', 'email.reset_subject', 'email.reset_body', 'email.reset_button_alignment', 'email.reset_button_text', 'email.invitation_subject', 'email.invitation_body', 'email.invitation_button_alignment', 'email.invitation_button_text'],
email: ['email.smtp_enabled', 'email.smtp_host', 'email.smtp_port', 'email.smtp_security', 'email.smtp_username', 'email.from_address', 'email.from_name', 'email.otp_expiry_minutes'],
'email-templates': ['email.verification_subject', 'email.verification_body', 'email.reset_subject', 'email.reset_body'],
audit: ['audit.enabled', 'audit.categories', 'audit.screen_control_commands', 'audit.include_request_metadata', 'audit.retention_days'],
media: ['uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types'],
weather: ['weather.open_meteo_api_key', 'weather.pirate_weather_api_key'],
@@ -152,20 +146,13 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
videoMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.filter(function (option) { return option.category === 'Video'; }).map(function (option) {
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
}),
sessionLifetimeDays: Number(settings['security.session_lifetime_days']) || 14,
allowUserSessionRevocation: Boolean(settings['security.allow_user_session_revocation']),
maxActiveSessions: Number(settings['security.max_active_sessions']) || 0,
loginMaxAttempts: Number(settings['security.login_max_attempts']) || 5,
loginLockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
loginRateLimitScope: String(settings['security.login_rate_limit_scope'] || 'both'),
passwordMinLength: Number(settings['security.password_min_length']) || 10,
passwordMinCategories: Number(settings['security.password_min_categories']) || 3,
passwordRequireLowercase: Boolean(settings['security.password_require_lowercase']),
passwordRequireUppercase: Boolean(settings['security.password_require_uppercase']),
passwordRequireNumber: Boolean(settings['security.password_require_number']),
passwordRequireSymbol: Boolean(settings['security.password_require_symbol']),
requirePasswordChangeForNewUsers: Boolean(settings['security.require_password_change_for_new_users']),
requirePasswordChangeAfterAdminReset: Boolean(settings['security.require_password_change_after_admin_reset']),
allowAdminEmailVerificationBypass: Boolean(settings['security.allow_admin_email_verification_bypass']),
smtpEnabled: Boolean(settings['email.smtp_enabled']),
smtpHost: String(settings['email.smtp_host'] || ''),
@@ -175,21 +162,11 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
smtpConfigured: Boolean(settings['email.smtp_password']),
emailFromAddress: String(settings['email.from_address'] || ''),
emailFromName: String(settings['email.from_name'] || ''),
verificationExpiryMinutes: Number(settings['email.verification_expiry_minutes']) || 30,
resetExpiryMinutes: Number(settings['email.reset_expiry_minutes']) || 30,
invitationExpiryHours: Number(settings['email.invitation_expiry_hours']) || 24,
otpExpiryMinutes: Number(settings['email.otp_expiry_minutes']) || 30,
verificationEmailSubject: String(settings['email.verification_subject'] || ''),
verificationEmailBody: String(settings['email.verification_body'] || ''),
verificationButtonAlignment: String(settings['email.verification_button_alignment'] || 'center'),
verificationButtonText: String(settings['email.verification_button_text'] || 'Verify email address'),
verificationEmailBody: normalizeOtpEmailBody(settings['email.verification_body']),
resetEmailSubject: String(settings['email.reset_subject'] || ''),
resetEmailBody: String(settings['email.reset_body'] || ''),
resetButtonAlignment: String(settings['email.reset_button_alignment'] || 'center'),
resetButtonText: String(settings['email.reset_button_text'] || 'Reset password'),
invitationEmailSubject: String(settings['email.invitation_subject'] || ''),
invitationEmailBody: String(settings['email.invitation_body'] || ''),
invitationButtonAlignment: String(settings['email.invitation_button_alignment'] || 'center'),
invitationButtonText: String(settings['email.invitation_button_text'] || 'Accept invitation'),
resetEmailBody: normalizeOtpEmailBody(settings['email.reset_body']),
defaultSlideDurationSeconds: Number(settings['player.default_slide_duration_seconds']) || 10,
defaultFadeBetweenSlides: Boolean(settings['player.default_fade_between_slides']),
defaultSkipUnavailableRtmp: Boolean(settings['player.skip_unavailable_rtmp']),
@@ -279,6 +256,25 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
}
});
app.post('/settings/system/login-lockouts/reset', deps.requirePermission('login-lockouts.allow'), async function (req, res, next) {
try {
await pool.query('DELETE FROM a_login_attempts WHERE rate_key LIKE ? OR rate_key LIKE ?', ['%|/sign-in/%', '%|/two-factor/verify-%']);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'system-settings',
eventType: 'system-settings.login_lockouts_reset',
actorUserId: req.currentUser && req.currentUser.id,
targetType: 'settings',
targetId: 'security',
targetLabel: 'All login lockouts'
});
}
res.redirect('/settings/system?message=' + encodeURIComponent('Sign-in and two-factor request rate limits reset for all clients.') + '#security-sessions');
} catch (error) {
next(error);
}
});
app.post('/settings/system', deps.requirePermission('system-settings.update'), async function (req, res, next) {
try {
const section = String(req.body && req.body.settings_section || '').trim().toLowerCase();
@@ -314,16 +310,11 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
if (section === 'email-templates') {
const verificationSubject = String(req.body && req.body.verification_subject || '').trim();
const verificationBody = String(req.body && req.body.verification_body || '').trim();
const verificationBody = normalizeOtpEmailBody(req.body && req.body.verification_body).trim();
const resetSubject = String(req.body && req.body.reset_subject || '').trim();
const resetBody = String(req.body && req.body.reset_body || '').trim();
const verificationButtonText = String(req.body && req.body.verification_button_text || '').trim();
const resetButtonText = String(req.body && req.body.reset_button_text || '').trim();
const invitationSubject = String(req.body && req.body.invitation_subject || '').trim();
const invitationBody = String(req.body && req.body.invitation_body || '').trim();
const invitationButtonText = String(req.body && req.body.invitation_button_text || '').trim();
if (!verificationSubject || !verificationBody.includes('[[url]]') || !resetSubject || !resetBody.includes('[[url]]') || !invitationSubject || !invitationBody.includes('[[url]]') || !verificationButtonText || !resetButtonText || !invitationButtonText) {
const error = new Error('Email templates require a subject, message, and the correct link placeholder.');
const resetBody = normalizeOtpEmailBody(req.body && req.body.reset_body).trim();
if (!verificationSubject || !verificationBody.includes('[[otp]]') || !resetSubject || !resetBody.includes('[[otp]]')) {
const error = new Error('Email templates require a subject, message, and the correct code or link placeholder.');
error.statusCode = 400;
error.expose = true;
throw error;
@@ -331,16 +322,8 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
const savedSettings = await saveAppSettings(pool, {
'email.verification_subject': verificationSubject,
'email.verification_body': verificationBody,
'email.verification_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.verification_button_alignment || '').trim()) ? String(req.body.verification_button_alignment).trim() : 'center',
'email.verification_button_text': verificationButtonText,
'email.reset_subject': resetSubject,
'email.reset_body': resetBody,
'email.reset_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.reset_button_alignment || '').trim()) ? String(req.body.reset_button_alignment).trim() : 'center',
'email.reset_button_text': resetButtonText
,'email.invitation_subject': invitationSubject
,'email.invitation_body': invitationBody
,'email.invitation_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.invitation_button_alignment || '').trim()) ? String(req.body.invitation_button_alignment).trim() : 'center'
,'email.invitation_button_text': invitationButtonText
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Account email templates saved.') + '#email-templates');
@@ -380,43 +363,29 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
}
if (section === 'security') {
const sessionLifetimeDays = Number(req.body && req.body.session_lifetime_days);
const allowUserSessionRevocation = req.body && (req.body.allow_user_session_revocation === '1' || req.body.allow_user_session_revocation === 'true');
const maxActiveSessions = Number(req.body && req.body.max_active_sessions);
const loginMaxAttempts = Number(req.body && req.body.login_max_attempts);
const loginLockoutMinutes = Number(req.body && req.body.login_lockout_minutes);
const loginRateLimitScope = String(req.body && req.body.login_rate_limit_scope || 'both').trim().toLowerCase();
const passwordMinLength = Number(req.body && req.body.password_min_length);
const passwordMinCategories = Number(req.body && req.body.password_min_categories);
const passwordRequireLowercase = req.body && (req.body.password_require_lowercase === '1' || req.body.password_require_lowercase === 'true');
const passwordRequireUppercase = req.body && (req.body.password_require_uppercase === '1' || req.body.password_require_uppercase === 'true');
const passwordRequireNumber = req.body && (req.body.password_require_number === '1' || req.body.password_require_number === 'true');
const passwordRequireSymbol = req.body && (req.body.password_require_symbol === '1' || req.body.password_require_symbol === 'true');
const requirePasswordChangeForNewUsers = req.body && (req.body.require_password_change_for_new_users === '1' || req.body.require_password_change_for_new_users === 'true');
const requirePasswordChangeAfterAdminReset = req.body && (req.body.require_password_change_after_admin_reset === '1' || req.body.require_password_change_after_admin_reset === 'true');
const requiredCategoryCount = [passwordRequireLowercase, passwordRequireUppercase, passwordRequireNumber, passwordRequireSymbol].filter(Boolean).length;
if (!Number.isInteger(sessionLifetimeDays) || sessionLifetimeDays < 1 || sessionLifetimeDays > 365 || !Number.isInteger(maxActiveSessions) || maxActiveSessions < 0 || maxActiveSessions > 100 || !Number.isInteger(loginMaxAttempts) || loginMaxAttempts < 1 || loginMaxAttempts > 100 || !Number.isInteger(loginLockoutMinutes) || loginLockoutMinutes < 1 || loginLockoutMinutes > 1440 || !['both', 'username', 'ip'].includes(loginRateLimitScope) || !Number.isInteger(passwordMinLength) || passwordMinLength < 8 || passwordMinLength > 128 || !Number.isInteger(passwordMinCategories) || passwordMinCategories < 1 || passwordMinCategories > 4 || requiredCategoryCount > passwordMinCategories) {
const error = new Error('Session and password settings must use valid values; required password categories cannot exceed the minimum category count.');
if (!Number.isInteger(passwordMinLength) || passwordMinLength < 8 || passwordMinLength > 128 || !Number.isInteger(passwordMinCategories) || passwordMinCategories < 1 || passwordMinCategories > 4 || requiredCategoryCount > passwordMinCategories) {
const error = new Error('Password settings must use valid values; required password categories cannot exceed the minimum category count.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'security.session_lifetime_days': sessionLifetimeDays,
'security.allow_user_session_revocation': allowUserSessionRevocation,
'security.max_active_sessions': maxActiveSessions,
'security.login_max_attempts': loginMaxAttempts,
'security.login_lockout_minutes': loginLockoutMinutes,
'security.login_rate_limit_scope': loginRateLimitScope,
'security.password_min_length': passwordMinLength,
'security.password_min_categories': passwordMinCategories,
'security.password_require_lowercase': passwordRequireLowercase,
'security.password_require_uppercase': passwordRequireUppercase,
'security.password_require_number': passwordRequireNumber,
'security.password_require_symbol': passwordRequireSymbol,
'security.require_password_change_for_new_users': requirePasswordChangeForNewUsers,
'security.require_password_change_after_admin_reset': requirePasswordChangeAfterAdminReset
,'security.allow_admin_email_verification_bypass': req.body && (req.body.allow_admin_email_verification_bypass === '1' || req.body.allow_admin_email_verification_bypass === 'true')
'security.allow_admin_email_verification_bypass': req.body && (req.body.allow_admin_email_verification_bypass === '1' || req.body.allow_admin_email_verification_bypass === 'true')
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Security settings saved.') + '#security-sessions');
@@ -431,10 +400,8 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
const smtpPassword = String(req.body && req.body.smtp_password || '');
const fromAddress = String(req.body && req.body.from_address || '').trim();
const fromName = String(req.body && req.body.from_name || '').trim();
const verificationExpiryMinutes = Number(req.body && req.body.verification_expiry_minutes);
const resetExpiryMinutes = Number(req.body && req.body.reset_expiry_minutes);
const invitationExpiryHours = Number(req.body && req.body.invitation_expiry_hours);
if (!Number.isInteger(verificationExpiryMinutes) || verificationExpiryMinutes < 1 || verificationExpiryMinutes > 10080 || !Number.isInteger(resetExpiryMinutes) || resetExpiryMinutes < 1 || resetExpiryMinutes > 10080 || !Number.isInteger(invitationExpiryHours) || invitationExpiryHours < 1 || invitationExpiryHours > 720 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
const otpExpiryMinutes = Number(req.body && req.body.otp_expiry_minutes);
if (!Number.isInteger(otpExpiryMinutes) || otpExpiryMinutes < 1 || otpExpiryMinutes > 10080 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
const error = new Error('Enabled email delivery requires an SMTP host, valid port, and from address.');
error.statusCode = 400;
error.expose = true;
@@ -449,9 +416,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'],
'email.from_address': fromAddress,
'email.from_name': fromName,
'email.verification_expiry_minutes': verificationExpiryMinutes,
'email.reset_expiry_minutes': resetExpiryMinutes,
'email.invitation_expiry_hours': invitationExpiryHours
'email.otp_expiry_minutes': otpExpiryMinutes
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Email settings saved.') + '#email-delivery');
@@ -36,12 +36,19 @@ function buildUsersAddViewModel(message, currentUser, roles, formValues, message
messageVariant: messageVariant || 'success',
currentUser: currentUser || null,
roles: mapRolesForForm(roles, []),
authMethods: [],
formValues: formValues || {},
formAttrs: 'data-async-save data-async-save-close-url="/settings/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/settings/users/new"'
};
}
function buildUsersEditViewModel(user, message, currentUser, roles, sessions) {
const authMethods = Array.isArray(user && user.authMethods) ? user.authMethods : [];
const passkeys = Array.isArray(user && user.passkeys) ? user.passkeys : [];
const mappedUser = Object.assign({}, user, {
passkeys: passkeys,
canUnlinkPasskeys: Boolean(currentUser && Number(currentUser.id) !== Number(user && user.id) && passkeys.length > 0)
});
return {
title: 'Edit user',
active: 'users',
@@ -60,7 +67,9 @@ function buildUsersEditViewModel(user, message, currentUser, roles, sessions) {
showSaveSecondaryActions: true,
message: message,
currentUser: currentUser || null,
user: user,
user: mappedUser,
authMethods: authMethods,
passkeys: passkeys,
sessions: Array.isArray(sessions) ? sessions : [],
inUse: Boolean(user && user.inUse),
roles: mapRolesForForm(roles, user && user.roleIds),
-14
View File
@@ -1,14 +0,0 @@
const { renderView } = require('#src/web/view');
module.exports = function renderUsersInvitePage(message, currentUser, roles, formValues, messageVariant) {
return renderView('settings/users/invite', {
title: 'Invite user',
active: 'users',
userManagementMenuOpen: true,
currentUser: currentUser || null,
message: message || '',
messageVariant: messageVariant || 'success',
roles: Array.isArray(roles) ? roles : [],
formValues: formValues || {}
});
};
+264 -48
View File
@@ -1,7 +1,9 @@
<div class="page-header">
<div>
<h2>My account</h2>
<p>Update your username, email, display name, and password.</p>
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3">
<div>
<h2>My account</h2>
<p>Manage your sign-in identity, password, and active sessions.</p>
</div>
</div>
</div>
@@ -9,7 +11,7 @@
<div class="col-12 col-xl-6">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Account details</h3>
<h3 class="card-title">Profile and email</h3>
</div>
<form id="account-profile-form" method="post" action="/account/profile" autocomplete="off" data-async-save data-async-save-refresh-page="true">
<div class="card-body">
@@ -17,23 +19,276 @@
<div class="col-12 col-md-6">
<label for="account-username" class="form-label">Username</label>
<input id="account-username" type="text" name="username" class="form-control" value="{{username}}" autocomplete="username" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" maxlength="64" required />
<div class="form-text">Used to sign in with your password.</div>
</div>
<div class="col-12 col-md-6">
<label for="account-name" class="form-label">Display name</label>
<input id="account-name" type="text" name="name" class="form-control" value="{{name}}" autocomplete="name" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" required />
</div>
<div class="col-12">
<label for="account-email" class="form-label">Email address</label>
<input id="account-email" type="email" name="email" class="form-control" value="{{email}}" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" />
<div class="form-text">{{#if emailPending}}Pending verification for {{pendingEmail}}. Enter a different address here to replace it, or resend the verification email below.{{else}}{{#if emailVerified}}Verified{{else}}Not verified{{/if}}. Email is optional; changing the address requires confirmation by email.{{/if}}</div>
<label for="account-email" class="form-label d-flex align-items-center justify-content-between gap-2">
<span>Email address</span>
<span class="small {{#if emailPending}}text-warning{{else}}{{#if emailVerified}}text-success{{else}}text-body-secondary{{/if}}{{/if}}">{{#if emailPending}}{{#if pendingEmailChange}}Change pending{{else}}Verification pending{{/if}}{{else}}{{#if emailVerified}}Verified{{else}}Not verified{{/if}}{{/if}}</span>
</label>
<div class="input-group">
<input id="account-email" type="email" name="email" class="form-control" value="{{email}}" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" />
{{#if emailPending}}
<button type="submit" class="btn btn-outline-primary" form="account-email-verification-form">Resend code</button>
{{else}}
{{#unless emailVerified}}
{{#if email}}<button type="submit" class="btn btn-outline-primary" form="account-email-verification-form">Send code</button>{{/if}}
{{/unless}}
{{/if}}
</div>
<div class="form-text">{{#if emailPending}}A code was sent to {{pendingEmail}}. Enter it below to verify the address.{{else}}Used for email verification and password recovery. Changing the address requires a code.{{/if}}</div>
</div>
<div class="col-12">
<label for="account-profile-current-password" class="form-label">Current password</label>
<input id="account-profile-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
<div class="form-text">Confirm your password to update your profile.</div>
</div>
</div>
<input type="hidden" name="current_password" data-account-profile-current-password />
</div>
</form>
{{#if emailPending}}<form method="post" action="/account/email" class="px-3 pb-3"><input type="hidden" name="email" value="{{pendingEmail}}"><button type="submit" class="btn btn-sm btn-outline-primary">Send verification email</button></form>{{/if}}
{{#if emailPending}}
<form id="account-email-verification-form" method="post" action="/account/email" hidden></form>
{{else}}
{{#if email}}
{{#unless emailVerified}}<form id="account-email-verification-form" method="post" action="/account/email/send-verification" hidden></form>{{/unless}}
{{/if}}
{{/if}}
<div class="card-footer d-flex justify-content-end">
<button type="submit" class="btn btn-success" form="account-profile-form" name="save_action" value="save">Save account details</button>
</div>
{{#if emailPending}}
<div class="card-body border-top pt-3">
<form method="post" action="/account/email/verify" class="mb-0">
<label for="account-email-otp" class="form-label">Email code</label>
<div class="input-group">
<input id="account-email-otp" name="otp" type="text" class="form-control" inputmode="numeric" autocomplete="one-time-code" required />
<button type="submit" class="btn btn-primary">Verify email</button>
</div>
</form>
</div>
{{/if}}
</div>
{{#if canManageTwoFactor}}
<div class="card card-outline card-info admin-form-card mt-3">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Sign-in methods</h3>
</div>
{{#if requiresTwoFactor}}
{{#unless twoFactorEnabled}}
<div class="alert alert-warning m-3 mb-0" role="alert">Two-factor authentication is required by one of your roles. Set up an authenticator to continue.</div>
{{/unless}}
{{/if}}
<div class="card-body py-0" data-passkey-rp-id="{{passkeyRpID}}">
<ul class="list-group list-group-flush">
{{#if authMethods.length}}
{{#each authMethods}}
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
<span>{{label}}</span>
<span class="small text-success">Linked</span>
</li>
{{/each}}
{{else}}
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
<span>No linked sign-in methods</span>
<span class="small text-body-secondary">None</span>
</li>
{{/if}}
{{#if twoFactorEnabled}}
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
<span>Authenticator app (TOTP)</span>
<span class="d-flex align-items-center gap-2">
<span class="small text-success">Enabled</span>
<button type="button" class="btn btn-sm {{#if requiresTwoFactor}}btn-outline-secondary{{else}}btn-outline-danger{{/if}}" data-bs-toggle="modal" data-bs-target="#two-factor-manage-modal">{{#if requiresTwoFactor}}Manage{{else}}Unlink{{/if}}</button>
</span>
</li>
{{else}}
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
<span>Two-step verification</span>
<span class="d-flex align-items-center gap-2">
{{#if requiresTwoFactor}}<span class="small text-warning">Required by role</span>{{/if}}
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#two-factor-setup-modal">Set up</button>
</span>
</li>
{{/if}}
<li class="list-group-item px-0" data-passkey-group>
<div class="d-flex align-items-center justify-content-between gap-3">
<span>Passkeys</span>
{{#if requiresTwoFactor}}
<span class="small text-body-secondary">Unavailable while an authenticator is required</span>
{{else if twoFactorEnabled}}
<span class="small text-body-secondary">Unavailable while authenticator verification is enabled</span>
{{else}}
<button type="button" class="btn btn-sm btn-outline-primary" data-passkey-add-trigger data-bs-toggle="modal" data-bs-target="#passkey-setup-modal">Set up</button>
{{/if}}
</div>
{{#if passkeys.length}}
<ul class="list-group list-group-flush ms-2 mt-1 ps-2" data-passkey-list>
{{#each passkeys}}
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">
<span><i class="bi bi-arrow-return-right me-2 text-body-secondary" aria-hidden="true"></i>{{#if name}}{{name}}{{else}}Passkey{{/if}}</span>
<button type="button" class="btn btn-sm btn-outline-danger" data-passkey-delete="{{id}}" aria-label="Remove passkey">Remove</button>
</li>
{{/each}}
</ul>
{{/if}}
</li>
</ul>
<p class="form-text mx-3 mb-3" data-passkey-unavailable hidden>Passkeys require HTTPS and the configured Pulse hostname.</p>
</div>
</div>
{{#unless twoFactorEnabled}}
{{#unless requiresTwoFactor}}
<div class="modal fade" id="passkey-setup-modal" tabindex="-1" aria-labelledby="passkey-setup-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title fs-5" id="passkey-setup-title">Set up a passkey</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Use your device biometrics, device PIN, or a security key. Your browser will guide you through setup.</p>
<form data-passkey-add>
<label for="passkey-name" class="form-label">Passkey name</label>
<div class="input-group">
<input id="passkey-name" name="name" type="text" class="form-control" maxlength="255" placeholder="For example, Work laptop" autocomplete="off" />
<button type="submit" class="btn btn-primary" data-passkey-submit><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Continue</button>
</div>
<div class="form-text" data-passkey-progress hidden>Follow the prompt from your browser or device to finish adding the passkey.</div>
<div class="alert alert-warning mt-2 mb-0" data-passkey-feedback role="alert" hidden></div>
</form>
</div>
</div>
</div>
</div>
{{/unless}}
{{/unless}}
{{#unless twoFactorEnabled}}
<div class="modal fade" id="two-factor-setup-modal" tabindex="-1" aria-labelledby="two-factor-setup-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title fs-5" id="two-factor-setup-title">Set up two-step verification</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form data-two-factor-enable>
<label for="two-factor-enable-password" class="form-label">Current password</label>
<div class="input-group">
<input id="two-factor-enable-password" name="password" type="password" class="form-control" autocomplete="current-password" required />
<button type="submit" class="btn btn-primary">Continue</button>
</div>
<div class="form-text">You’ll scan a QR code with your authenticator app, save recovery codes, then verify a code to finish.</div>
<div class="alert alert-warning mt-2" data-two-factor-enable-feedback hidden role="alert"></div>
</form>
<div data-two-factor-setup hidden>
<p class="fw-semibold">Scan this QR code with your authenticator app</p>
<div class="d-flex flex-column flex-sm-row align-items-start gap-3">
<div class="p-1 bg-white border rounded" data-two-factor-qr></div>
<div>
<p class="small mb-1">Or enter this setup key manually:</p>
<code class="user-select-all" data-two-factor-secret></code>
</div>
</div>
<div class="alert alert-warning mt-3" data-two-factor-codes hidden>
<div class="d-flex align-items-start justify-content-between gap-3 mb-2">
<p class="fw-semibold mb-0">Save these recovery codes before verifying. Each code can only be used once.</p>
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" data-two-factor-download aria-label="Download recovery codes" title="Download recovery codes"><i class="bi bi-download" aria-hidden="true"></i></button>
</div>
<ul class="mb-2 font-monospace" data-two-factor-codes-list></ul>
</div>
<form data-two-factor-verify class="mt-3">
<label for="two-factor-setup-code" class="form-label">Authenticator code</label>
<div class="input-group">
<input id="two-factor-setup-code" name="code" type="text" class="form-control" inputmode="numeric" autocomplete="one-time-code" required />
<button type="submit" class="btn btn-primary">Verify and enable</button>
</div>
<div class="alert alert-warning mt-2" data-two-factor-verify-feedback hidden role="alert"></div>
</form>
</div>
</div>
</div>
</div>
</div>
{{/unless}}
{{#if twoFactorEnabled}}
<div class="modal fade" id="two-factor-manage-modal" tabindex="-1" aria-labelledby="two-factor-manage-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title fs-5" id="two-factor-manage-title">Manage authenticator</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{#unless requiresTwoFactor}}
<form data-two-factor-disable>
<p>Unlinking removes the authenticator and recovery codes. You will need to sign in again.</p>
<label for="two-factor-disable-password" class="form-label">Current password</label>
<div class="input-group">
<input id="two-factor-disable-password" name="password" type="password" class="form-control" autocomplete="current-password" required />
<button type="submit" class="btn btn-outline-danger">Unlink</button>
</div>
<div class="alert alert-warning mt-2" data-two-factor-disable-feedback hidden role="alert"></div>
</form>
{{/unless}}
<hr>
<form data-two-factor-regenerate-codes>
<label for="two-factor-codes-password" class="form-label">Generate new recovery codes</label>
<div class="input-group">
<input id="two-factor-codes-password" name="password" type="password" class="form-control" autocomplete="current-password" placeholder="Current password" required />
<button type="submit" class="btn btn-outline-primary">Generate</button>
</div>
<div class="form-text">Generating new codes invalidates all previously issued recovery codes.</div>
</form>
<div class="alert alert-success mt-3" data-two-factor-codes hidden>
<div class="d-flex align-items-start justify-content-between gap-3 mb-2">
<p class="fw-semibold mb-0">Store these recovery codes somewhere safe. Each code can only be used once.</p>
<button type="button" class="btn btn-sm btn-outline-success flex-shrink-0" data-two-factor-download aria-label="Download recovery codes" title="Download recovery codes"><i class="bi bi-download" aria-hidden="true"></i></button>
</div>
<ul class="mb-2 font-monospace" data-two-factor-codes-list></ul>
</div>
<div class="alert alert-warning mt-2" data-two-factor-codes-feedback hidden role="alert"></div>
</div>
</div>
</div>
</div>
{{/if}}
{{/if}}
</div>
<div class="col-12 col-xl-6">
<div class="card card-outline card-warning admin-form-card">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Change password</h3>
</div>
<form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
<div class="card-body">
<div class="mb-3">
<label for="account-password-current-password" class="form-label">Current password</label>
<input id="account-password-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
</div>
<div class="mb-3">
<label for="account-new-password" class="form-label">New password</label>
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
</div>
<div class="mb-0">
<label for="account-confirm-password" class="form-label">Confirm new password</label>
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
</div>
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
</div>
</form>
<div class="card-footer d-flex justify-content-end">
<button type="submit" class="btn btn-warning" form="account-password-form" name="save_action" value="save">Change password</button>
</div>
</div>
{{#if allowUserSessionRevocation}}
<div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header bg-body-tertiary">
@@ -71,43 +326,4 @@
</div>
{{/if}}
</div>
<div class="col-12 col-xl-6">
<div class="card card-outline card-warning admin-form-card">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Password</h3>
</div>
<form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
<div class="card-body">
<input type="hidden" name="current_password" data-account-password-current-password />
<div class="mb-3">
<label for="account-new-password" class="form-label">New password</label>
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
</div>
<div class="mb-0">
<label for="account-confirm-password" class="form-label">Confirm new password</label>
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
</div>
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
</div>
</form>
</div>
<div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Confirmation</h3>
</div>
<div class="card-body">
<label for="account-current-password" class="form-label">Current password</label>
<input id="account-current-password" type="password" class="form-control" autocomplete="current-password" data-account-current-password required />
<div class="form-text">Required to save account details or change your password.</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Account change actions">
<button type="submit" class="btn btn-success" form="account-profile-form" name="save_action" value="save">Save account details</button>
<button type="submit" class="btn btn-warning" form="account-password-form" name="save_action" value="save">Change password</button>
<a class="btn btn-secondary" href="{{returnUrl}}" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</div>
</div>
</div>
-27
View File
@@ -1,27 +0,0 @@
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
{{#if isValidInvitation}}
<div class="card mx-auto" style="max-width: 30rem;"><div class="card-body p-4">
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
<form method="post" action="/accept-invite">
<input type="hidden" name="token" value="{{token}}">
<div class="mb-3"><label for="invite-email" class="form-label">Email address</label><input id="invite-email" type="email" class="form-control" value="{{email}}" disabled></div>
<div class="mb-3"><label for="invite-username" class="form-label">Username</label><input id="invite-username" name="username" type="text" class="form-control" autocomplete="username" maxlength="255" required></div>
<div class="mb-3"><label for="invite-name" class="form-label">Display name</label><input id="invite-name" name="name" type="text" class="form-control" value="{{name}}" autocomplete="name" maxlength="255" required></div>
<div class="mb-3"><label for="invite-password" class="form-label">Password</label><input id="invite-password" name="password" type="password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required></div>
<div class="mb-3"><label for="invite-confirm-password" class="form-label">Confirm password</label><input id="invite-confirm-password" name="confirm_password" type="password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required></div>
<div class="form-text mb-3">{{passwordRequirementsText}}</div>
<button type="submit" class="btn btn-primary w-100">Create account</button>
</form>
</div></div>
{{else}}
<div class="card login-card email-verification-error-card mx-auto">
<div class="email-verification-error-card__header">
<div class="email-verification-error-card__icon"><i class="bi bi-exclamation-lg" aria-hidden="true"></i></div>
<h1 class="h4 mb-0">Invitation link unavailable</h1>
</div>
<div class="card-body login-card-body text-center">
<p class="mb-4">{{message}}</p>
<a href="/login" class="btn btn-primary w-100">Return to sign in</a>
</div>
</div>
{{/if}}
@@ -1,11 +0,0 @@
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
<div class="card login-card email-verification-error-card mx-auto">
<div class="email-verification-error-card__header">
<div class="email-verification-error-card__icon"><i class="bi bi-exclamation-lg" aria-hidden="true"></i></div>
<h1 class="h4 mb-0">Verification link unavailable</h1>
</div>
<div class="card-body login-card-body text-center">
<p class="mb-4">{{message}}</p>
<a href="/login" class="btn btn-primary w-100">Return to sign in</a>
</div>
</div>
-11
View File
@@ -1,11 +0,0 @@
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
<div class="card login-card email-verified-card mx-auto">
<div class="email-verified-card__header">
<div class="email-verified-card__icon"><i class="bi bi-check-lg" aria-hidden="true"></i></div>
<h1 class="h4 mb-0">Email verified</h1>
</div>
<div class="card-body login-card-body text-center">
<p class="mb-4">Your email address has been verified successfully.</p>
<a href="/login" class="btn btn-primary w-100">Continue to sign in</a>
</div>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<form method="post" action="/forgot-password">
<label for="reset-identity" class="form-label">Username or email</label>
<input id="reset-identity" name="identity" type="text" class="form-control mb-3" autocomplete="username" required>
<button type="submit" class="btn btn-primary w-100">Email reset link</button>
<button type="submit" class="btn btn-primary w-100">Send reset code</button>
</form>
<a class="d-block text-center mt-3" href="/login">Back to sign in</a>
</div></div>
+45 -26
View File
@@ -1,44 +1,63 @@
<div class="login-logo text-center mb-3">
<strong>Pulse</strong> Signage
<a class="text-reset text-decoration-none" href="/login"><strong>Pulse</strong> Signage</a>
</div>
<div class="card login-card mx-auto">
<div class="card-body login-card-body">
<p class="login-box-msg">Sign in to start your session</p>
{{#if message}}
<div class="alert alert-warning py-2">{{message}}</div>
<div class="alert alert-warning py-2" data-login-initial-message>{{message}}</div>
{{/if}}
<form method="post" action="/login">
<form method="post" action="/api/auth/sign-in/username" data-better-auth-login>
{{#if returnTo}}
<input type="hidden" name="returnTo" value="{{returnTo}}" />
{{/if}}
<div class="mb-3">
<label for="username" class="visually-hidden">Username or email</label>
<div class="input-group">
<input id="username" name="username" type="text" class="form-control" placeholder="Username or email" autocomplete="username" value="{{username}}" required />
<span class="input-group-text" aria-hidden="true"><i class="bi bi-person"></i></span>
</div>
</div>
<div class="mb-3">
<label for="password" class="visually-hidden">Password</label>
<div class="input-group">
<input id="password" name="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required />
<span class="input-group-text" aria-hidden="true"><i class="bi bi-lock"></i></span>
</div>
</div>
<div class="row align-items-center g-3 mb-3">
<div class="col-7">
<div class="form-check">
<input id="remember-me" name="remember_me" type="checkbox" class="form-check-input" value="1" />
<label for="remember-me" class="form-check-label">Remember me</label>
<div data-login-credentials>
<div class="mb-3">
<label for="username" class="visually-hidden">Username or email</label>
<div class="input-group">
<input id="username" name="username" type="text" class="form-control" placeholder="Username or email" autocomplete="username" value="{{username}}" required />
<span class="input-group-text" aria-hidden="true"><i class="bi bi-person"></i></span>
</div>
</div>
<div class="col-5">
<button type="submit" class="btn btn-primary w-100">Sign in</button>
<div class="mb-3">
<label for="password" class="visually-hidden">Password</label>
<div class="input-group">
<input id="password" name="password" type="password" class="form-control" placeholder="Enter your password" autocomplete="current-password" required />
<span class="input-group-text" aria-hidden="true"><i class="bi bi-lock"></i></span>
</div>
</div>
</div>
<div class="mb-3" data-login-two-factor hidden>
<p class="mb-2 fw-semibold">Two-factor authentication</p>
<p class="small text-body-secondary">Enter a code from your authenticator app or use a recovery code.</p>
<label for="two-factor-code" class="visually-hidden" data-two-factor-code-label>Authenticator code</label>
<input id="two-factor-code" name="two_factor_code" type="text" class="form-control mb-2" inputmode="numeric" autocomplete="one-time-code" disabled />
<div class="form-check mb-2">
<input id="trust-device" name="trust_device" type="checkbox" class="form-check-input" value="1" disabled />
<label for="trust-device" class="form-check-label">Trust this device for 30 days</label>
</div>
<div class="d-flex align-items-center justify-content-between gap-2">
<button type="button" class="btn btn-sm btn-link p-0" data-two-factor-toggle-method>Use recovery code</button>
<button type="button" class="btn btn-sm btn-link p-0" data-two-factor-back>Back to sign in</button>
</div>
</div>
<div class="alert alert-warning py-2" data-login-feedback role="alert" hidden></div>
<div class="d-flex align-items-center justify-content-between gap-3 mb-3" data-login-actions>
<div data-login-remember>
<div class="form-check">
<input id="remember-me" name="remember_me" type="checkbox" class="form-check-input" value="1" />
<label for="remember-me" class="form-check-label">Remember Me</label>
</div>
</div>
<button type="submit" class="btn btn-primary px-4" data-login-submit>Sign In</button>
</div>
<div data-passkey-login-panel data-passkey-rp-id="{{passkeyRpID}}">
<div class="login-or-divider" aria-hidden="true"><span>OR</span></div>
<button type="button" class="btn btn-secondary w-100" data-passkey-login><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Sign in with a passkey</button>
<p class="form-text text-center mb-0 mt-2" data-passkey-unavailable hidden>Passkeys require HTTPS and the configured Pulse hostname.</p>
</div>
</form>
{{#if showForgotPassword}}
<p class="mb-0 text-center"><a href="/forgot-password">I forgot my password</a></p>
<p class="mb-0 mt-3"><a href="/forgot-password">I forgot my password</a></p>
{{/if}}
</div>
</div>
+4 -1
View File
@@ -3,7 +3,10 @@
<h1 class="h4 mb-3">Choose a new password</h1>
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
<form method="post" action="/reset-password">
<input type="hidden" name="token" value="{{token}}">
<label for="reset-email" class="form-label">Email address</label>
<input id="reset-email" name="email" type="email" class="form-control mb-3" autocomplete="email" value="{{email}}" required>
<label for="reset-otp" class="form-label">Email code</label>
<input id="reset-otp" name="otp" type="text" class="form-control mb-3" autocomplete="one-time-code" inputmode="numeric" required>
<label for="reset-password" class="form-label">New password</label>
<input id="reset-password" name="password" type="password" class="form-control mb-3" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required>
<label for="reset-confirm-password" class="form-label">Confirm password</label>
@@ -1,67 +0,0 @@
<div class="page-header">
<div>
<h2>Pending invitations</h2>
<p>Invitations that have not yet been accepted.</p>
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">Active invitations</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search invitations" aria-label="Search invitations" data-table-search />
</div>
{{#if (hasPermission currentUser 'invitations.create')}}
<a href="/settings/users/invite" class="btn btn-info btn-sm">Invite user</a>
{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0">
<thead>
<tr>
<th data-table-sort-key="email">Email</th>
<th data-table-sort-key="name">Name</th>
<th>Roles</th>
<th data-table-sort-key="created">Sent</th>
<th data-table-sort-key="expires">Expires</th>
<th data-table-sort-key="createdBy">Sent by</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if invitations.length}}
{{#each invitations}}
<tr data-table-search-row>
<td data-label="Email">{{email}}</td>
<td data-label="Name">{{#if name}}{{name}}{{else}}-{{/if}}</td>
<td data-label="Roles">{{roleNames}}</td>
<td data-label="Sent">{{createdAtLabel}}</td>
<td data-label="Expires">{{expiresAtLabel}}</td>
<td data-label="Sent by">{{createdByLabel}}</td>
<td data-label="Actions">
<div class="actions users-row-actions">
{{#if (hasPermission ../currentUser 'invitations.allow')}}
<form method="post" action="/settings/invitations/{{id}}/resend" class="inline-form" data-confirm-message="Resend invitation to {{email}}?">
<button type="submit" class="btn btn-sm btn-primary">Resend</button>
</form>
{{/if}}
{{#if (hasPermission ../currentUser 'invitations.delete')}}
<form method="post" action="/settings/invitations/{{id}}/delete" class="inline-form" data-confirm-message="Delete invitation for {{email}}?">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{{/if}}
</div>
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="7" class="empty">No pending invitations.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/settings/invitations" alwaysShow=true}}
</div>
+4
View File
@@ -24,6 +24,10 @@
<label for="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" class="form-label">Description</label>
<textarea id="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" name="description" class="form-control" rows="5" maxlength="512" data-limit-text-length>{{#if isEdit}}{{role.description}}{{else}}{{formValues.description}}{{/if}}</textarea>
</div>
<div class="form-check form-switch mt-3">
<input id="role-require-two-factor" name="require_two_factor" type="checkbox" class="form-check-input" value="1" {{#if isEdit}}{{#if role.require_two_factor}}checked{{/if}}{{else}}{{#if formValues.require_two_factor}}checked{{/if}}{{/if}} />
<label class="form-check-label" for="role-require-two-factor">Require two-factor authentication</label>
</div>
</div>
<div class="card-footer d-flex justify-content-end flex-wrap">
<div class="btn-group" role="group" aria-label="Role actions">
+15 -75
View File
@@ -1,6 +1,6 @@
<div class="page-header">
<div>
<h2>System Settings</h2>
<h2>System settings</h2>
<p>Configure the defaults and content policies used across Pulse Signage.</p>
</div>
</div>
@@ -282,9 +282,7 @@
<div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-password">SMTP password</label><input id="settings-smtp-password" name="smtp_password" type="password" class="form-control" placeholder="{{#if mediaSettings.smtpConfigured}}Leave unchanged{{/if}}"></div>
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-address">From address</label><input id="settings-from-address" name="from_address" type="email" class="form-control" value="{{mediaSettings.emailFromAddress}}"></div>
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-name">From name</label><input id="settings-from-name" name="from_name" type="text" class="form-control" value="{{mediaSettings.emailFromName}}"></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-expiry">Verification expiry</label><div class="input-group"><input id="settings-verification-expiry" name="verification_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.verificationExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-expiry">Password reset expiry</label><div class="input-group"><input id="settings-reset-expiry" name="reset_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.resetExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-expiry">Invitation expiry</label><div class="input-group"><input id="settings-invitation-expiry" name="invitation_expiry_hours" type="number" class="form-control" min="1" max="720" value="{{mediaSettings.invitationExpiryHours}}"><span class="input-group-text">hours</span></div></div>
<div class="col-12 col-md-6"><label class="form-label" for="settings-otp-expiry">Email code expiry</label><div class="input-group"><input id="settings-otp-expiry" name="otp_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.otpExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
</div></div>
<div class="card-footer d-flex align-items-center">
{{#if (hasPermission currentUser 'system-settings.update')}}
@@ -303,7 +301,7 @@
<div id="email-templates" class="card settings-section-card" data-settings-section hidden>
<div class="card-header"><h3 class="h5 mb-1 fw-semibold"><i class="bi bi-envelope-paper me-2 text-primary" aria-hidden="true"></i>Email templates</h3><p class="text-muted small mb-0">Customize the messages sent for email verification and password resets.</p></div>
<div class="card-body">
<div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" data-verification-expiry="{{mediaSettings.verificationExpiryMinutes}}" data-reset-expiry="{{mediaSettings.resetExpiryMinutes}}" data-invitation-expiry="{{mediaSettings.invitationExpiryHours}}" hidden></div>
<div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" data-otp-expiry="{{mediaSettings.otpExpiryMinutes}}" hidden></div>
<div class="email-template-editor" data-email-template-editor>
<div class="row g-4 align-items-start">
<div class="col-12 col-lg-6">
@@ -313,11 +311,7 @@
<label class="form-label" for="settings-verification-body">Message</label>
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Verification message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
<textarea id="settings-verification-body" name="verification_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.verificationEmailBody}}</textarea>
<div class="row g-2 mt-1">
<div class="col-12 col-md-8"><label class="form-label" for="settings-verification-button-text">Button text</label><input id="settings-verification-button-text" name="verification_button_text" type="text" class="form-control" value="{{mediaSettings.verificationButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-alignment">Alignment</label><select id="settings-verification-alignment" name="verification_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.verificationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.verificationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.verificationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the verification link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
<div class="form-text">Use <code>[[otp]]</code> for the one-time code. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
</div>
<div class="col-12 col-lg-6">
<div class="email-template-preview" data-email-template-preview aria-label="Email verification preview"></div>
@@ -334,36 +328,13 @@
<label class="form-label" for="settings-reset-body">Message</label>
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Reset message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
<textarea id="settings-reset-body" name="reset_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.resetEmailBody}}</textarea>
<div class="row g-2 mt-1">
<div class="col-12 col-md-8"><label class="form-label" for="settings-reset-button-text">Button text</label><input id="settings-reset-button-text" name="reset_button_text" type="text" class="form-control" value="{{mediaSettings.resetButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-alignment">Alignment</label><select id="settings-reset-alignment" name="reset_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.resetButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.resetButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.resetButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the reset link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
<div class="form-text">Use <code>[[otp]]</code> for the one-time code. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
</div>
<div class="col-12 col-lg-6">
<div class="email-template-preview" data-email-template-preview aria-label="Password reset preview"></div>
</div>
</div>
</div>
<hr class="my-4">
<div class="email-template-editor" data-email-template-editor>
<div class="row g-4 align-items-start">
<div class="col-12 col-lg-6">
<h4 class="h6">User invitation</h4>
<label class="form-label" for="settings-invitation-subject">Subject</label>
<input id="settings-invitation-subject" name="invitation_subject" type="text" class="form-control mb-2" value="{{mediaSettings.invitationEmailSubject}}" maxlength="255" data-email-template-subject>
<label class="form-label" for="settings-invitation-body">Message</label>
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Invitation message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
<textarea id="settings-invitation-body" name="invitation_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.invitationEmailBody}}</textarea>
<div class="row g-2 mt-1">
<div class="col-12 col-md-8"><label class="form-label" for="settings-invitation-button-text">Button text</label><input id="settings-invitation-button-text" name="invitation_button_text" type="text" class="form-control" value="{{mediaSettings.invitationButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-alignment">Alignment</label><select id="settings-invitation-alignment" name="invitation_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.invitationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.invitationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.invitationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the invitation link. Available values: <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
</div>
<div class="col-12 col-lg-6"><div class="email-template-preview" data-email-template-preview aria-label="User invitation preview"></div></div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
{{#if (hasPermission currentUser 'system-settings.update')}}<button type="submit" class="btn btn-success">Save</button>{{else}}<button type="button" class="btn btn-outline-success" disabled>Save</button>{{/if}}
@@ -371,6 +342,9 @@
</div>
</form>
{{#if (hasPermission currentUser 'login-lockouts.allow')}}
<form id="reset-login-lockouts-form" method="post" action="/settings/system/login-lockouts/reset" data-confirm-message="Clear sign-in and two-factor request rate limits for all clients? This does not clear per-account authenticator lockouts."></form>
{{/if}}
<form method="post" action="/settings/system" data-async-save data-settings-form="security">
<input type="hidden" name="settings_section" value="security">
<div id="security-sessions" class="card settings-section-card" data-settings-section hidden>
@@ -383,22 +357,6 @@
<div class="card-body">
<div class="row g-3">
<div class="col-12"><h4 class="h6 text-uppercase text-muted mb-0">Sessions</h4></div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-session-lifetime">Session lifetime</label>
<div class="input-group">
<input id="settings-session-lifetime" name="session_lifetime_days" type="number" class="form-control" min="1" max="365" required value="{{mediaSettings.sessionLifetimeDays}}">
<span class="input-group-text">days</span>
</div>
<div class="form-text">Applies to new logins. Existing sessions keep their current expiry.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-max-active-sessions">Maximum active sessions</label>
<div class="input-group">
<input id="settings-max-active-sessions" name="max_active_sessions" type="number" class="form-control" min="0" max="100" required value="{{mediaSettings.maxActiveSessions}}">
<span class="input-group-text">sessions</span>
</div>
<div class="form-text">Use 0 for unlimited. Oldest sessions are removed first.</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input id="settings-allow-session-revocation" name="allow_user_session_revocation" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.allowUserSessionRevocation}}checked{{/if}}>
@@ -406,26 +364,12 @@
</div>
</div>
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Login protection</h4></div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-login-attempts">Failed attempts before lockout</label>
<input id="settings-login-attempts" name="login_max_attempts" type="number" class="form-control" min="1" max="100" required value="{{mediaSettings.loginMaxAttempts}}">
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-login-lockout">Lockout duration</label>
<div class="input-group">
<input id="settings-login-lockout" name="login_lockout_minutes" type="number" class="form-control" min="1" max="1440" required value="{{mediaSettings.loginLockoutMinutes}}">
<span class="input-group-text">minutes</span>
<div class="col-12"><p class="form-text mb-0">Built-in request protection allows up to 30 requests per 15 minutes, with username sign-in limited to 5 per 15 minutes.</p></div>
{{#if (hasPermission currentUser 'login-lockouts.allow')}}
<div class="col-12">
<button type="submit" form="reset-login-lockouts-form" class="btn btn-sm btn-outline-warning"><i class="bi bi-unlock me-1" aria-hidden="true"></i>Reset sign-in and two-factor request limits</button>
</div>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-login-scope">Track failed logins by</label>
<select id="settings-login-scope" name="login_rate_limit_scope" class="form-select" required>
<option value="both" {{#if (eq mediaSettings.loginRateLimitScope 'both')}}selected{{/if}}>Username and IP address</option>
<option value="username" {{#if (eq mediaSettings.loginRateLimitScope 'username')}}selected{{/if}}>Username</option>
<option value="ip" {{#if (eq mediaSettings.loginRateLimitScope 'ip')}}selected{{/if}}>IP address</option>
</select>
<div class="form-text">Failed login responses remain generic.</div>
</div>
{{/if}}
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Password and account protection</h4></div>
<div class="col-12 col-md-6 col-xl-3">
<label class="form-label" for="settings-password-min-length">Minimum length</label>
@@ -450,13 +394,9 @@
</div>
</div>
<div class="col-12">
<div class="form-check form-switch mb-2">
<input id="settings-require-password-new" name="require_password_change_for_new_users" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.requirePasswordChangeForNewUsers}}checked{{/if}}>
<label class="form-check-label" for="settings-require-password-new">Require password change for newly created users</label>
</div>
<div class="form-check form-switch">
<input id="settings-require-password-reset" name="require_password_change_after_admin_reset" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.requirePasswordChangeAfterAdminReset}}checked{{/if}}>
<label class="form-check-label" for="settings-require-password-reset">Require password change after an administrator reset</label>
<input id="settings-admin-email-verification-bypass" name="allow_admin_email_verification_bypass" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.allowAdminEmailVerificationBypass}}checked{{/if}}>
<label class="form-check-label" for="settings-admin-email-verification-bypass">Allow administrators to mark user email addresses as verified</label>
</div>
</div>
</div>
+81 -27
View File
@@ -7,7 +7,7 @@
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="col-12 col-xl-5">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">{{#if isEdit}}Profile{{else}}User details{{/if}}</h3>
@@ -25,7 +25,7 @@
<div class="col-12">
<label for="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" class="form-label">Email address</label>
<input id="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" type="email" name="email" class="form-control" autocomplete="email" value="{{#if isEdit}}{{user.email}}{{else}}{{formValues.email}}{{/if}}" maxlength="320" />
{{#if isEdit}}<div class="form-text">{{#if user.email_verified_at}}Verified{{else}}Not verified{{/if}}. Email is used for password recovery.</div>{{/if}}
{{#if isEdit}}<div class="form-text">{{#if user.email_verified}}Verified{{else}}Not verified{{/if}}. Email is used for password recovery.</div>{{/if}}
</div>
</div>
{{#if isEdit}}
@@ -39,19 +39,25 @@
</div>
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input id="edit-user-email-verified" name="email_verified" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if user.email_verified_at}}checked{{/if}}>
<input id="edit-user-email-verified" name="email_verified" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if user.email_verified}}checked{{/if}}>
<label class="form-check-label" for="edit-user-email-verified">Email verified</label>
</div>
</div>
</div>
</div>
{{/if}}
<div class="border-top mt-3 pt-3">
<div class="form-check form-switch">
<input id="user-must-change-password" name="must_change_password" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if isEdit}}{{#if user.must_change_password}}checked{{/if}}{{else}}checked{{/if}}>
<label class="form-check-label" for="user-must-change-password">Require password change at next sign-in</label>
</div>
</div>
</div>
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap">
{{#if isEdit}}
{{#if (hasPermission currentUser 'impersonate.allow')}}
{{#unless currentUser.isViewingAs}}
<button type="submit" form="view-as-user-form" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person-badge me-1" aria-hidden="true"></i>View as</button>
<button type="submit" form="view-as-user-form" class="btn btn btn-outline-secondary"><i class="bi bi-person-badge me-1" aria-hidden="true"></i>View as</button>
{{/unless}}
{{/if}}
{{/if}}
@@ -76,40 +82,80 @@
<input id="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="10" {{#unless isEdit}}required{{/unless}} />
</div>
</div>
<div class="form-text mt-2">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
<div class="form-text">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
{{#if isEdit}}
<div class="form-text mt-2">Leave both fields blank to keep the current password.</div>
<div class="form-text">Leave both fields blank to keep the current password.</div>
{{/if}}
</div>
</div>
{{#if isEdit}}
<div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Sign-in methods</h3>
</div>
<div class="card-body py-0">
<ul class="list-group list-group-flush">
{{#each authMethods}}
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
<span>{{label}}</span>
<span class="small text-success">Linked</span>
</li>
{{/each}}
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
<span>{{#if user.twoFactorEnabled}}Authenticator app (TOTP){{else}}{{#if user.twoFactorSetupPending}}Authenticator setup{{else}}Two-step verification{{/if}}{{/if}}</span>
<span class="d-flex align-items-center gap-2">
{{#if user.twoFactorEnabled}}<span class="small text-success">Enabled</span>{{else}}{{#if user.twoFactorSetupPending}}<span class="small text-warning">Setup pending</span>{{else}}<span class="small text-body-secondary">Not enabled</span>{{/if}}{{/if}}
{{#if user.canUnlinkTwoFactor}}<button type="submit" form="unlink-two-factor-form" class="btn btn-sm btn-outline-danger">Unlink</button>{{/if}}
</span>
</li>
<li class="list-group-item px-0" data-admin-passkey-group>
<div class="d-flex align-items-center justify-content-between gap-3">
<span>Passkeys</span>
<span class="small text-body-secondary">{{#if user.passkeys.length}}{{user.passkeys.length}} registered{{else}}Not registered{{/if}}</span>
</div>
{{#if user.passkeys.length}}
<ul class="list-group list-group-flush ms-2 mt-1 ps-2" data-admin-passkey-list>
{{#each user.passkeys}}
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">
<span><i class="bi bi-arrow-return-right me-2 text-body-secondary" aria-hidden="true"></i>{{#if name}}{{name}}{{else}}Passkey{{/if}}</span>
<span class="d-flex align-items-center gap-2">
<span class="small text-success">Linked</span>
{{#if ../user.canUnlinkPasskeys}}<button type="submit" form="unlink-passkey-form-{{id}}" class="btn btn-sm btn-outline-danger">Unlink</button>{{/if}}
</span>
</li>
{{/each}}
</ul>
{{/if}}
</li>
</ul>
</div>
</div>
{{/if}}
</div>
<div class="col-12 col-xl-8">
<div class="col-12 col-xl-7">
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Roles</h3>
</div>
<div class="card-body">
<div class="row g-3">
{{#if roles.length}}
<div class="card-body py-0">
{{#if roles.length}}
<div class="list-group list-group-flush">
{{#each roles}}
<div class="col-12 col-md-6 col-xl-4">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label pe-4">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
<label class="list-group-item d-flex align-items-start gap-3 px-0">
<input class="form-check-input mt-1 flex-shrink-0" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<span class="flex-grow-1">
<strong class="d-block">{{name}}</strong>
<span class="text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">{{#if isEdit}}Create at least one role before assigning this role.{{else}}Create at least one role before adding users.{{/if}}</div>
</div>
{{/if}}
</div>
<div class="form-text mt-2">{{#if isEdit}}Select one or more roles. Leave them blank to remove all roles.{{else}}Select one or more roles, or leave them blank to create a user without roles.{{/if}}</div>
</div>
{{else}}
<div class="py-3"><div class="alert alert-warning mb-0">{{#if isEdit}}Create at least one role before assigning this role.{{else}}Create at least one role before adding users.{{/if}}</div></div>
{{/if}}
<div class="form-text border-top py-2">{{#if isEdit}}Select one or more roles. Leave them blank to remove all roles.{{else}}Select one or more roles, or leave them blank to create a user without roles.{{/if}}</div>
</div>
</div>
</div>
@@ -117,6 +163,14 @@
</form>
{{#if isEdit}}
{{#if user.canUnlinkTwoFactor}}
<form id="unlink-two-factor-form" method="post" action="/settings/users/{{user.id}}/two-factor/unlink" data-confirm-message="Unlink two-step verification? The user will need to sign in again."></form>
{{/if}}
{{#if user.canUnlinkPasskeys}}
{{#each user.passkeys}}
<form id="unlink-passkey-form-{{id}}" method="post" action="/settings/users/{{../user.id}}/passkeys/{{id}}/unlink" data-confirm-message="Unlink this passkey? The user will need to sign in again."></form>
{{/each}}
{{/if}}
{{#if (hasPermission currentUser 'impersonate.allow')}}
{{#unless currentUser.isViewingAs}}
<form id="view-as-user-form" method="post" action="/settings/users/{{user.id}}/view-as" data-confirm-message="View as {{user.username}}? This preview is read-only and ends after 15 minutes."></form>
-67
View File
@@ -1,67 +0,0 @@
<div class="page-header">
<div>
<h2>Invite user</h2>
<p>Send a secure invitation. The account is created when the recipient accepts it.</p>
</div>
</div>
<form method="post" action="/settings/users/invite">
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Invitation details</h3>
</div>
<div class="card-body">
{{#if message}}<div class="alert alert-{{messageVariant}}">{{message}}</div>{{/if}}
<div class="row g-3">
<div class="col-12">
<label for="invite-email" class="form-label">Email address</label>
<input id="invite-email" name="email" type="email" class="form-control" value="{{formValues.email}}" autocomplete="email" maxlength="320" required>
</div>
<div class="col-12">
<label for="invite-name" class="form-label">Display name</label>
<input id="invite-name" name="name" type="text" class="form-control" value="{{formValues.name}}" autocomplete="name" maxlength="255" required>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Invitation actions">
<button type="submit" class="btn btn-primary">Send invitation</button>
<a class="btn btn-secondary" href="/settings/users">Cancel</a>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Roles</h3>
</div>
<div class="card-body">
<div class="row g-3">
{{#if roles.length}}
{{#each roles}}
<div class="col-12 col-md-6 col-xl-4">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}}>
<span class="form-check-label pe-4">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create a role before sending invitations.</div>
</div>
{{/if}}
</div>
<div class="form-text mt-2">The recipient will receive these roles when the invitation is accepted.</div>
</div>
</div>
</div>
</div>
</form>
+1 -5
View File
@@ -13,11 +13,7 @@
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
</div>
<div class="btn-group">
{{#if (hasPermission currentUser 'invitations.create')}}
<a href="/settings/users/invite" class="btn btn-info btn-sm">Invite user</a><a class="btn btn-primary btn-sm" href="/settings/users/new">Add<span class="table-action-context"> user</span></a>
{{/if}}
</div>
<a class="btn btn-primary btn-sm" href="/settings/users/new">Add<span class="table-action-context"> user</span></a>
</div>
</div>
<div class="card-body table-responsive p-0">
+10 -35
View File
@@ -28,36 +28,19 @@
<script src="/assets/js/admin/admin-page.js"></script>
<script src="/assets/js/shared/system-status.js"></script>
{{else if authShell}}
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
{{#if message}}
<div class="toast shadow-lg border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}warning{{/if}}" data-toast-created-at="">
<div class="toast-header border-0 text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}warning{{/if}}">
{{#if (eq messageVariant 'success')}}
<i class="bi bi-check-circle-fill me-2"></i>
{{else if (eq messageVariant 'warning')}}
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{{else if (eq messageVariant 'danger')}}
<i class="bi bi-x-circle-fill me-2"></i>
{{else if (eq messageVariant 'primary')}}
<i class="bi bi-bell-fill me-2"></i>
{{else}}
<i class="bi bi-info-circle-fill me-2"></i>
{{/if}}
<strong class="me-auto">Pulse</strong>
<small class="toast-time text-nowrap">just now</small>
<button type="button" class="btn-close btn-close-white ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
</div>
<div class="toast-body">{{message}}</div>
</div>
{{/if}}
</div>
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
<div class="login-box">
{{{body}}}
</div>
</main>
<script src="/assets/js/shared/ui/toast.js"></script>
<script src="/assets/js/admin/admin-page.js"></script>
{{#each scripts}}
{{#if (isExternalUrl this)}}
<script src="{{this}}"></script>
{{else}}
<script src="/assets/{{this}}"></script>
{{/if}}
{{/each}}
<script src="/assets/js/shared/system-status.js"></script>
{{else}}
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
@@ -285,10 +268,10 @@
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'invitations.read' 'fonts.read' 'weather-icons.read')}}
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read' 'weather-icons.read')}}
<li class="nav-header">SETTINGS</li>
{{/if}}
{{#if (anyPermission currentUser 'users.read' 'rbac.read' 'invitations.read')}}
{{#if (anyPermission currentUser 'users.read' 'rbac.read')}}
<li class="nav-item has-treeview{{#if userManagementMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#">
<i class="nav-icon bi bi-people"></i>
@@ -314,14 +297,6 @@
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'invitations.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'users-invitations')}}active{{/if}}" href="/settings/invitations">
<i class="nav-icon bi bi-envelope-open"></i>
<p>Pending invitations</p>
</a>
</li>
{{/if}}
</ul>
</li>
{{/if}}
@@ -384,7 +359,7 @@
<li class="nav-item">
<a class="nav-link {{#if (eq active 'settings')}}active{{/if}}" href="/settings/system">
<i class="nav-icon bi bi-sliders"></i>
<p>System Settings</p>
<p>System settings</p>
</a>
</li>
{{/if}}