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
+5048 -1906

No files matched your search

+18
View File
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.20.1 - 2026-10-25
### Changed
- Removed restart-bound session lifetime and active-session limit controls and inactive login-throttling controls. Sessions now use a fixed 14-day lifetime with no active-session cap; sign-in request limits use Better Auth's built-in policy. Added a migration to remove the retired settings from the database.
- Clarified the scope of resetting sign-in and two-factor request limits, retained the account-level authenticator lockouts, and exposed the administrator email-verification bypass setting.
## 2.20.0 - 2026-10-25
### Added
- Migrated sign-in, sessions, credentials, and identity fields to Better Auth while preserving application user IDs and roles. Existing password hashes are migrated and upgraded to scrypt after successful sign-in; existing sessions are invalidated once. Configure a stable `AUTH_SECRET` before startup.
- Added email OTP flows for address verification, email changes, and password recovery with configurable expiry and restricted-MySQL-compatible account synchronization.
- Added optional authenticator-based two-factor authentication with recovery codes, trusted devices, and role-required enrollment.
- Added passkey registration, sign-in, and account management, plus administrator controls to unlink a user's passkeys and authenticators.
- Added per-user password-change requirements and direct administrator account creation without invitations.
- Added compact auditing across content updates that records only changed fields, matches template regions by stable keys, fingerprints generated QR assets instead of storing their payloads, and skips empty update records. Playlist auditing includes slide order, durations, grouping, and schedules. Existing audit history is preserved.
## 2.19.3 - 2026-10-25 ## 2.19.3 - 2026-10-25
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.19.3", "version": "2.20.1",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.19.3", "version": "2.20.1",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
@@ -18,6 +18,8 @@
"dependencies": { "dependencies": {
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"@better-auth/passkey": "^1.7.13",
"better-auth": "^1.7.13",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"multer": "^2.2.0", "multer": "^2.2.0",
"mysql2": "^3.23.3", "mysql2": "^3.23.3",
+2
View File
@@ -4,6 +4,8 @@ PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:lat
# Shared security # Shared security
PULSE_SIGNAGE_SHARED_SECRET="" PULSE_SIGNAGE_SHARED_SECRET=""
AUTH_SECRET="REPLACE_WITH_A_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS"
AUTH_TRUSTED_ORIGINS="http://localhost:8080,http://127.0.0.1:8080"
# Database # Database
DB_HOST="mysql" DB_HOST="mysql"
-2
View File
@@ -13,7 +13,5 @@ WEB_PUBLIC_URL="https://signage.example.com"
# Optional URL used by the kiosk launcher when the remote player is directly reachable # Optional URL used by the kiosk launcher when the remote player is directly reachable
PLAYER_PUBLIC_URL="http://remote-player.example.com:8081" PLAYER_PUBLIC_URL="http://remote-player.example.com:8081"
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
# Remote bridge connectivity # Remote bridge connectivity
BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090" BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090"
+5
View File
@@ -60,6 +60,8 @@ Key configuration:
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
- `PULSE_SIGNAGE_SHARED_SECRET` - `PULSE_SIGNAGE_SHARED_SECRET`
- `AUTH_SECRET` (generate with `openssl rand -base64 32` and keep stable across restarts)
- `AUTH_TRUSTED_ORIGINS` (comma-separated extra browser origins, such as local `localhost` URLs)
- `WEB_PUBLIC_URL` - `WEB_PUBLIC_URL`
- `BRIDGE_INTERNAL_URL` - `BRIDGE_INTERNAL_URL`
- `DEFAULT_ADMIN_USERNAME` - `DEFAULT_ADMIN_USERNAME`
@@ -129,6 +131,8 @@ Key configuration:
The example files are templates. Copy the appropriate file, review its defaults, and replace secrets or placeholder URLs before deploying. The example files are templates. Copy the appropriate file, review its defaults, and replace secrets or placeholder URLs before deploying.
The local development Compose file reads `docker-compose/.local.env`; set `AUTH_SECRET` there as well. Generate one with `openssl rand -base64 32` and keep it stable across restarts.
### Public stack: `.env.example` ### Public stack: `.env.example`
Use this file as a starting point for the public Compose stack. Use this file as a starting point for the public Compose stack.
@@ -140,6 +144,7 @@ Important values:
| `PULSE_SIGNAGE_WEB_IMAGE` | Image for the web app and bridge services. | `git.lzstealth.com/lzstealth/pulse-signage-web:latest` | | `PULSE_SIGNAGE_WEB_IMAGE` | Image for the web app and bridge services. | `git.lzstealth.com/lzstealth/pulse-signage-web:latest` |
| `PULSE_SIGNAGE_PLAYER_IMAGE` | Image for the player services. | `git.lzstealth.com/lzstealth/pulse-signage-player:latest` | | `PULSE_SIGNAGE_PLAYER_IMAGE` | Image for the player services. | `git.lzstealth.com/lzstealth/pulse-signage-player:latest` |
| `PULSE_SIGNAGE_SHARED_SECRET` | Shared request-signing secret. | Blank; set this for a secured deployment. | | `PULSE_SIGNAGE_SHARED_SECRET` | Shared request-signing secret. | Blank; set this for a secured deployment. |
| `AUTH_SECRET` | Signing/encryption secret for authentication sessions. | Generate with `openssl rand -base64 32`; keep stable across restarts. |
| `DB_HOST` | MySQL host name. | `mysql` | | `DB_HOST` | MySQL host name. | `mysql` |
| `DB_PORT` | MySQL port. | `3306` | | `DB_PORT` | MySQL port. | `3306` |
| `DB_NAME` | MySQL database name. | `pulse-signage` | | `DB_NAME` | MySQL database name. | `pulse-signage` |
+2
View File
@@ -15,6 +15,8 @@ services:
DB_USER: ${DB_USER:-pulse-signage} DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
AUTH_SECRET: ${AUTH_SECRET:?Set a random AUTH_SECRET in .env}
AUTH_TRUSTED_ORIGINS: ${AUTH_TRUSTED_ORIGINS:-}
WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080} WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080}
BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090} BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
+40 -19
View File
@@ -7,45 +7,60 @@ Tables generally use a numeric auto-increment `id` primary key. The relationship
## Admin ## Admin
- `a_users` - admin user accounts and password hashes. - `a_users` - admin user profiles and app-specific account controls.
- `a_roles` - named role definitions. - `a_roles` - named role definitions.
- `a_permissions` - permission catalog seeded from the application constants. - `a_permissions` - permission catalog seeded from the application constants.
- `a_role_permissions` - many-to-many mapping between roles and permissions. - `a_role_permissions` - many-to-many mapping between roles and permissions.
- `a_user_roles` - many-to-many mapping between users and roles. - `a_user_roles` - many-to-many mapping between users and roles.
- `a_sessions` - persisted admin session tokens. - `a_sessions` - Better Auth session tokens with application View-as metadata.
- `a_account_tokens` - short-lived account verification and password-reset tokens. - `a_accounts` - Better Auth credentials and linked identity-provider accounts.
- `a_user_invitations` - pending user invitations and assigned role ids. - `a_verifications` - Better Auth email OTP verification and reset challenges.
- `a_login_attempts` - login rate-limit and lockout state. - `a_user_two_factors` - Better Auth authenticator secrets, backup codes, and per-user verification lock state.
- `a_user_passkeys` - Better Auth passkey credentials and authenticator metadata.
- `a_login_attempts` - Better Auth database-backed rate-limit counters.
### `a_users` ### `a_users`
- `id`, `name`, `username`, `email`, `email_verified_at`, `pending_email`, `pending_email_token_hash`, `pending_email_expires_at`, `password_hash`, `password_salt`, `password_iterations`, `must_change_password`, `account_locked`, `last_login_at`, `last_login_ip`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `name`, `username`, `email`, `email_verified`, `image`, `pending_email`, `must_change_password`, `account_locked`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `username` is unique. - `username` is unique.
- `email` is Better Auth's unique, non-null email identity; synthetic addresses are used when the app email is absent.
### `a_account_tokens` ### `a_accounts`
- `id`, `user_id`, `token_type`, `token_hash`, `expires_at`, `used_at`, `created_at` - `id`, `account_id`, `provider_id`, `user_id`, `access_token`, `refresh_token`, `id_token`, `access_token_expires_at`, `refresh_token_expires_at`, `scope`, `password`, `created_at`, `updated_at`
- `token_hash` is unique. - `(provider_id, account_id)` is unique.
- Credential passwords are stored in `password` on the `credential` provider account using Better Auth's native scrypt format; legacy PBKDF2 hashes upgrade after successful sign-in. Password hashes are not stored on `a_users`.
- Foreign key: - Foreign key:
- `user_id` -> `a_users.id` with `ON DELETE CASCADE` - `user_id` -> `a_users.id` with `ON DELETE CASCADE`
- Indexed by `(token_type, token_hash, expires_at)` and `(user_id, token_type)`.
### `a_user_invitations` ### `a_verifications`
- `id`, `email`, `name`, `role_ids_json`, `token_hash`, `expires_at`, `used_at`, `created_at`, `created_by` - `id`, `identifier`, `value`, `expires_at`, `created_at`, `updated_at`
- `token_hash` is unique. - Indexed by `identifier`; Better Auth stores one-time email codes and their expiry here.
- Indexed by `(email, used_at, expires_at)` and `(created_by, created_at)`.
### `a_user_two_factors`
- `id`, `user_id`, `secret`, `backup_codes`, `verified`, `failed_verification_count`, `locked_until`
- `user_id` is unique; each user has at most one authenticator record.
- Foreign key: `user_id` -> `a_users.id` with `ON DELETE CASCADE`.
### `a_user_passkeys`
- `id`, `name`, `public_key`, `user_id`, `credential_id`, `counter`, `device_type`, `backed_up`, `transports`, `created_at`, `aaguid`
- `credential_id` is unique; `user_id` is indexed.
- Foreign key: `user_id` -> `a_users.id` with `ON DELETE CASCADE`.
### `a_login_attempts` ### `a_login_attempts`
- `id`, `rate_key`, `failed_count`, `last_failed_at`, `locked_until`, `created_at`, `modified_at` - `id`, `rate_key`, `failed_count`, `last_request`
- `rate_key` is unique. - `rate_key` is unique.
### `a_roles` ### `a_roles`
- `id`, `role_key`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `role_key`, `name`, `description`, `require_two_factor`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `role_key` is unique. - `role_key` is unique.
- `name` is unique. - `name` is unique.
- `require_two_factor` defaults to false; users must enroll when any assigned role requires two-factor authentication.
### `a_permissions` ### `a_permissions`
@@ -294,9 +309,13 @@ erDiagram
} }
A_SESSIONS { A_SESSIONS {
} }
A_ACCOUNT_TOKENS { A_ACCOUNTS {
} }
A_USER_INVITATIONS { A_VERIFICATIONS {
}
A_USER_TWO_FACTORS {
}
A_USER_PASSKEYS {
} }
A_LOGIN_ATTEMPTS { A_LOGIN_ATTEMPTS {
} }
@@ -354,7 +373,9 @@ erDiagram
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
A_USERS ||--o{ A_SESSIONS : owns A_USERS ||--o{ A_SESSIONS : owns
A_USERS ||--o{ A_ACCOUNT_TOKENS : has A_USERS ||--o{ A_ACCOUNTS : has
A_USERS ||--o| A_USER_TWO_FACTORS : secures_with
A_USERS ||--o{ A_USER_PASSKEYS : registers
A_USERS o|--o{ O_AUDIT_EVENTS : acts A_USERS o|--o{ O_AUDIT_EVENTS : acts
I_API_SOURCE_GROUPS ||--o{ I_API_SOURCES : contains I_API_SOURCE_GROUPS ||--o{ I_API_SOURCES : contains
+14 -15
View File
@@ -4,18 +4,7 @@ This guide covers the administrative tasks that support a Pulse Signage deployme
## Accounts and Access ## Accounts and Access
Your account determines which parts of the web application are available to you. Administrators can invite users, create or edit accounts, assign roles, reset passwords, and remove accounts that are no longer needed. Your account determines which parts of the web application are available to you. Administrators can create or edit accounts, assign roles, reset passwords, and remove accounts that are no longer needed.
### Inviting a User
Use the invitation workflow when the person should create their own account:
1. Enter the recipient's email address and display name.
2. Select the roles the person should receive after accepting the invitation.
3. Send the invitation.
4. Ask the recipient to follow the invitation message and complete account setup.
Assign the smallest role set that allows the person to do their work. Review the selected roles before sending because the invitation applies them when the account is accepted. If there are no roles available, create a suitable role before sending the invitation.
### Managing Existing Users ### Managing Existing Users
@@ -30,6 +19,14 @@ The current administrator account is protected from actions that would remove or
If you cannot see a feature or action described in the user guides, ask an administrator to check your account rather than assuming the feature is unavailable. If you cannot see a feature or action described in the user guides, ask an administrator to check your account rather than assuming the feature is unavailable.
### Requiring Two-Factor Authentication
Enable **Require two-factor authentication** when creating or editing a role. The requirement applies when any of a user's assigned roles has the toggle enabled. Existing roles default to off.
Users without an enrolled authenticator are restricted to My account until they complete setup. Users cannot unlink their authenticator while a role requires it. Administrator authenticator recovery remains available, but the user must enroll again before accessing the rest of the application.
Turning the role toggle off does not disable an authenticator that a user has already enrolled.
## Fonts ## Fonts
Managed fonts are uploaded once to the deployment and can then be enabled for the editor and players. They are separate from slide media and are intended for font-family choices in text and data regions. Managed fonts are uploaded once to the deployment and can then be enabled for the editor and players. They are separate from slide media and are intended for font-family choices in text and data regions.
@@ -80,13 +77,13 @@ Weather Providers stores shared credentials for the configured weather services.
### Security and Sessions ### Security and Sessions
Security and Sessions contains deployment-wide controls for authentication and session behavior. Review these settings when changing login policy, session lifetime, or security controls. Test the change with a non-administrator account where possible so an overly restrictive setting does not prevent normal users from working. Security and Sessions contains password and account policies, the option for users to revoke other sessions, and whether administrators may mark user email addresses verified. Sessions expire after 14 days with no active-session cap. Sign-in throttling uses fixed built-in limits: up to 30 requests per 15 minutes generally, with username sign-in limited to 5. Resetting these request limits does not clear per-account authenticator lockouts.
### Email Delivery and Templates ### Email Delivery and Templates
Email Delivery configures SMTP for messages such as password recovery and account verification. Check the SMTP host, port, security mode, and enabled state before relying on invitations or recovery emails. Email Delivery configures SMTP for password recovery and account verification. Check the SMTP host, port, security mode, and enabled state before relying on recovery emails.
Email Templates controls the messages sent by the application. Keep the wording clear and verify links and sender details after changing a template. Send a test invitation or recovery message when the deployment's email settings change. Email Templates controls the messages sent by the application. Keep the wording clear and verify codes and sender details after changing a template. Send a test email when the deployment's email settings change.
### Audit Logging and Diagnostics ### Audit Logging and Diagnostics
@@ -112,6 +109,8 @@ Scheduled tasks are service operations, not content settings. Change a source's
The Audit Log records authentication, security, session, and other important administrative events. Filter by category or event type, search by event, actor, or target, and review the time, source address, user agent, and recorded changes. The Audit Log records authentication, security, session, and other important administrative events. Filter by category or event type, search by event, actor, or target, and review the time, source address, user agent, and recorded changes.
Audited content updates record only changed fields and their old and new values; empty update records are skipped. Template regions are compared by their stable region keys. Playlist changes include slide order, durations, groups, and schedules. Generated QR SVGs and preview images, including template defaults, are represented by byte counts and SHA-256 fingerprints rather than full payloads, so those assets cannot be reconstructed from the audit log. Existing audit entries and category settings are unchanged.
Use the audit log to answer questions such as: Use the audit log to answer questions such as:
- Who changed a user, setting, source, or player-related value? - Who changed a user, setting, source, or player-related value?
+1145 -7
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.19.3", "version": "2.20.1",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
@@ -21,10 +21,13 @@
"start:player": "node -r dotenv/config src/player.js", "start:player": "node -r dotenv/config src/player.js",
"dev:web": "nodemon -r dotenv/config src/web.js", "dev:web": "nodemon -r dotenv/config src/web.js",
"dev:player": "nodemon -r dotenv/config src/player.js", "dev:player": "nodemon -r dotenv/config src/player.js",
"build:auth-client": "esbuild src/web/public/js/auth/passkey-client-entry.js --bundle --minify --format=iife --outfile=src/web/public/js/auth/passkey-client.js",
"test": "node --test test/*.test.js" "test": "node --test test/*.test.js"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.7.13",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"better-auth": "^1.7.13",
"bootstrap-icons": "1.13.1", "bootstrap-icons": "1.13.1",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
@@ -41,6 +44,7 @@
"ws": "^8.21.3" "ws": "^8.21.3"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.28.2",
"nodemon": "^3.1.10" "nodemon": "^3.1.10"
} }
} }
+56
View File
@@ -5,6 +5,7 @@ const crypto = require('crypto');
const PASSWORD_ITERATIONS = 310000; const PASSWORD_ITERATIONS = 310000;
const PASSWORD_KEY_LENGTH = 32; const PASSWORD_KEY_LENGTH = 32;
const PASSWORD_DIGEST = 'sha256'; const PASSWORD_DIGEST = 'sha256';
const SCRYPT_OPTIONS = { N: 16384, r: 16, p: 1, keyLength: 64, maxmem: 128 * 16384 * 16 * 2 };
const SESSION_BYTES = 32; const SESSION_BYTES = 32;
function createOneTimeToken() { 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) { function verifyPassword(password, record) {
if (!record || !record.password_salt || !record.password_hash) { if (!record || !record.password_salt || !record.password_hash) {
return false; return false;
@@ -93,6 +144,11 @@ function hashSessionToken(token) {
module.exports = { module.exports = {
hashPassword, hashPassword,
verifyPassword, verifyPassword,
encodePasswordRecord,
verifyEncodedPassword,
hashScryptPassword,
verifyScryptPassword,
verifyCredentialPassword,
validatePasswordStrength, validatePasswordStrength,
createSessionToken, createSessionToken,
hashSessionToken, hashSessionToken,
+15 -3
View File
@@ -1,8 +1,8 @@
const DEFAULT_ACCOUNT_EMAIL_TEMPLATES = { const DEFAULT_ACCOUNT_EMAIL_TEMPLATES = {
verificationSubject: 'Verify your Pulse Signage email address', 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', 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) { function formatAccountEmailExpiry(value, unit) {
@@ -12,6 +12,18 @@ function formatAccountEmailExpiry(value, unit) {
return normalizedAmount + ' ' + normalizedUnit + (normalizedAmount === 1 ? '' : 's'); 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) { function renderAccountEmailTemplate(subject, body, variables) {
const values = variables || {}; const values = variables || {};
const replaceVariables = function (value) { const replaceVariables = function (value) {
@@ -51,4 +63,4 @@ function renderAccountEmailTemplate(subject, body, variables) {
return { subject: renderedSubject, text: plainText, html: html }; 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.timezone', type: 'string', defaultValue: 'Europe/London' },
{ key: 'locale.language', type: 'string', defaultValue: 'en' }, { key: 'locale.language', type: 'string', defaultValue: 'en' },
{ key: 'ui.theme', type: 'enum', values: ['dark', 'light', 'auto'], defaultValue: 'dark' }, { 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.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_length', type: 'integer', min: 8, defaultValue: 10 },
{ key: 'security.password_min_categories', type: 'integer', min: 1, defaultValue: 3 }, { key: 'security.password_min_categories', type: 'integer', min: 1, defaultValue: 3 },
{ key: 'security.password_require_lowercase', type: 'boolean', defaultValue: false }, { key: 'security.password_require_lowercase', type: 'boolean', defaultValue: false },
{ key: 'security.password_require_uppercase', 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_number', type: 'boolean', defaultValue: false },
{ key: 'security.password_require_symbol', 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: 'security.allow_admin_email_verification_bypass', type: 'boolean', defaultValue: true },
{ key: 'email.smtp_enabled', type: 'boolean', defaultValue: false }, { key: 'email.smtp_enabled', type: 'boolean', defaultValue: false },
{ key: 'email.smtp_host', type: 'string', defaultValue: '' }, { key: 'email.smtp_host', type: 'string', defaultValue: '' },
@@ -30,21 +23,11 @@ const SETTING_DEFINITIONS = [
{ key: 'email.smtp_password', type: 'string', defaultValue: '' }, { key: 'email.smtp_password', type: 'string', defaultValue: '' },
{ key: 'email.from_address', type: 'string', defaultValue: '' }, { key: 'email.from_address', type: 'string', defaultValue: '' },
{ key: 'email.from_name', type: 'string', defaultValue: 'Pulse Signage' }, { key: 'email.from_name', type: 'string', defaultValue: 'Pulse Signage' },
{ key: 'email.verification_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 }, { key: 'email.otp_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.verification_subject', type: 'string', defaultValue: 'Verify your Pulse Signage email address' }, { 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_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.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: '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: 'audit.enabled', type: 'boolean', defaultValue: true }, { key: 'audit.enabled', type: 'boolean', defaultValue: true },
{ key: 'audit.categories', type: 'string_array', defaultValue: ['authentication', 'security', 'sessions', 'users', 'roles', 'system-settings'] }, { key: 'audit.categories', type: 'string_array', defaultValue: ['authentication', 'security', 'sessions', 'users', 'roles', 'system-settings'] },
{ key: 'audit.screen_control_commands', type: 'string_array', defaultValue: [] }, { key: 'audit.screen_control_commands', type: 'string_array', defaultValue: [] },
+60 -9
View File
@@ -27,7 +27,7 @@ const AUDIT_CATEGORY_LABELS = Object.freeze({
sessions: 'Sessions', sessions: 'Sessions',
users: 'Users', users: 'Users',
roles: 'Roles', roles: 'Roles',
'system-settings': 'System Settings', 'system-settings': 'System settings',
slides: 'Slides', slides: 'Slides',
templates: 'Templates', templates: 'Templates',
playlists: 'Playlists', playlists: 'Playlists',
@@ -51,6 +51,7 @@ const SCREEN_CONTROL_COMMAND_LABELS = Object.freeze({
setclientname: 'Rename client' setclientname: 'Rename client'
}); });
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { createHash } = require('node:crypto');
function formatUserAgentLabel(userAgent) { function formatUserAgentLabel(userAgent) {
const value = String(userAgent || '').trim(); const value = String(userAgent || '').trim();
@@ -81,20 +82,64 @@ function normalizeDetails(details) {
} }
function buildAuditChanges(previousValues, nextValues) { function buildAuditChanges(previousValues, nextValues) {
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
const changes = {}; const changes = {};
const keys = new Set(Object.keys(previous).concat(Object.keys(next))); function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
keys.forEach(function (key) {
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
changes[key] = { from: previous[key], to: next[key] };
} }
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; return changes;
} }
const buildSlideAuditChanges = buildAuditChanges;
function getRequestMetadata(req) { function getRequestMetadata(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim(); const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return { return {
@@ -110,6 +155,11 @@ async function recordAuditEvent(pool, event) {
if (!category || !eventType) { if (!category || !eventType) {
throw new Error('Audit events require a category and event type.'); 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( await pool.query(
`INSERT INTO o_audit_events `INSERT INTO o_audit_events
@@ -162,6 +212,7 @@ module.exports = {
formatUserAgentLabel, formatUserAgentLabel,
getRequestMetadata, getRequestMetadata,
buildAuditChanges, buildAuditChanges,
buildSlideAuditChanges,
recordAuditEvent, recordAuditEvent,
recordRequestAuditEvent 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 { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
const { createSyntheticAuthEmail, hashCredentialPassword, setCredentialPassword } = require('#src/data/auth-accounts');
async function bootstrapDatabase(pool) { async function bootstrapDatabase(pool) {
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes'); 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) { if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin'; const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin'; const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password); const encodedPassword = await hashCredentialPassword(password);
await pool.query( const [result] = await pool.query(
'INSERT IGNORE INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', 'INSERT IGNORE INTO a_users (name, username, email, email_verified, created_by, modified_by) VALUES (?, ?, ?, 0, NULL, NULL)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, 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']); 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS a_users ( CREATE TABLE IF NOT EXISTS a_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL, name VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL UNIQUE, username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(320) NULL, email VARCHAR(320) NOT NULL UNIQUE,
email_verified_at DATETIME NULL, email_verified TINYINT(1) NOT NULL DEFAULT 0,
image TEXT NULL,
pending_email VARCHAR(320) 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, must_change_password TINYINT(1) NOT NULL DEFAULT 0,
account_locked TINYINT(1) NOT NULL DEFAULT 0, account_locked TINYINT(1) NOT NULL DEFAULT 0,
last_login_at DATETIME NULL, two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
last_login_ip VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -442,33 +437,35 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS a_account_tokens ( CREATE TABLE IF NOT EXISTS a_user_two_factors (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id VARCHAR(191) NOT NULL PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
token_type VARCHAR(32) NOT NULL, secret TEXT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE, backup_codes TEXT NOT NULL,
expires_at DATETIME NOT NULL, verified TINYINT(1) NOT NULL DEFAULT 0,
used_at DATETIME NULL, failed_verification_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, locked_until DATETIME NULL,
CONSTRAINT fk_account_tokens_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE, UNIQUE KEY uq_a_user_two_factors_user_id (user_id),
INDEX idx_account_tokens_lookup (token_type, token_hash, expires_at), CONSTRAINT fk_a_user_two_factors_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
INDEX idx_account_tokens_user_type (user_id, token_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS a_user_invitations ( CREATE TABLE IF NOT EXISTS a_user_passkeys (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id VARCHAR(191) NOT NULL PRIMARY KEY,
email VARCHAR(320) NOT NULL,
name VARCHAR(255) NULL, name VARCHAR(255) NULL,
role_ids_json TEXT NOT NULL, public_key TEXT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE, user_id INT NOT NULL,
expires_at DATETIME NOT NULL, credential_id VARCHAR(512) NOT NULL,
used_at DATETIME NULL, counter BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, device_type VARCHAR(32) NOT NULL,
created_by INT NULL, backed_up TINYINT(1) NOT NULL DEFAULT 0,
INDEX idx_user_invitations_email (email, used_at, expires_at), transports TEXT NULL,
INDEX idx_user_invitations_created_by (created_by, created_at) 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 ) 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, role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT NULL, description TEXT NULL,
require_two_factor TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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, user_agent VARCHAR(512) NULL,
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 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, last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE, 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 ) 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS a_login_attempts ( CREATE TABLE IF NOT EXISTS a_login_attempts (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
rate_key VARCHAR(600) NOT NULL UNIQUE, rate_key VARCHAR(600) NOT NULL UNIQUE,
failed_count INT NOT NULL DEFAULT 0, failed_count INT NOT NULL DEFAULT 0,
last_failed_at DATETIME NULL, last_request BIGINT NOT NULL DEFAULT 0
locked_until DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) 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. // Ordered database migrations kept independent from the application version.
const { version: appVersion } = require('#root/package.json'); const { version: appVersion } = require('#root/package.json');
const { encodePasswordRecord } = require('#src/auth');
const { parseJsonSafe } = require('#src/data/utils'); const { parseJsonSafe } = require('#src/data/utils');
const { collectContentReferences, insertContentReferences } = require('#src/data/content-references'); const { collectContentReferences, insertContentReferences } = require('#src/data/content-references');
const TIMETABLE_TIME_ZONE = 'Europe/London'; const TIMETABLE_TIME_ZONE = 'Europe/London';
@@ -874,6 +874,158 @@ const VERSIONED_MIGRATIONS = [
throw error; 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 path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws'); 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 { verifyRequestAuth } = require('#src/request-auth');
const DEFAULT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; 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 (Array.isArray(users) ? users : []).map(function (user) {
return { return {
username_hash: String(user && (user.username_hash || fingerprintUsername(user.username)) || '').trim(), username_hash: String(user && (user.username_hash || fingerprintUsername(user.username)) || '').trim(),
password_hash: String(user && user.password_hash || '').trim(), password_hash: String(user && user.password_hash || '').trim()
password_salt: String(user && user.password_salt || '').trim()
}; };
}).filter(function (user) { }).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) { const user = cache.users.find(function (candidate) {
return candidate.username_hash === fingerprintUsername(username); 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); recordLoginFailure(req, username);
return res.status(401).json({ error: 'Invalid local control credentials.' }); 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: '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', key: 'impersonate',
order: 25, order: 25,
@@ -220,6 +209,14 @@ const PERMISSION_SECTIONS = [
{ key: 'allow', name: 'Allow', description: 'Preview a user account in read-only mode.' } { 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', key: 'rbac',
order: 20, order: 20,
+43 -22
View File
@@ -6,7 +6,7 @@ const multer = require('multer');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const common = require('#src/common'); 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 pages = require('#src/web/pages/index');
const registerMiddleware = require('#src/web/middleware'); const registerMiddleware = require('#src/web/middleware');
const registerNotFoundHandler = require('#src/web/middleware/not-found'); 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 { createPlayerActionService } = require('#src/web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index'); 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 { fetchAppSettings } = require('#src/data/app-settings');
const { sendAccountEmail } = require('#src/data/mailer'); const { sendAccountEmail } = require('#src/data/mailer');
const { recordRequestAuditEvent } = require('#src/data/audit-log'); const { recordRequestAuditEvent } = require('#src/data/audit-log');
@@ -51,6 +52,20 @@ async function start() {
const server = http.createServer(app); const server = http.createServer(app);
const pool = common.createPool(); const pool = common.createPool();
const webConfig = createWebConfig(); 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. // Background services and request forwarding helpers.
const backgroundTaskQueue = createBackgroundTaskQueue({ const backgroundTaskQueue = createBackgroundTaskQueue({
@@ -99,30 +114,38 @@ async function start() {
// Session and auth helpers for route registration. // Session and auth helpers for route registration.
const broadcastDashboardState = webBootstrap.broadcastDashboardState; const broadcastDashboardState = webBootstrap.broadcastDashboardState;
const sessionService = createSessionService({ const sessionService = createSessionService({
sessionCookieName: webConfig.sessionCookieName, 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
}); });
const parseCookies = sessionService.parseCookies; const parseCookies = sessionService.parseCookies;
const createUserSession = sessionService.createUserSession;
const clearSessionCookie = sessionService.clearSessionCookie; const clearSessionCookie = sessionService.clearSessionCookie;
const setSessionCookie = sessionService.setSessionCookie;
const getSessionMaxAgeMs = sessionService.getSessionMaxAgeMs;
const setAuthMessageCookie = sessionService.setAuthMessageCookie; const setAuthMessageCookie = sessionService.setAuthMessageCookie;
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie; 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 requireAuth = sessionService.requireAuth;
const startViewAsUser = sessionService.startViewAsUser; const startViewAsUser = sessionService.startViewAsUser;
const stopViewAsUser = sessionService.stopViewAsUser; 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. // Middleware and route registration.
registerMiddleware(app, { registerMiddleware(app, {
pool: pool, pool: pool,
@@ -137,16 +160,15 @@ async function start() {
registerRoutes(app, { registerRoutes(app, {
pool: pool, pool: pool,
betterAuth: betterAuth,
fromNodeHeaders: fromNodeHeaders,
common: common, common: common,
pages: pages, pages: pages,
upload: upload, upload: upload,
mediaDir: webConfig.mediaDir, mediaDir: webConfig.mediaDir,
uploadDir: webConfig.uploadsDir, uploadDir: webConfig.uploadsDir,
createUserSession: createUserSession,
startViewAsUser: startViewAsUser, startViewAsUser: startViewAsUser,
stopViewAsUser: stopViewAsUser, stopViewAsUser: stopViewAsUser,
setSessionCookie: setSessionCookie,
getSessionMaxAgeMs: getSessionMaxAgeMs,
clearSessionCookie: clearSessionCookie, clearSessionCookie: clearSessionCookie,
setAuthMessageCookie: setAuthMessageCookie, setAuthMessageCookie: setAuthMessageCookie,
consumeAuthMessageCookie: consumeAuthMessageCookie, consumeAuthMessageCookie: consumeAuthMessageCookie,
@@ -235,11 +257,10 @@ async function start() {
}); });
// Bootstrap and startup tasks. // Bootstrap and startup tasks.
fs.mkdirSync(webConfig.mediaDir, { recursive: true });
await ensureFontLibrary(webConfig.mediaDir);
await initializeWebServer({ await initializeWebServer({
common: common, common: common,
pool: pool, pool: pool,
databaseInitialized: true,
mediaDir: webConfig.mediaDir, mediaDir: webConfig.mediaDir,
backgroundTaskQueue: backgroundTaskQueue, backgroundTaskQueue: backgroundTaskQueue,
webBootstrap: webBootstrap, 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 { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
const { fetchPagedRows } = require('#src/data/utils'); const { fetchPagedRows } = require('#src/data/utils');
const { toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
function parseCsvIds(value) { function parseCsvIds(value) {
return String(value || '') return String(value || '')
@@ -57,35 +58,9 @@ async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDir
return Object.assign({ roles: paged.rows }, paged); 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) { async function fetchRoleById(pool, roleId) {
const [rows] = await pool.query( 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_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 (SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM a_roles r FROM a_roles r
@@ -168,7 +143,7 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : ''; const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
const queryArgs = hasExcludedUserId ? [excludedUserId] : []; const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
const paged = await fetchPagedRows(pool, { 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_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM a_users u FROM a_users u
@@ -202,6 +177,8 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
return { return {
users: (paged.rows || []).map(function (row) { users: (paged.rows || []).map(function (row) {
return Object.assign({}, row, { return Object.assign({}, row, {
email: toVisibleEmail(row.email),
email_verified: Boolean(row.email_verified),
roleIds: parseCsvIds(row.role_ids_csv), roleIds: parseCsvIds(row.role_ids_csv),
roleNames: String(row.role_names || '').trim() roleNames: String(row.role_names || '').trim()
}); });
@@ -215,10 +192,12 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
async function fetchUserWithRoles(pool, userId) { async function fetchUserWithRoles(pool, userId) {
const [rows] = await pool.query( 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_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM a_users u FROM a_users u
LEFT JOIN a_user_two_factors tf ON tf.user_id = u.id
LEFT JOIN ( LEFT JOIN (
SELECT ur.user_id, SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names, GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
@@ -236,7 +215,22 @@ async function fetchUserWithRoles(pool, userId) {
return null; 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], { 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), roleIds: parseCsvIds(rows[0].role_ids_csv),
roleNames: String(rows[0].role_names || '').trim() roleNames: String(rows[0].role_names || '').trim()
}); });
@@ -325,7 +319,6 @@ module.exports = {
fetchPermissions, fetchPermissions,
fetchRoles, fetchRoles,
fetchRolesPage, fetchRolesPage,
fetchInvitationsPage,
fetchRoleById, fetchRoleById,
fetchRolePermissionKeys, fetchRolePermissionKeys,
fetchRoleUserIds, fetchRoleUserIds,
+32 -74
View File
@@ -1,6 +1,7 @@
// Session cookie and current-user helpers for the web auth flow. // Session cookie and current-user helpers for the web auth flow.
const { hasPermission, normalizePermissionKeys } = require('#src/rbac'); const { hasPermission, normalizePermissionKeys } = require('#src/rbac');
const { toVisibleEmail } = require('#src/data/auth-accounts');
function getRequestOrigin(req) { function getRequestOrigin(req) {
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0]; 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) { function createSessionService(options) {
const sessionCookieName = String(options && options.sessionCookieName || '').trim(); 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'; const authMessageCookieName = 'pulse_auth_message';
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') { if (!sessionCookieName) {
throw new Error('createSessionService requires the session dependencies.'); throw new Error('createSessionService requires a session cookie name.');
} }
function parseCookies(cookieHeader) { function parseCookies(cookieHeader) {
@@ -89,23 +85,6 @@ function createSessionService(options) {
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 })); 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) { function setAuthMessageCookie(res, message) {
appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 })); appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 }));
} }
@@ -119,27 +98,35 @@ function createSessionService(options) {
return message; return message;
} }
async function loadCurrentUser(pool, req) { function getSessionToken(req, resolvedSessionToken) {
const cookies = parseCookies(req.headers.cookie || ''); if (resolvedSessionToken) return String(resolvedSessionToken);
const token = cookies[sessionCookieName]; 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) { if (!token) {
return null; return null;
} }
const tokenHash = hashSessionToken(token); const sessionToken = token;
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT s.user_id, s.view_as_user_id, s.view_as_expires_at, `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.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.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.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 FROM a_sessions s
JOIN a_users u ON u.id = s.user_id 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() LEFT JOIN a_users preview ON preview.id = s.view_as_user_id AND s.view_as_expires_at > NOW()
WHERE s.session_hash = ? WHERE s.session_hash = ?
AND s.expires_at > NOW() AND s.expires_at > NOW()
LIMIT 1`, LIMIT 1`,
[tokenHash] [sessionToken]
); );
if (!rows.length) { if (!rows.length) {
return null; return null;
@@ -148,7 +135,7 @@ function createSessionService(options) {
const userId = Number(rows[0].id); const userId = Number(rows[0].id);
async function loadAccess(userId) { async function loadAccess(userId) {
const [roleRows] = await pool.query( const [roleRows] = await pool.query(
`SELECT r.role_key `SELECT r.role_key, r.require_two_factor
FROM a_user_roles ur FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = ? WHERE ur.user_id = ?
@@ -165,6 +152,7 @@ function createSessionService(options) {
[userId] [userId]
); );
return { return {
requiresTwoFactor: roleRows.some(function (row) { return Boolean(row.require_two_factor); }),
roleKeys: roleRows.map(function (row) { roleKeys: roleRows.map(function (row) {
return String(row.role_key || '').trim(); return String(row.role_key || '').trim();
}).filter(Boolean), }).filter(Boolean),
@@ -176,7 +164,11 @@ function createSessionService(options) {
const actorAccess = await loadAccess(userId); const actorAccess = await loadAccess(userId);
const authenticatedUser = Object.assign({}, rows[0], { 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), mustChangePassword: Boolean(rows[0].must_change_password),
twoFactorEnabled: Boolean(rows[0].two_factor_enabled),
requiresTwoFactor: actorAccess.requiresTwoFactor,
roleKeys: actorAccess.roleKeys, roleKeys: actorAccess.roleKeys,
permissionKeys: actorAccess.permissionKeys permissionKeys: actorAccess.permissionKeys
}); });
@@ -188,11 +180,11 @@ function createSessionService(options) {
if (Number(rows[0].view_as_user_id) > 0 && !canContinuePreview) { if (Number(rows[0].view_as_user_id) > 0 && !canContinuePreview) {
await pool.query( await pool.query(
'UPDATE a_sessions SET view_as_user_id = NULL, view_as_expires_at = NULL WHERE session_hash = ? AND user_id = ?', '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) { if (!canContinuePreview) {
return authenticatedUser; return authenticatedUser;
@@ -203,10 +195,12 @@ function createSessionService(options) {
id: previewUserId, id: previewUserId,
name: rows[0].preview_name, name: rows[0].preview_name,
username: rows[0].preview_username, username: rows[0].preview_username,
email: rows[0].preview_email, email: toVisibleEmail(rows[0].preview_email),
email_verified_at: rows[0].preview_email_verified_at, email_verified: Boolean(rows[0].preview_email_verified),
pending_email: rows[0].preview_pending_email, pending_email: rows[0].preview_pending_email,
mustChangePassword: Boolean(rows[0].preview_must_change_password), mustChangePassword: Boolean(rows[0].preview_must_change_password),
twoFactorEnabled: Boolean(rows[0].preview_two_factor_enabled),
requiresTwoFactor: previewAccess.requiresTwoFactor,
roleKeys: previewAccess.roleKeys, roleKeys: previewAccess.roleKeys,
permissionKeys: previewAccess.permissionKeys, permissionKeys: previewAccess.permissionKeys,
authenticatedUser: authenticatedUser, authenticatedUser: authenticatedUser,
@@ -216,9 +210,7 @@ function createSessionService(options) {
} }
function getSessionHash(req) { function getSessionHash(req) {
const cookies = parseCookies(req && req.headers && req.headers.cookie || ''); return getSessionToken(req);
const token = cookies[sessionCookieName];
return token ? hashSessionToken(token) : '';
} }
async function startViewAsUser(pool, req, actorUserId, targetUserId) { async function startViewAsUser(pool, req, actorUserId, targetUserId) {
@@ -249,37 +241,6 @@ function createSessionService(options) {
return Number(result && result.affectedRows) === 1; 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) { function requireAuth(req, res, next) {
if (req.currentUser) { if (req.currentUser) {
return next(); return next();
@@ -293,14 +254,11 @@ function createSessionService(options) {
parseCookies: parseCookies, parseCookies: parseCookies,
serializeCookie: serializeCookie, serializeCookie: serializeCookie,
clearSessionCookie: clearSessionCookie, clearSessionCookie: clearSessionCookie,
setSessionCookie: setSessionCookie,
setAuthMessageCookie: setAuthMessageCookie, setAuthMessageCookie: setAuthMessageCookie,
consumeAuthMessageCookie: consumeAuthMessageCookie, consumeAuthMessageCookie: consumeAuthMessageCookie,
loadCurrentUser: loadCurrentUser, loadCurrentUser: loadCurrentUser,
startViewAsUser: startViewAsUser, startViewAsUser: startViewAsUser,
stopViewAsUser: stopViewAsUser, stopViewAsUser: stopViewAsUser,
createUserSession: createUserSession,
getSessionMaxAgeMs: getSessionMaxAgeMs,
requireAuth: requireAuth, requireAuth: requireAuth,
getRequestOrigin: getRequestOrigin getRequestOrigin: getRequestOrigin
}; };
+7 -4
View File
@@ -2,24 +2,27 @@ const crypto = require('crypto');
async function collectLocalControlUsers(pool) { async function collectLocalControlUsers(pool) {
const [rows] = await pool.query( 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 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_user_roles ur ON ur.user_id = u.id
JOIN a_role_permissions rp ON rp.role_id = ur.role_id JOIN a_role_permissions rp ON rp.role_id = ur.role_id
JOIN a_permissions p ON p.id = rp.permission_id JOIN a_permissions p ON p.id = rp.permission_id
WHERE u.account_locked = 0 WHERE u.account_locked = 0
AND u.must_change_password = 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' AND p.permission_key = 'clients.allow'
ORDER BY u.username ASC` ORDER BY u.username ASC`
); );
return (rows || []).map(function (user) { return (rows || []).map(function (user) {
return { return {
username_hash: crypto.createHash('sha256').update(String(user && user.username || '').trim()).digest('hex'), username_hash: crypto.createHash('sha256').update(String(user && user.username || '').trim()).digest('hex'),
password_hash: String(user && user.password_hash || '').trim(), password_hash: String(user && user.password_hash || '').trim()
password_salt: String(user && user.password_salt || '').trim()
}; };
}).filter(function (user) { }).filter(function (user) {
return Boolean(user.username_hash && user.password_hash && user.password_salt); return Boolean(user.username_hash && user.password_hash);
}); });
} }
+2
View File
@@ -18,8 +18,10 @@ async function initializeWebServer(options) {
throw new Error('initializeWebServer requires the web startup dependencies.'); throw new Error('initializeWebServer requires the web startup dependencies.');
} }
if (!(options && options.databaseInitialized)) {
await common.ensureSchema(pool, { mediaDir: mediaDir }); await common.ensureSchema(pool, { mediaDir: mediaDir });
await common.bootstrapDatabase(pool); await common.bootstrapDatabase(pool);
}
await backgroundTaskQueue.initialize(); await backgroundTaskQueue.initialize();
await initializeBackgroundTasks({ await initializeBackgroundTasks({
+9 -1
View File
@@ -43,7 +43,7 @@ module.exports = function registerMiddleware(app, deps) {
return next(error); 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(); return next();
} }
@@ -55,6 +55,14 @@ module.exports = function registerMiddleware(app, deps) {
return res.redirect('/account/password'); 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(); next();
}); });
}); });
-5
View File
@@ -10,19 +10,14 @@ module.exports = {
renderLoginPage: require(routePath('auth', 'login')), renderLoginPage: require(routePath('auth', 'login')),
renderForgotPasswordPage: require(routePath('auth', 'forgot-password')), renderForgotPasswordPage: require(routePath('auth', 'forgot-password')),
renderResetPasswordPage: require(routePath('auth', 'reset-password')), renderResetPasswordPage: require(routePath('auth', 'reset-password')),
renderAcceptInvitePage: require(routePath('auth', 'accept-invite')),
renderNoAccessPage: require(routePath('auth', 'no-access')), renderNoAccessPage: require(routePath('auth', 'no-access')),
renderEmailVerifiedPage: require(routePath('auth', 'email-verified')),
renderEmailVerificationErrorPage: require(routePath('auth', 'email-verification-error')),
renderAccountPage: require(routePath('account', 'password')), renderAccountPage: require(routePath('account', 'password')),
renderRequiredPasswordPage: require(routePath('account', 'password-required')), renderRequiredPasswordPage: require(routePath('account', 'password-required')),
renderSettingsPage: require(routePath('settings', 'index')), renderSettingsPage: require(routePath('settings', 'index')),
renderAboutPage: require(routePath('settings', 'about', 'index')), renderAboutPage: require(routePath('settings', 'about', 'index')),
renderChangelogPage: require(routePath('settings', 'changelog', 'index')), renderChangelogPage: require(routePath('settings', 'changelog', 'index')),
renderUsersPage: require(routePath('settings', 'users', 'list')), renderUsersPage: require(routePath('settings', 'users', 'list')),
renderInvitationsPage: require(routePath('settings', 'invitations', 'list')),
renderUsersAddPage: require(routePath('settings', 'users', 'add')), renderUsersAddPage: require(routePath('settings', 'users', 'add')),
renderUsersInvitePage: require(routePath('settings', 'users', 'invite')),
renderUsersEditPage: require(routePath('settings', 'users', 'edit')), renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
renderDashboardPage: require(routePath('signage', 'dashboard', 'index')), renderDashboardPage: require(routePath('signage', 'dashboard', 'index')),
renderConnectedClientsPage: require(routePath('signage', 'clients', 'list')), renderConnectedClientsPage: require(routePath('signage', 'clients', 'list')),
+43
View File
@@ -447,6 +447,49 @@
overflow: hidden; 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 { .email-verified-card {
max-width: 26rem; 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') : '', username: previewUser ? previewUser.getAttribute('data-username') : '',
display_name: previewUser ? previewUser.getAttribute('data-display-name') : '', display_name: previewUser ? previewUser.getAttribute('data-display-name') : '',
email: previewUser ? previewUser.getAttribute('data-email') : '', email: previewUser ? previewUser.getAttribute('data-email') : '',
verification_expiry: previewUser ? previewUser.getAttribute('data-verification-expiry') : '30', otp_expiry: previewUser ? previewUser.getAttribute('data-otp-expiry') : '30'
reset_expiry: previewUser ? previewUser.getAttribute('data-reset-expiry') : '30',
invitation_expiry: previewUser ? previewUser.getAttribute('data-invitation-expiry') : '24'
}; };
document.querySelectorAll('[data-email-template-editor]').forEach(function (editor) { document.querySelectorAll('[data-email-template-editor]').forEach(function (editor) {
var subject = editor.querySelector('[data-email-template-subject]'); var subject = editor.querySelector('[data-email-template-subject]');
var body = editor.querySelector('[data-email-template-body]'); 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]'); var preview = editor.querySelector('[data-email-template-preview]');
if (!subject || !body || !preview) return; 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() { function render() {
preview.innerHTML = ''; preview.innerHTML = '';
@@ -418,45 +411,16 @@
username: signedInUser.username || 'username', username: signedInUser.username || 'username',
display_name: signedInUser.display_name || signedInUser.username || 'Your name', display_name: signedInUser.display_name || signedInUser.username || 'Your name',
email: signedInUser.email || 'your-email@example.com', 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) { 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 line = sourceParagraph.replace(/\[\[(username|display_name|email|expiry_time|otp)\]\]/g, function (_match, key) { return sampleValues[key]; });
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;
}
appendPreviewParagraph(line); appendPreviewParagraph(line);
}); });
function appendPreviewParagraph(line) { function appendPreviewParagraph(line) {
var paragraph = document.createElement('p'); 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); message.appendChild(paragraph);
} }
preview.appendChild(brand); preview.appendChild(brand);
@@ -466,8 +430,6 @@
subject.addEventListener('input', render); subject.addEventListener('input', render);
body.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) { editor.querySelectorAll('[data-email-format]').forEach(function (formatButton) {
formatButton.addEventListener('click', function () { formatButton.addEventListener('click', function () {
var tag = formatButton.getAttribute('data-email-format'); var tag = formatButton.getAttribute('data-email-format');
+11 -3
View File
@@ -1,6 +1,7 @@
// Account password page renderer and return URL normalization. // Account password page renderer and return URL normalization.
const { renderView } = require('#src/web/view'); const { renderView } = require('#src/web/view');
const { resolvePasskeyRpID } = require('#src/web/lib/auth/better-auth');
function buildPasswordRequirementsText(requirements) { function buildPasswordRequirementsText(requirements) {
const requiredCategories = []; const requiredCategories = [];
@@ -26,7 +27,7 @@ function normalizeReturnUrl(value) {
return url; 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 || { const requirements = passwordRequirements || {
minimumLength: 10, minimumLength: 10,
minimumCategories: 3, minimumCategories: 3,
@@ -45,13 +46,20 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl, all
email: currentUser ? String(currentUser.email || '') : '', email: currentUser ? String(currentUser.email || '') : '',
emailPending: Boolean(currentUser && currentUser.pending_email), emailPending: Boolean(currentUser && currentUser.pending_email),
pendingEmail: currentUser ? String(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), returnUrl: normalizeReturnUrl(returnUrl),
allowUserSessionRevocation: Boolean(allowUserSessionRevocation), allowUserSessionRevocation: Boolean(allowUserSessionRevocation),
sessions: Array.isArray(sessions) ? sessions : [], sessions: Array.isArray(sessions) ? sessions : [],
passwordMinimumLength: requirements.minimumLength, passwordMinimumLength: requirements.minimumLength,
passwordRequirementsText: buildPasswordRequirementsText(requirements), 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']
}); });
}; };
+139 -79
View File
@@ -1,33 +1,50 @@
// Admin account route registration and profile helpers. // Admin account route registration and profile helpers.
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { formatUserAgentLabel } = require('#src/data/audit-log'); const { formatUserAgentLabel } = require('#src/data/audit-log');
const { createSyntheticAuthEmail, toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
module.exports = function registerAccountRoutes(app, deps) { module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
const common = deps.common; const common = deps.common;
const pages = deps.pages; const pages = deps.pages;
const betterAuth = deps.betterAuth;
const fromNodeHeaders = deps.fromNodeHeaders;
const formatDashboardDate = deps.formatDashboardDate; const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId; const getAuditUserId = deps.getAuditUserId;
const verifyPassword = deps.verifyPassword;
const hashPassword = deps.hashPassword;
const validatePasswordStrength = deps.validatePasswordStrength; const validatePasswordStrength = deps.validatePasswordStrength;
const createUserSession = deps.createUserSession; const clearSessionCookie = deps.clearSessionCookie;
const setSessionCookie = deps.setSessionCookie;
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
const parseCookies = deps.parseCookies; const parseCookies = deps.parseCookies;
const hashSessionToken = deps.hashSessionToken;
const sessionCookieName = deps.sessionCookieName; const sessionCookieName = deps.sessionCookieName;
const recordRequestAuditEvent = deps.recordRequestAuditEvent; 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() { async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool); const settings = await fetchAppSettings(pool);
return buildPasswordRequirements(settings); 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) { function isPasswordChangeRequired(req) {
return Boolean(req && (req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword)); return Boolean(req && (req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword));
} }
@@ -51,55 +68,101 @@ module.exports = function registerAccountRoutes(app, deps) {
}; };
} }
function getSessionMetadata(req) { async function clearPendingEmailWithoutActiveOtp(user) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim(); const pendingEmail = String(user && user.pending_email || '').trim().toLowerCase();
return { if (!pendingEmail) return;
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim(), const email = String(user.email || '').trim().toLowerCase();
userAgent: req && req.headers && req.headers['user-agent'] const identifier = pendingEmail === email
}; ? 'email-verification-otp-' + email
} : 'change-email-otp-' + email + '-' + pendingEmail;
function getRequestOrigin(req) { await pool.query('DELETE FROM a_verifications WHERE identifier = ? AND expires_at <= NOW()', [identifier]);
const forwardedProto = String(req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); const [result] = await pool.query(
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim(); `UPDATE a_users
return forwardedHost ? forwardedProto + '://' + forwardedHost : ''; 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) { app.post('/account/email', async function (req, res, next) {
try { try {
const email = String(req.body.email || '').trim().toLowerCase(); const [rows] = await pool.query('SELECT pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.'); 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); const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.'); if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
const token = createOneTimeToken(); await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
const expiryMinutes = Number(settings['email.verification_expiry_minutes']); res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your new email address.'));
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.'));
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
app.get('/account', function (req, res) { app.post('/account/email/send-verification', async function (req, res, next) {
fetchAppSettings(pool).then(function (settings) { 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 passwordRequirements = buildPasswordRequirements(settings);
const authMethods = await getAuthMethods(req.currentUser.id);
const passkeys = await getPasskeys(req.currentUser.id);
if (!settings['security.allow_user_session_revocation']) { 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 cookies = parseCookies(req.headers.cookie || '');
const tokenHash = cookies[sessionCookieName] ? hashSessionToken(cookies[sessionCookieName]) : ''; const sessionToken = String(req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName] || '');
return pool.query( 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', '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] [req.currentUser.id]
).then(function (result) { );
const rows = result[0] || []; const rows = result[0] || [];
const sessions = rows.map(function (session) { const sessions = rows.map(function (session) {
return { return {
id: Number(session.id), id: Number(session.id),
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash), isCurrent: Boolean(sessionToken && session.session_hash === sessionToken),
ipAddress: String(session.ip_address || 'Unknown'), ipAddress: String(session.ip_address || 'Unknown'),
userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser', userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
createdAtLabel: formatDashboardDate(session.created_at), createdAtLabel: formatDashboardDate(session.created_at),
@@ -107,11 +170,10 @@ module.exports = function registerAccountRoutes(app, deps) {
expiresAtLabel: formatDashboardDate(session.expires_at) expiresAtLabel: formatDashboardDate(session.expires_at)
}; };
}); });
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements)); res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements, authMethods, passkeys));
}); } catch (error) {
}).catch(function (error) {
res.status(500).send(error.message || 'Unable to load account settings.'); res.status(500).send(error.message || 'Unable to load account settings.');
}); }
}); });
app.get('/account/password', async function (req, res, next) { 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 (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.'); 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; const user = rows[0] || null;
if (!user) return res.status(404).send('User not found.'); 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 (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.'); 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 pendingEmail = String(user.pending_email || '').trim().toLowerCase();
const emailChanged = email !== confirmedEmail && email !== pendingEmail; const emailChanged = email !== confirmedEmail && email !== pendingEmail;
const pendingEmailCleared = Boolean(pendingEmail && email === confirmedEmail); const pendingEmailCleared = Boolean(pendingEmail && email === confirmedEmail);
const settings = emailChanged ? await fetchAppSettings(pool) : null; 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); const actorId = getAuditUserId(req);
await pool.query('UPDATE a_users SET username = ?, name = ?, modified_by = ? WHERE id = ?', [username, name, actorId, user.id]); await pool.query('UPDATE a_users SET username = ?, name = ?, modified_by = ? WHERE id = ?', [username, name, actorId, user.id]);
if (emailChanged || pendingEmailCleared) { if (emailChanged || pendingEmailCleared) {
if (!email) { 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) { } 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 { } else {
const token = createOneTimeToken(); await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, user.id]);
const expiryMinutes = Number(settings['email.verification_expiry_minutes']); try {
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]); await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token); } catch (error) {
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 = 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) } }); 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) { } catch (error) {
next(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.')); return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
} }
const cookies = parseCookies(req.headers.cookie || ''); 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) { if (!token) {
return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.')); return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.'));
} }
const [result] = await pool.query( const [result] = await pool.query(
'DELETE FROM a_sessions WHERE id = ? AND user_id = ? AND session_hash <> ?', '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') { if (result && result.affectedRows && typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, { 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.')); return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
} }
const cookies = parseCookies(req.headers.cookie || ''); 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) { 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') { if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, { await recordRequestAuditEvent(pool, req, {
@@ -266,10 +330,10 @@ module.exports = function registerAccountRoutes(app, deps) {
const currentPassword = String(req.body.current_password || ''); const currentPassword = String(req.body.current_password || '');
if (!username) return res.status(400).send('Username is required.'); if (!username) return res.status(400).send('Username is required.');
if (!currentPassword) return res.status(400).send('Current password 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; const user = rows[0] || null;
if (!user) return res.status(404).send('User not found.'); 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]); 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.'); 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]); 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 currentPassword = String(req.body.current_password || '');
const newPassword = String(req.body.new_password || ''); const newPassword = String(req.body.new_password || '');
const confirmPassword = String(req.body.confirm_password || ''); const confirmPassword = String(req.body.confirm_password || '');
if (!currentPassword) {
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)) {
return sendPasswordError(req, res, 'Current password is incorrect.'); return sendPasswordError(req, res, 'Current password is incorrect.');
} }
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements()); 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.'); return sendPasswordError(req, res, 'New passwords do not match.');
} }
const passwordRecord = hashPassword(newPassword); try {
await pool.query( await betterAuth.api.changePassword({
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_by = ? WHERE id = ?', body: { currentPassword: currentPassword, newPassword: newPassword, revokeOtherSessions: true },
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id] headers: fromNodeHeaders(req.headers)
); });
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]); } 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') { if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, { await recordRequestAuditEvent(pool, req, {
category: 'security', category: 'security',
eventType: 'password.changed', eventType: 'password.changed',
actorUserId: user.id, actorUserId: req.currentUser.id,
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: req.currentUser.id,
targetLabel: user.username, targetLabel: req.currentUser.username,
details: { source: 'account' } details: { source: 'account' }
}); });
} }
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined; if (typeof clearSessionCookie === 'function') clearSessionCookie(res);
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, getSessionMetadata(req)); res.redirect('/login?message=' + encodeURIComponent('Password updated. Sign in again.'));
setSessionCookie(res, token, sessionMaxAgeMs);
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
} catch (error) { } catch (error) {
next(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 { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library');
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units'); 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 { replaceContentReferences } = require('#src/data/content-references');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates'); const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
@@ -644,7 +644,7 @@ module.exports = function registerContentRoutes(app, deps) {
} }
const nextUploadRefs = collectUploadReferencesFromPayload(payload); const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
const changes = buildAuditChanges({ const changes = buildSlideAuditChanges({
title: slide.title, title: slide.title,
templateId: Number(slide.template_id), templateId: Number(slide.template_id),
content: slide.content content: slide.content
@@ -683,7 +683,7 @@ module.exports = function registerContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts screenSlideCounts: screenSlideCounts
}); });
await broadcastDashboardState(); 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) { queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
console.warn('Unable to queue slide thumbnail refresh:', error); console.warn('Unable to queue slide thumbnail refresh:', error);
}); });
@@ -921,7 +921,7 @@ module.exports = function registerContentRoutes(app, deps) {
details: { changes: buildAuditChanges( details: { changes: buildAuditChanges(
{ regions: normalizeTemplateRegionsForAudit(template.regions) }, { regions: normalizeTemplateRegionsForAudit(template.regions) },
{ regions: normalizeTemplateRegionsForAudit(regions) } { regions: normalizeTemplateRegionsForAudit(regions) }
) } ), ...(slideId ? { promotedSlideId: slideId } : {}) }
}); });
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount }); res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
} catch (error) { } catch (error) {
@@ -1005,7 +1005,7 @@ module.exports = function registerContentRoutes(app, deps) {
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, {
category: 'templates', eventType: 'template.updated', actorUserId: actorId, category: 'templates', eventType: 'template.updated', actorUserId: actorId,
targetType: 'template', targetId: templateId, targetLabel: template.name, 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 }); res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
} catch (error) { } catch (error) {
@@ -1047,12 +1047,14 @@ module.exports = function registerContentRoutes(app, deps) {
canvasSizeId: Number(template.canvas_size_id), canvasSizeId: Number(template.canvas_size_id),
backgroundImagePath: template.background_image_path, backgroundImagePath: template.background_image_path,
backgroundColor: template.background_color, backgroundColor: template.background_color,
backgroundGradient: template.background_gradient,
regions: normalizeTemplateRegionsForAudit(template.regions) regions: normalizeTemplateRegionsForAudit(template.regions)
}, { }, {
name: payload.name, name: payload.name,
canvasSizeId: Number(payload.canvasSizeId), canvasSizeId: Number(payload.canvasSizeId),
backgroundImagePath: payload.backgroundImagePath, backgroundImagePath: payload.backgroundImagePath,
backgroundColor: payload.backgroundColor, backgroundColor: payload.backgroundColor,
backgroundGradient: payload.backgroundGradient,
regions: normalizeTemplateRegionsForAudit(payload.regions) regions: normalizeTemplateRegionsForAudit(payload.regions)
}); });
connection = await pool.getConnection(); connection = await pool.getConnection();
+33 -2
View File
@@ -19,6 +19,33 @@ module.exports = function registerPlaylistRoutes(app, deps) {
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage; const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const requirePermission = deps.requirePermission; 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) { function readRawFieldArray(reqBody, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys]; const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) { 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 saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
const nextItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
await connection.commit(); await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh'); await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState(); await broadcastDashboardState();
@@ -465,12 +494,14 @@ module.exports = function registerPlaylistRoutes(app, deps) {
name: playlist.name, name: playlist.name,
fadeBetweenSlides: Boolean(playlist.fade_between_slides), fadeBetweenSlides: Boolean(playlist.fade_between_slides),
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp), 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, name: name,
fadeBetweenSlides: Boolean(fadeBetweenSlides), fadeBetweenSlides: Boolean(fadeBetweenSlides),
skipUnavailableRtmp: Boolean(skipUnavailableRtmp), 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 } }); 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 { return {
formValues: { formValues: {
name: common.validateMaxLength(formValues && formValues.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'), 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)), permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(data.users, selectedUserIds), users: mapUsersForView(data.users, selectedUserIds),
@@ -247,7 +248,8 @@
const permissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId); const permissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
const viewModel = await buildRoleCreateViewModel({ const viewModel = await buildRoleCreateViewModel({
name: buildDuplicateRoleName(role.name), 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); }, 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')); 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 { try {
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'); 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 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') const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
? readArrayField(req.body, ['user_ids[]', 'user_ids']) ? readArrayField(req.body, ['user_ids[]', 'user_ids'])
: []; : [];
@@ -277,7 +280,7 @@
const sort = common.getSortQuery(req); const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req); const direction = common.getSortDirectionQuery(req);
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; 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) { if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); 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 { try {
await connection.beginTransaction(); await connection.beginTransaction();
const [result] = await connection.query( const [result] = await connection.query(
'INSERT INTO a_roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO a_roles (role_key, name, description, require_two_factor, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
[roleKey, name, description || null, actorId, actorId] [roleKey, name, description || null, requireTwoFactor ? 1 : 0, actorId, actorId]
); );
insertedRoleId = Number(result.insertId); insertedRoleId = Number(result.insertId);
if (selectedPermissionKeys.length) { if (selectedPermissionKeys.length) {
@@ -331,7 +334,7 @@
targetType: 'role', targetType: 'role',
targetId: insertedRoleId, targetId: insertedRoleId,
targetLabel: name, targetLabel: name,
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds } details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds, requireTwoFactor: requireTwoFactor }
}); });
} }
res.redirect('/settings/roles/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.')); 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 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 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 shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present'); const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
const selectedPermissionKeys = shouldSyncPermissions const selectedPermissionKeys = shouldSyncPermissions
@@ -440,8 +444,8 @@
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
await connection.query( await connection.query(
'UPDATE a_roles SET name = ?, description = ?, modified_by = ? WHERE id = ?', 'UPDATE a_roles SET name = ?, description = ?, require_two_factor = ?, modified_by = ? WHERE id = ?',
[name, description || null, getAuditUserId(req), roleId] [name, description || null, requireTwoFactor ? 1 : 0, getAuditUserId(req), roleId]
); );
if (shouldSyncPermissions) { if (shouldSyncPermissions) {
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys); await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
@@ -460,11 +464,13 @@
const changes = buildAuditChanges({ const changes = buildAuditChanges({
name: role.name, name: role.name,
description: role.description, description: role.description,
requireTwoFactor: Boolean(role.require_two_factor),
permissionKeys: existingRolePermissionKeys, permissionKeys: existingRolePermissionKeys,
userIds: existingRoleUserIds userIds: existingRoleUserIds
}, { }, {
name: name, name: name,
description: description || null, description: description || null,
requireTwoFactor: requireTwoFactor,
permissionKeys: normalizedPermissionKeys, permissionKeys: normalizedPermissionKeys,
userIds: normalizedUserIds userIds: normalizedUserIds
}); });
+119 -171
View File
@@ -1,8 +1,8 @@
// Admin user route registration and user-role management. // Admin user route registration and user-role management.
const { fetchAppSettings } = require('#src/data/app-settings'); 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 { buildAuditChanges, formatUserAgentLabel } = require('#src/data/audit-log');
const { createSyntheticAuthEmail, toVisibleEmail, hashCredentialPassword, setCredentialPassword } = require('#src/data/auth-accounts');
module.exports = function registerUsersRoutes(app, deps) { module.exports = function registerUsersRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -11,11 +11,6 @@
const formatDashboardDate = deps.formatDashboardDate; const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId; const getAuditUserId = deps.getAuditUserId;
const recordRequestAuditEvent = deps.recordRequestAuditEvent; 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 validatePasswordStrength = deps.validatePasswordStrength;
const readArrayField = deps.readArrayField; const readArrayField = deps.readArrayField;
const rbacData = deps.rbacData; const rbacData = deps.rbacData;
@@ -34,11 +29,6 @@
return rbacData.fetchRoles(pool); return rbacData.fetchRoles(pool);
} }
async function shouldRequirePasswordChange(settingKey) {
const settings = await fetchAppSettings(pool);
return Boolean(settings[settingKey]);
}
async function getPasswordRequirements() { async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool); const settings = await fetchAppSettings(pool);
return { return {
@@ -85,23 +75,6 @@
return { ok: true, roleIds: normalizedRoleIds }; 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) { app.get('/settings/users', requirePermission('users.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); 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) { app.get('/settings/users/new', requirePermission('users.create'), function (req, res) {
fetchRoleOptions().then(function (roles) { fetchRoleOptions().then(function (roles) {
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary')); 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) { app.get('/settings/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
try { try {
const userId = Number(req.params.id); 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) { app.post('/settings/users', requirePermission('users.create'), async function (req, res, next) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
@@ -368,13 +307,15 @@
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase(); const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
const password = String(req.body.password || ''); const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_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 saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase();
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds); const roleCheck = await validateRoleIds(selectedRoleIds);
const formValues = { const formValues = {
username: username, username: username,
name: name, name: name,
email: email email: email,
must_change_password: mustChangePassword
}; };
async function renderValidationError(message) { async function renderValidationError(message) {
@@ -410,14 +351,14 @@
return renderValidationError('That username already exists.'); return renderValidationError('That username already exists.');
} }
const passwordRecord = hashPassword(password); const encodedPassword = await hashCredentialPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_for_new_users');
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
await connection.beginTransaction(); await connection.beginTransaction();
const [result] = await connection.query( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO a_users (name, username, email, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
[name, username, email || null, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, actorId, actorId] [name, username, email || createSyntheticAuthEmail(), mustChangePassword ? 1 : 0, actorId, actorId]
); );
await setCredentialPassword(connection, result.insertId, encodedPassword);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds); await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit(); await connection.commit();
if (typeof recordRequestAuditEvent === 'function') { if (typeof recordRequestAuditEvent === 'function') {
@@ -515,6 +456,8 @@
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase(); 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 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 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 [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1; const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
@@ -525,6 +468,7 @@
return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, { return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, {
name: name || user.name, name: name || user.name,
username: username || user.username, username: username || user.username,
must_change_password: mustChangePassword,
roleIds: selectedRoleIds, roleIds: selectedRoleIds,
inUse: !canDelete inUse: !canDelete
}), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds))); }), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds)));
@@ -559,6 +503,10 @@
if (existingRows.length) { if (existingRows.length) {
return renderValidationError('That username already exists.'); 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({ const changes = buildAuditChanges({
name: user.name, name: user.name,
@@ -566,6 +514,7 @@
email: user.email || '', email: user.email || '',
roleIds: user.roleIds, roleIds: user.roleIds,
accountLocked: Boolean(user.account_locked), accountLocked: Boolean(user.account_locked),
mustChangePassword: Boolean(user.must_change_password),
passwordReset: false passwordReset: false
}, { }, {
name: name, name: name,
@@ -573,29 +522,28 @@
email: email, email: email,
roleIds: roleCheck.roleIds, roleIds: roleCheck.roleIds,
accountLocked: accountLocked, accountLocked: accountLocked,
mustChangePassword: mustChangePassword,
passwordReset: shouldUpdatePassword passwordReset: shouldUpdatePassword
}); });
await connection.beginTransaction(); 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) { if (!result.affectedRows) {
await connection.rollback(); await connection.rollback();
return res.status(404).send('User not found.'); 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]); const emailIsUnchanged = Boolean(email && email === user.email);
if (emailVerified && email && (await fetchAppSettings(pool))['security.allow_admin_email_verification_bypass']) { let verifiedEmail = Boolean(emailIsUnchanged && user.email_verified);
await connection.query('UPDATE a_users SET email_verified_at = NOW() WHERE id = ? AND email = ?', [userId, email]); 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); await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
if (accountLocked) { if (accountLocked) {
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]); await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
} }
if (shouldUpdatePassword) { if (shouldUpdatePassword) {
const passwordRecord = hashPassword(password); const encodedPassword = await hashCredentialPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset'); await setCredentialPassword(connection, userId, encodedPassword);
await connection.query( await connection.query('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?', [mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
'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]
);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]); await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
} }
await connection.commit(); await connection.commit();
@@ -630,7 +578,7 @@
details: { source: 'administrator' } details: { source: 'administrator' }
}); });
} }
if (emailVerified && !user.email_verified_at && email) { if (emailVerified && !user.email_verified && verifiedEmail && email) {
await recordRequestAuditEvent(pool, req, { await recordRequestAuditEvent(pool, req, {
category: 'security', category: 'security',
eventType: 'email.verification_bypassed', eventType: 'email.verification_bypassed',
@@ -679,17 +627,17 @@
return res.redirect(editUrl + '?message=' + encodeURIComponent('Passwords do not match.')); 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) { if (!rows.length) {
return res.status(404).send('User not found.'); return res.status(404).send('User not found.');
} }
const passwordRecord = hashPassword(password); const encodedPassword = await hashCredentialPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset'); const mustChangePassword = Object.prototype.hasOwnProperty.call(req.body || {}, 'must_change_password')
await pool.query( ? req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'))
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?', : Boolean(rows[0].must_change_password);
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId] 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]); await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
if (typeof recordRequestAuditEvent === 'function') { if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, { 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' });
};
+20 -264
View File
@@ -2,7 +2,6 @@
const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session'); const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session');
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
const { hasPermission } = require('#src/rbac'); const { hasPermission } = require('#src/rbac');
const DEFAULT_LANDING_ROUTES = [ const DEFAULT_LANDING_ROUTES = [
@@ -21,7 +20,6 @@ const DEFAULT_LANDING_ROUTES = [
['weather.read', '/data-sources/weather'], ['weather.read', '/data-sources/weather'],
['users.read', '/settings/users'], ['users.read', '/settings/users'],
['rbac.read', '/settings/roles'], ['rbac.read', '/settings/roles'],
['invitations.read', '/settings/invitations'],
['fonts.read', '/settings/fonts'], ['fonts.read', '/settings/fonts'],
['weather-icons.read', '/settings/weather-icons'], ['weather-icons.read', '/settings/weather-icons'],
['background-tasks.read', '/settings/tasks-background'], ['background-tasks.read', '/settings/tasks-background'],
@@ -41,81 +39,18 @@ function getDefaultLandingPath(currentUser) {
module.exports = function registerAuthRoutes(app, deps) { module.exports = function registerAuthRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
const pages = deps.pages; const pages = deps.pages;
const createUserSession = deps.createUserSession; const betterAuth = deps.betterAuth;
const setSessionCookie = deps.setSessionCookie;
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
const clearSessionCookie = deps.clearSessionCookie; const clearSessionCookie = deps.clearSessionCookie;
const parseCookies = deps.parseCookies; const parseCookies = deps.parseCookies;
const consumeAuthMessageCookie = deps.consumeAuthMessageCookie; const consumeAuthMessageCookie = deps.consumeAuthMessageCookie;
const setAuthMessageCookie = deps.setAuthMessageCookie; const setAuthMessageCookie = deps.setAuthMessageCookie;
const hashSessionToken = deps.hashSessionToken;
const verifyPassword = deps.verifyPassword;
const sessionCookieName = deps.sessionCookieName; const sessionCookieName = deps.sessionCookieName;
const recordRequestAuditEvent = deps.recordRequestAuditEvent; const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const sendAccountEmail = deps.sendAccountEmail;
const createOneTimeToken = deps.createOneTimeToken;
const getConnection = deps.getConnection;
const rbacData = deps.rbacData;
function getReturnTo(req) { function getReturnTo(req) {
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(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() { async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool); const settings = await fetchAppSettings(pool);
return { 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) { app.get('/forgot-password', function (req, res) {
res.send(pages.renderForgotPasswordPage('')); res.send(pages.renderForgotPasswordPage(''));
}); });
@@ -184,24 +70,15 @@ module.exports = function registerAuthRoutes(app, deps) {
app.post('/forgot-password', async function (req, res, next) { app.post('/forgot-password', async function (req, res, next) {
try { try {
const identity = String(req.body.identity || '').trim(); const identity = String(req.body.identity || '').trim();
const genericMessage = 'If that account has a verified email address, a reset link has been sent.'; const genericMessage = 'If that account has a verified email address, a reset code 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 [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 user = rows[0] || null;
const settings = await fetchAppSettings(pool); const settings = await fetchAppSettings(pool);
if (user && user.email && user.email_verified_at && typeof sendAccountEmail === 'function' && typeof createOneTimeToken === 'function') { if (user && user.email && user.email_verified && settings['email.smtp_enabled']) {
const token = createOneTimeToken(); await betterAuth.api.requestPasswordResetEmailOTP({ body: { email: user.email } });
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)]);
} }
} const resetEmail = identity.indexOf('@') !== -1 ? identity.toLowerCase() : '';
res.send(pages.renderForgotPasswordPage(genericMessage)); res.send(pages.renderResetPasswordPage(genericMessage, resetEmail, await getPasswordRequirements()));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -209,9 +86,7 @@ module.exports = function registerAuthRoutes(app, deps) {
app.get('/reset-password', async function (req, res, next) { app.get('/reset-password', async function (req, res, next) {
try { try {
const token = String(req.query.token || '').trim(); res.send(pages.renderResetPasswordPage('', String(req.query.email || ''), await getPasswordRequirements()));
if (!token) return res.redirect('/forgot-password');
res.send(pages.renderResetPasswordPage('', token, await getPasswordRequirements()));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -219,34 +94,19 @@ module.exports = function registerAuthRoutes(app, deps) {
app.post('/reset-password', async function (req, res, next) { app.post('/reset-password', async function (req, res, next) {
try { 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 password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_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 requirements = await getPasswordRequirements();
const record = rows[0] || null; const strengthMessage = deps.validatePasswordStrength(password, requirements);
if (!record) return res.status(400).send(pages.renderResetPasswordPage('This reset link is invalid or has expired.', token, await getPasswordRequirements())); if (strengthMessage || password !== confirmPassword) return res.status(400).send(pages.renderResetPasswordPage(strengthMessage || 'Passwords do not match.', email, requirements));
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 { try {
const tokenHash = hashSessionToken(String(req.query.token || '')); await betterAuth.api.resetPasswordEmailOTP({ body: { email: email, otp: otp, password: password } });
const [rows] = await pool.query('SELECT id FROM a_users WHERE pending_email_token_hash = ? AND pending_email_expires_at > NOW() LIMIT 1', [tokenHash]); } catch (error) {
const user = rows[0] || null; return res.status(400).send(pages.renderResetPasswordPage('The email code is invalid or expired. Request a new code and try again.', email, requirements));
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]); res.send(pages.renderForgotPasswordPage('Password updated. You can now sign in.'));
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_completed', targetType: 'user', targetId: user.id });
res.send(pages.renderEmailVerifiedPage());
} catch (error) { } catch (error) {
next(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) { app.get('/logout', async function (req, res, next) {
try { try {
const cookies = parseCookies(req.headers.cookie || ''); 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) { 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; const actor = req.authenticatedUser || req.currentUser;
if (typeof recordRequestAuditEvent === 'function' && actor) { if (typeof recordRequestAuditEvent === 'function' && actor) {
+4 -1
View File
@@ -1,6 +1,7 @@
// Sign-in page renderer for the authentication flow. // Sign-in page renderer for the authentication flow.
const { renderView } = require('#src/web/view'); const { renderView } = require('#src/web/view');
const { resolvePasskeyRpID } = require('#src/web/lib/auth/better-auth');
module.exports = function renderLoginPage(message, returnTo, username, showForgotPassword) { module.exports = function renderLoginPage(message, returnTo, username, showForgotPassword) {
return renderView('auth/login', { return renderView('auth/login', {
@@ -11,6 +12,8 @@ module.exports = function renderLoginPage(message, returnTo, username, showForgo
returnTo: returnTo || '', returnTo: returnTo || '',
username: username || '', username: username || '',
showForgotPassword: Boolean(showForgotPassword), 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'); 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 requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
const requiredCategories = []; const requiredCategories = [];
if (requirements.requireLowercase) requiredCategories.push('lowercase'); if (requirements.requireLowercase) requiredCategories.push('lowercase');
@@ -12,5 +12,5 @@ module.exports = function renderResetPasswordPage(message, token, passwordRequir
: requirements.minimumCategories === 4 : requirements.minimumCategories === 4
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.' ? '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.'; : '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. // Auth, profile, and user management routes share the same session helpers.
registerAuthRoutes(app, { registerAuthRoutes(app, {
pool: deps.pool, pool: deps.pool,
betterAuth: deps.betterAuth,
fromNodeHeaders: deps.fromNodeHeaders,
pages: deps.pages, pages: deps.pages,
createUserSession: deps.createUserSession,
setSessionCookie: deps.setSessionCookie,
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
getConfiguredMaxActiveSessions: deps.getConfiguredMaxActiveSessions,
clearSessionCookie: deps.clearSessionCookie, clearSessionCookie: deps.clearSessionCookie,
setAuthMessageCookie: deps.setAuthMessageCookie, setAuthMessageCookie: deps.setAuthMessageCookie,
consumeAuthMessageCookie: deps.consumeAuthMessageCookie, consumeAuthMessageCookie: deps.consumeAuthMessageCookie,
parseCookies: deps.parseCookies, parseCookies: deps.parseCookies,
hashSessionToken: deps.hashSessionToken,
verifyPassword: deps.verifyPassword,
hashPassword: deps.hashPassword,
validatePasswordStrength: deps.validatePasswordStrength, validatePasswordStrength: deps.validatePasswordStrength,
sessionCookieName: deps.sessionCookieName, sessionCookieName: deps.sessionCookieName,
recordRequestAuditEvent: deps.recordRequestAuditEvent 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, { registerPagesRoutes(app, {
@@ -120,22 +111,18 @@ function registerAuthAndAccountRoutes(app, deps) {
registerAccountRoutes(app, { registerAccountRoutes(app, {
pool: deps.pool, pool: deps.pool,
betterAuth: deps.betterAuth,
fromNodeHeaders: deps.fromNodeHeaders,
common: deps.common, common: deps.common,
pages: deps.pages, pages: deps.pages,
formatDashboardDate: deps.formatDashboardDate, formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId, getAuditUserId: deps.getAuditUserId,
verifyPassword: deps.verifyPassword,
hashPassword: deps.hashPassword, hashPassword: deps.hashPassword,
validatePasswordStrength: deps.validatePasswordStrength, validatePasswordStrength: deps.validatePasswordStrength,
createUserSession: deps.createUserSession,
setSessionCookie: deps.setSessionCookie,
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
parseCookies: deps.parseCookies, parseCookies: deps.parseCookies,
hashSessionToken: deps.hashSessionToken, hashSessionToken: deps.hashSessionToken,
sessionCookieName: deps.sessionCookieName, sessionCookieName: deps.sessionCookieName,
recordRequestAuditEvent: deps.recordRequestAuditEvent recordRequestAuditEvent: deps.recordRequestAuditEvent
,sendAccountEmail: deps.sendAccountEmail
,createOneTimeToken: deps.createOneTimeToken
}); });
registerUsersRoutes(app, { registerUsersRoutes(app, {
@@ -150,10 +137,6 @@ function registerAuthAndAccountRoutes(app, deps) {
rbacData: deps.rbacData, rbacData: deps.rbacData,
requirePermission: deps.requirePermission, requirePermission: deps.requirePermission,
recordRequestAuditEvent: deps.recordRequestAuditEvent 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 { return {
id: null, id: null,
name: String(duplicateName || buildDuplicateRoleName(source.name)).trim(), 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 { 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 { 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 { fetchAppSettings, saveAppSettings } = require('#src/data/app-settings');
const { normalizeOtpEmailBody } = require('#src/data/account-email-templates');
const { appVersion, detectSchemaVersion } = require('#src/db/migrations'); const { appVersion, detectSchemaVersion } = require('#src/db/migrations');
const UPLOAD_MIME_TYPE_OPTIONS = [ const UPLOAD_MIME_TYPE_OPTIONS = [
@@ -42,24 +43,17 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
'data-sources.api_default_interval_unit' 'data-sources.api_default_interval_unit'
], ],
security: [ security: [
'security.session_lifetime_days',
'security.allow_user_session_revocation', '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_length',
'security.password_min_categories', 'security.password_min_categories',
'security.password_require_lowercase', 'security.password_require_lowercase',
'security.password_require_uppercase', 'security.password_require_uppercase',
'security.password_require_number', 'security.password_require_number',
'security.password_require_symbol', 'security.password_require_symbol',
'security.require_password_change_for_new_users',
'security.require_password_change_after_admin_reset',
'security.allow_admin_email_verification_bypass' '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: ['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.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-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'], 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'], 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'], 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) { 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 }); 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']), 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, passwordMinLength: Number(settings['security.password_min_length']) || 10,
passwordMinCategories: Number(settings['security.password_min_categories']) || 3, passwordMinCategories: Number(settings['security.password_min_categories']) || 3,
passwordRequireLowercase: Boolean(settings['security.password_require_lowercase']), passwordRequireLowercase: Boolean(settings['security.password_require_lowercase']),
passwordRequireUppercase: Boolean(settings['security.password_require_uppercase']), passwordRequireUppercase: Boolean(settings['security.password_require_uppercase']),
passwordRequireNumber: Boolean(settings['security.password_require_number']), passwordRequireNumber: Boolean(settings['security.password_require_number']),
passwordRequireSymbol: Boolean(settings['security.password_require_symbol']), 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']), allowAdminEmailVerificationBypass: Boolean(settings['security.allow_admin_email_verification_bypass']),
smtpEnabled: Boolean(settings['email.smtp_enabled']), smtpEnabled: Boolean(settings['email.smtp_enabled']),
smtpHost: String(settings['email.smtp_host'] || ''), smtpHost: String(settings['email.smtp_host'] || ''),
@@ -175,21 +162,11 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
smtpConfigured: Boolean(settings['email.smtp_password']), smtpConfigured: Boolean(settings['email.smtp_password']),
emailFromAddress: String(settings['email.from_address'] || ''), emailFromAddress: String(settings['email.from_address'] || ''),
emailFromName: String(settings['email.from_name'] || ''), emailFromName: String(settings['email.from_name'] || ''),
verificationExpiryMinutes: Number(settings['email.verification_expiry_minutes']) || 30, otpExpiryMinutes: Number(settings['email.otp_expiry_minutes']) || 30,
resetExpiryMinutes: Number(settings['email.reset_expiry_minutes']) || 30,
invitationExpiryHours: Number(settings['email.invitation_expiry_hours']) || 24,
verificationEmailSubject: String(settings['email.verification_subject'] || ''), verificationEmailSubject: String(settings['email.verification_subject'] || ''),
verificationEmailBody: String(settings['email.verification_body'] || ''), verificationEmailBody: normalizeOtpEmailBody(settings['email.verification_body']),
verificationButtonAlignment: String(settings['email.verification_button_alignment'] || 'center'),
verificationButtonText: String(settings['email.verification_button_text'] || 'Verify email address'),
resetEmailSubject: String(settings['email.reset_subject'] || ''), resetEmailSubject: String(settings['email.reset_subject'] || ''),
resetEmailBody: String(settings['email.reset_body'] || ''), resetEmailBody: normalizeOtpEmailBody(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'),
defaultSlideDurationSeconds: Number(settings['player.default_slide_duration_seconds']) || 10, defaultSlideDurationSeconds: Number(settings['player.default_slide_duration_seconds']) || 10,
defaultFadeBetweenSlides: Boolean(settings['player.default_fade_between_slides']), defaultFadeBetweenSlides: Boolean(settings['player.default_fade_between_slides']),
defaultSkipUnavailableRtmp: Boolean(settings['player.skip_unavailable_rtmp']), 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) { app.post('/settings/system', deps.requirePermission('system-settings.update'), async function (req, res, next) {
try { try {
const section = String(req.body && req.body.settings_section || '').trim().toLowerCase(); 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') { if (section === 'email-templates') {
const verificationSubject = String(req.body && req.body.verification_subject || '').trim(); 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 resetSubject = String(req.body && req.body.reset_subject || '').trim();
const resetBody = String(req.body && req.body.reset_body || '').trim(); const resetBody = normalizeOtpEmailBody(req.body && req.body.reset_body).trim();
const verificationButtonText = String(req.body && req.body.verification_button_text || '').trim(); if (!verificationSubject || !verificationBody.includes('[[otp]]') || !resetSubject || !resetBody.includes('[[otp]]')) {
const resetButtonText = String(req.body && req.body.reset_button_text || '').trim(); const error = new Error('Email templates require a subject, message, and the correct code or link placeholder.');
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.');
error.statusCode = 400; error.statusCode = 400;
error.expose = true; error.expose = true;
throw error; throw error;
@@ -331,16 +322,8 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
const savedSettings = await saveAppSettings(pool, { const savedSettings = await saveAppSettings(pool, {
'email.verification_subject': verificationSubject, 'email.verification_subject': verificationSubject,
'email.verification_body': verificationBody, '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_subject': resetSubject,
'email.reset_body': resetBody, '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); }, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section); await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Account email templates saved.') + '#email-templates'); 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') { 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 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 passwordMinLength = Number(req.body && req.body.password_min_length);
const passwordMinCategories = Number(req.body && req.body.password_min_categories); 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 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 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 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 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; 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) { if (!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.'); const error = new Error('Password settings must use valid values; required password categories cannot exceed the minimum category count.');
error.statusCode = 400; error.statusCode = 400;
error.expose = true; error.expose = true;
throw error; throw error;
} }
const savedSettings = await saveAppSettings(pool, { const savedSettings = await saveAppSettings(pool, {
'security.session_lifetime_days': sessionLifetimeDays,
'security.allow_user_session_revocation': allowUserSessionRevocation, '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_length': passwordMinLength,
'security.password_min_categories': passwordMinCategories, 'security.password_min_categories': passwordMinCategories,
'security.password_require_lowercase': passwordRequireLowercase, 'security.password_require_lowercase': passwordRequireLowercase,
'security.password_require_uppercase': passwordRequireUppercase, 'security.password_require_uppercase': passwordRequireUppercase,
'security.password_require_number': passwordRequireNumber, 'security.password_require_number': passwordRequireNumber,
'security.password_require_symbol': passwordRequireSymbol, 'security.password_require_symbol': passwordRequireSymbol,
'security.require_password_change_for_new_users': requirePasswordChangeForNewUsers, '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.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')
}, req.currentUser && req.currentUser.id); }, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section); await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Security settings saved.') + '#security-sessions'); 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 smtpPassword = String(req.body && req.body.smtp_password || '');
const fromAddress = String(req.body && req.body.from_address || '').trim(); const fromAddress = String(req.body && req.body.from_address || '').trim();
const fromName = String(req.body && req.body.from_name || '').trim(); const fromName = String(req.body && req.body.from_name || '').trim();
const verificationExpiryMinutes = Number(req.body && req.body.verification_expiry_minutes); const otpExpiryMinutes = Number(req.body && req.body.otp_expiry_minutes);
const resetExpiryMinutes = Number(req.body && req.body.reset_expiry_minutes); if (!Number.isInteger(otpExpiryMinutes) || otpExpiryMinutes < 1 || otpExpiryMinutes > 10080 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
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 error = new Error('Enabled email delivery requires an SMTP host, valid port, and from address.'); const error = new Error('Enabled email delivery requires an SMTP host, valid port, and from address.');
error.statusCode = 400; error.statusCode = 400;
error.expose = true; error.expose = true;
@@ -449,9 +416,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'], 'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'],
'email.from_address': fromAddress, 'email.from_address': fromAddress,
'email.from_name': fromName, 'email.from_name': fromName,
'email.verification_expiry_minutes': verificationExpiryMinutes, 'email.otp_expiry_minutes': otpExpiryMinutes
'email.reset_expiry_minutes': resetExpiryMinutes,
'email.invitation_expiry_hours': invitationExpiryHours
}, req.currentUser && req.currentUser.id); }, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section); await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Email settings saved.') + '#email-delivery'); 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', messageVariant: messageVariant || 'success',
currentUser: currentUser || null, currentUser: currentUser || null,
roles: mapRolesForForm(roles, []), roles: mapRolesForForm(roles, []),
authMethods: [],
formValues: formValues || {}, 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"' 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) { 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 { return {
title: 'Edit user', title: 'Edit user',
active: 'users', active: 'users',
@@ -60,7 +67,9 @@ function buildUsersEditViewModel(user, message, currentUser, roles, sessions) {
showSaveSecondaryActions: true, showSaveSecondaryActions: true,
message: message, message: message,
currentUser: currentUser || null, currentUser: currentUser || null,
user: user, user: mappedUser,
authMethods: authMethods,
passkeys: passkeys,
sessions: Array.isArray(sessions) ? sessions : [], sessions: Array.isArray(sessions) ? sessions : [],
inUse: Boolean(user && user.inUse), inUse: Boolean(user && user.inUse),
roles: mapRolesForForm(roles, user && user.roleIds), 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 || {}
});
};
+261 -45
View File
@@ -1,7 +1,9 @@
<div class="page-header"> <div class="page-header">
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3">
<div> <div>
<h2>My account</h2> <h2>My account</h2>
<p>Update your username, email, display name, and password.</p> <p>Manage your sign-in identity, password, and active sessions.</p>
</div>
</div> </div>
</div> </div>
@@ -9,7 +11,7 @@
<div class="col-12 col-xl-6"> <div class="col-12 col-xl-6">
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-header bg-body-tertiary"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Account details</h3> <h3 class="card-title">Profile and email</h3>
</div> </div>
<form id="account-profile-form" method="post" action="/account/profile" autocomplete="off" data-async-save data-async-save-refresh-page="true"> <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"> <div class="card-body">
@@ -17,23 +19,276 @@
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="account-username" class="form-label">Username</label> <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 /> <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>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="account-name" class="form-label">Display name</label> <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 /> <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>
<div class="col-12"> <div class="col-12">
<label for="account-email" class="form-label">Email address</label> <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" /> <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> {{#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>
</div> </div>
<input type="hidden" name="current_password" data-account-profile-current-password />
</div> </div>
</form> </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> </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}} {{#if allowUserSessionRevocation}}
<div class="card card-outline card-secondary admin-form-card mt-3"> <div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header bg-body-tertiary"> <div class="card-header bg-body-tertiary">
@@ -71,43 +326,4 @@
</div> </div>
{{/if}} {{/if}}
</div> </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> </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"> <form method="post" action="/forgot-password">
<label for="reset-identity" class="form-label">Username or email</label> <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> <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> </form>
<a class="d-block text-center mt-3" href="/login">Back to sign in</a> <a class="d-block text-center mt-3" href="/login">Back to sign in</a>
</div></div> </div></div>
+30 -11
View File
@@ -1,16 +1,16 @@
<div class="login-logo text-center mb-3"> <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>
<div class="card login-card mx-auto"> <div class="card login-card mx-auto">
<div class="card-body login-card-body"> <div class="card-body login-card-body">
<p class="login-box-msg">Sign in to start your session</p>
{{#if message}} {{#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}} {{/if}}
<form method="post" action="/login"> <form method="post" action="/api/auth/sign-in/username" data-better-auth-login>
{{#if returnTo}} {{#if returnTo}}
<input type="hidden" name="returnTo" value="{{returnTo}}" /> <input type="hidden" name="returnTo" value="{{returnTo}}" />
{{/if}} {{/if}}
<div data-login-credentials>
<div class="mb-3"> <div class="mb-3">
<label for="username" class="visually-hidden">Username or email</label> <label for="username" class="visually-hidden">Username or email</label>
<div class="input-group"> <div class="input-group">
@@ -21,24 +21,43 @@
<div class="mb-3"> <div class="mb-3">
<label for="password" class="visually-hidden">Password</label> <label for="password" class="visually-hidden">Password</label>
<div class="input-group"> <div class="input-group">
<input id="password" name="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required /> <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> <span class="input-group-text" aria-hidden="true"><i class="bi bi-lock"></i></span>
</div> </div>
</div> </div>
<div class="row align-items-center g-3 mb-3"> </div>
<div class="col-7"> <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"> <div class="form-check">
<input id="remember-me" name="remember_me" type="checkbox" class="form-check-input" value="1" /> <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> <label for="remember-me" class="form-check-label">Remember Me</label>
</div> </div>
</div> </div>
<div class="col-5"> <button type="submit" class="btn btn-primary px-4" data-login-submit>Sign In</button>
<button type="submit" class="btn btn-primary w-100">Sign in</button>
</div> </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> </div>
</form> </form>
{{#if showForgotPassword}} {{#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}} {{/if}}
</div> </div>
</div> </div>
+4 -1
View File
@@ -3,7 +3,10 @@
<h1 class="h4 mb-3">Choose a new password</h1> <h1 class="h4 mb-3">Choose a new password</h1>
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}} {{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
<form method="post" action="/reset-password"> <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> <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> <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> <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> <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> <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>
<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>
<div class="card-footer d-flex justify-content-end flex-wrap"> <div class="card-footer d-flex justify-content-end flex-wrap">
<div class="btn-group" role="group" aria-label="Role actions"> <div class="btn-group" role="group" aria-label="Role actions">
+15 -75
View File
@@ -1,6 +1,6 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h2>System Settings</h2> <h2>System settings</h2>
<p>Configure the defaults and content policies used across Pulse Signage.</p> <p>Configure the defaults and content policies used across Pulse Signage.</p>
</div> </div>
</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-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-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-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-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 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></div> </div></div>
<div class="card-footer d-flex align-items-center"> <div class="card-footer d-flex align-items-center">
{{#if (hasPermission currentUser 'system-settings.update')}} {{#if (hasPermission currentUser 'system-settings.update')}}
@@ -303,7 +301,7 @@
<div id="email-templates" class="card settings-section-card" data-settings-section hidden> <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-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 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="email-template-editor" data-email-template-editor>
<div class="row g-4 align-items-start"> <div class="row g-4 align-items-start">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
@@ -313,11 +311,7 @@
<label class="form-label" for="settings-verification-body">Message</label> <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> <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> <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="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 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> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="email-template-preview" data-email-template-preview aria-label="Email verification preview"></div> <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> <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> <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> <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="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 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> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="email-template-preview" data-email-template-preview aria-label="Password reset preview"></div> <div class="email-template-preview" data-email-template-preview aria-label="Password reset preview"></div>
</div> </div>
</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>
<div class="card-footer d-flex justify-content-end"> <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}} {{#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> </div>
</form> </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"> <form method="post" action="/settings/system" data-async-save data-settings-form="security">
<input type="hidden" name="settings_section" value="security"> <input type="hidden" name="settings_section" value="security">
<div id="security-sessions" class="card settings-section-card" data-settings-section hidden> <div id="security-sessions" class="card settings-section-card" data-settings-section hidden>
@@ -383,22 +357,6 @@
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <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"><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="col-12">
<div class="form-check form-switch"> <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}}> <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> </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"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Login protection</h4></div>
<div class="col-12 col-md-4"> <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>
<label class="form-label" for="settings-login-attempts">Failed attempts before lockout</label> {{#if (hasPermission currentUser 'login-lockouts.allow')}}
<input id="settings-login-attempts" name="login_max_attempts" type="number" class="form-control" min="1" max="100" required value="{{mediaSettings.loginMaxAttempts}}"> <div class="col-12">
</div> <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 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>
</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> </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"><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"> <div class="col-12 col-md-6 col-xl-3">
<label class="form-label" for="settings-password-min-length">Minimum length</label> <label class="form-label" for="settings-password-min-length">Minimum length</label>
@@ -450,13 +394,9 @@
</div> </div>
</div> </div>
<div class="col-12"> <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"> <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}}> <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-require-password-reset">Require password change after an administrator reset</label> <label class="form-check-label" for="settings-admin-email-verification-bypass">Allow administrators to mark user email addresses as verified</label>
</div> </div>
</div> </div>
</div> </div>
+76 -22
View File
@@ -7,7 +7,7 @@
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="row g-3"> <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 card-outline card-primary admin-form-card">
<div class="card-header bg-body-tertiary"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">{{#if isEdit}}Profile{{else}}User details{{/if}}</h3> <h3 class="card-title">{{#if isEdit}}Profile{{else}}User details{{/if}}</h3>
@@ -25,7 +25,7 @@
<div class="col-12"> <div class="col-12">
<label for="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" class="form-label">Email address</label> <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" /> <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>
</div> </div>
{{#if isEdit}} {{#if isEdit}}
@@ -39,19 +39,25 @@
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<div class="form-check form-switch"> <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> <label class="form-check-label" for="edit-user-email-verified">Email verified</label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{{/if}} {{/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 class="card-footer d-flex align-items-center justify-content-between flex-wrap gap-2"> </div>
</div>
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap">
{{#if isEdit}} {{#if isEdit}}
{{#if (hasPermission currentUser 'impersonate.allow')}} {{#if (hasPermission currentUser 'impersonate.allow')}}
{{#unless currentUser.isViewingAs}} {{#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}} {{/unless}}
{{/if}} {{/if}}
{{/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}} /> <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> </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}} {{#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}} {{/if}}
</div> </div>
</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>
<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 card-outline card-secondary admin-form-card">
<div class="card-header bg-body-tertiary"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Roles</h3> <h3 class="card-title">Roles</h3>
</div> </div>
<div class="card-body"> <div class="card-body py-0">
<div class="row g-3">
{{#if roles.length}} {{#if roles.length}}
<div class="list-group list-group-flush">
{{#each roles}} {{#each roles}}
<div class="col-12 col-md-6 col-xl-4"> <label class="list-group-item d-flex align-items-start gap-3 px-0">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0"> <input class="form-check-input mt-1 flex-shrink-0" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<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="flex-grow-1">
<span class="form-check-label pe-4"> <strong class="d-block">{{name}}</strong>
<strong>{{name}}</strong> <span class="text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span> </span>
</label> </label>
</div>
{{/each}} {{/each}}
</div>
{{else}} {{else}}
<div class="col-12"> <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>
<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}} {{/if}}
</div> <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 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> </div>
</div> </div>
</div> </div>
@@ -117,6 +163,14 @@
</form> </form>
{{#if isEdit}} {{#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')}} {{#if (hasPermission currentUser 'impersonate.allow')}}
{{#unless currentUser.isViewingAs}} {{#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> <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> <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 /> <input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
</div> </div>
<div class="btn-group"> <a class="btn btn-primary btn-sm" href="/settings/users/new">Add<span class="table-action-context"> user</span></a>
{{#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>
</div> </div>
</div> </div>
<div class="card-body table-responsive p-0"> <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/admin/admin-page.js"></script>
<script src="/assets/js/shared/system-status.js"></script> <script src="/assets/js/shared/system-status.js"></script>
{{else if authShell}} {{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"> <main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
<div class="login-box"> <div class="login-box">
{{{body}}} {{{body}}}
</div> </div>
</main> </main>
<script src="/assets/js/shared/ui/toast.js"></script>
<script src="/assets/js/admin/admin-page.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> <script src="/assets/js/shared/system-status.js"></script>
{{else}} {{else}}
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3"> <div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
@@ -285,10 +268,10 @@
</a> </a>
</li> </li>
{{/if}} {{/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> <li class="nav-header">SETTINGS</li>
{{/if}} {{/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}}"> <li class="nav-item has-treeview{{#if userManagementMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#"> <a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#">
<i class="nav-icon bi bi-people"></i> <i class="nav-icon bi bi-people"></i>
@@ -314,14 +297,6 @@
</a> </a>
</li> </li>
{{/if}} {{/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> </ul>
</li> </li>
{{/if}} {{/if}}
@@ -384,7 +359,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{#if (eq active 'settings')}}active{{/if}}" href="/settings/system"> <a class="nav-link {{#if (eq active 'settings')}}active{{/if}}" href="/settings/system">
<i class="nav-icon bi bi-sliders"></i> <i class="nav-icon bi bi-sliders"></i>
<p>System Settings</p> <p>System settings</p>
</a> </a>
</li> </li>
{{/if}} {{/if}}
+15 -7
View File
@@ -1,7 +1,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, renderAccountEmailTemplate } = require('../src/data/account-email-templates'); const { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTemplate } = require('../src/data/account-email-templates');
test('account email expiry values render with singular and plural units', () => { test('account email expiry values render with singular and plural units', () => {
assert.equal(formatAccountEmailExpiry(1, 'minutes'), '1 minute'); assert.equal(formatAccountEmailExpiry(1, 'minutes'), '1 minute');
@@ -10,16 +10,24 @@ test('account email expiry values render with singular and plural units', () =>
assert.equal(renderAccountEmailTemplate('Subject [[expiry_time]]', 'Expires in [[expiry_time]].', { expiry_time: '45 minutes' }).text, 'Expires in 45 minutes.'); assert.equal(renderAccountEmailTemplate('Subject [[expiry_time]]', 'Expires in [[expiry_time]].', { expiry_time: '45 minutes' }).text, 'Expires in 45 minutes.');
}); });
test('default account email templates render both the action button and inline link', () => { test('default account email templates render one-time verification codes', () => {
const rendered = renderAccountEmailTemplate('Subject', DEFAULT_ACCOUNT_EMAIL_TEMPLATES.verificationBody, { const rendered = renderAccountEmailTemplate('Subject', DEFAULT_ACCOUNT_EMAIL_TEMPLATES.verificationBody, {
url: 'https://example.test/verify',
display_name: 'Alex', display_name: 'Alex',
otp: '123456',
expiry_time: '30 minutes', expiry_time: '30 minutes',
action_label: 'Verify email address'
}); });
assert.match(rendered.html, /<strong>Alex<\/strong>/); assert.match(rendered.html, /<strong>Alex<\/strong>/);
assert.match(rendered.html, /This <u>link<\/u> expires in <em>30 minutes<\/em>\./); assert.match(rendered.html, /verification code[\s\S]*<strong>123456<\/strong>/);
assert.match(rendered.html, /Confirm this email address: <a href="https:\/\/example\.test\/verify">https:\/\/example\.test\/verify<\/a>/); assert.match(rendered.html, /This code expires in <em>30 minutes<\/em>\./);
assert.doesNotMatch(rendered.html, /\[\[(?:action_button|url)\]\]/); assert.doesNotMatch(rendered.html, /\[\[(?:action_button|url|otp)\]\]/);
});
test('legacy link email templates normalize to OTP wording and placeholders', () => {
const normalized = normalizeOtpEmailBody('Click [[action_button]]. This link expires in [[expiry_time]]. Verify: [[url]]');
const rendered = renderAccountEmailTemplate('Subject', normalized, { otp: '321654', expiry_time: '5 minutes' });
assert.match(rendered.text, /verification code/);
assert.match(rendered.text, /321654/);
assert.doesNotMatch(rendered.text, /\[\[(?:action_button|url|otp)\]\]/);
}); });
+258 -10
View File
@@ -27,6 +27,197 @@ test('required-password page shows only password controls and uses an inline war
assert.doesNotMatch(html, /id="app-toast"/); assert.doesNotMatch(html, /id="app-toast"/);
}); });
test('account page presents Better Auth identity, credential, and session controls', () => {
const html = pages.renderAccountPage({
id: 5,
username: 'demo',
name: 'Demo',
email: 'demo@example.test',
email_verified: true
}, '', '/dashboard', true, [], {
minimumLength: 10,
minimumCategories: 3,
requireLowercase: false,
requireUppercase: false,
requireNumber: false,
requireSymbol: false
});
assert.match(html, /Profile and email/);
assert.match(html, /Verified/);
assert.match(html, /Active sessions/);
assert.match(html, /Change password/);
assert.equal((html.match(/name="current_password"/g) || []).length, 2);
assert.doesNotMatch(html, /Confirmation|data-account-current-password|type="hidden" name="current_password"/);
});
test('two-factor account controls show setup or management only for the authenticated user', () => {
const requirements = { minimumLength: 10, minimumCategories: 3 };
const render = user => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements);
const setupHtml = render({ id: 5, username: 'demo', twoFactorEnabled: false });
const enabledHtml = render({ id: 5, username: 'demo', twoFactorEnabled: true });
const viewAsHtml = render({ id: 8, username: 'preview', twoFactorEnabled: false, isViewingAs: true });
assert.match(setupHtml, /data-two-factor-enable/);
assert.match(setupHtml, /data-two-factor-setup hidden/);
assert.doesNotMatch(setupHtml, /optional/i);
assert.match(setupHtml, /id="two-factor-setup-modal"/);
assert.match(setupHtml, /class="card-body py-0"[\s\S]*class="list-group list-group-flush"/);
assert.match(setupHtml, /list-group-item d-flex align-items-center justify-content-between gap-3 px-0/);
assert.match(setupHtml, /data-two-factor-download/);
assert.match(setupHtml, /aria-label="Download recovery codes"/);
assert.doesNotMatch(setupHtml, /modal-body[^]*?<hr>/);
assert.doesNotMatch(setupHtml, /data-two-factor-disable/);
assert.match(enabledHtml, /data-two-factor-disable/);
assert.match(enabledHtml, /data-two-factor-regenerate-codes/);
assert.match(enabledHtml, /id="two-factor-manage-modal"/);
assert.match(enabledHtml, /aria-label="Download recovery codes"/);
assert.doesNotMatch(enabledHtml, /Once enabled, two-step verification is required at password sign-in\./);
assert.match(enabledHtml, /data-bs-target="#two-factor-manage-modal"/);
assert.match(enabledHtml, /id="two-factor-manage-modal"/);
assert.doesNotMatch(viewAsHtml, /Two-step verification|data-two-factor-enable/);
});
test('passkey management is account-scoped and hidden while TOTP policy requires password sign-in', () => {
const requirements = { minimumLength: 10, minimumCategories: 3 };
const render = (user, passkeys) => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements, [], passkeys);
const available = render({ id: 5, username: 'demo', twoFactorEnabled: false }, [{ id: 'pk-1', name: 'Laptop' }]);
const protectedAccount = render({ id: 5, username: 'demo', twoFactorEnabled: true }, [{ id: 'pk-1', name: 'Laptop' }]);
const viewAs = render({ id: 8, username: 'preview', isViewingAs: true }, []);
assert.match(available, /data-passkey-add/);
assert.match(available, /id="passkey-setup-modal"/);
assert.match(available, /data-passkey-add-trigger[^>]*data-bs-toggle="modal"[^>]*data-bs-target="#passkey-setup-modal"/);
assert.doesNotMatch(available, /optional/i);
assert.match(available, /data-passkey-group[\s\S]*?data-passkey-list/);
assert.match(available, /data-passkey-list[\s\S]*?data-passkey-delete="pk-1"/);
assert.match(available, /<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">[\s\S]*Laptop/);
assert.match(available, /bi-arrow-return-right[^>]*aria-hidden="true"/);
assert.match(available, /Laptop/);
assert.match(available, /data-passkey-delete="pk-1"/);
assert.match(available, /js\/account\/passkeys\.js/);
assert.doesNotMatch(protectedAccount, /data-passkey-add/);
assert.match(protectedAccount, /Unavailable while authenticator verification is enabled/);
assert.doesNotMatch(viewAs, /data-passkey-add|data-passkey-delete/);
});
test('role-required two-factor enrollment is visible and cannot be unlinked from My account', () => {
const render = twoFactorEnabled => pages.renderAccountPage({ id: 5, username: 'demo', requiresTwoFactor: true, twoFactorEnabled }, '', '/dashboard', false, []);
const pending = render(false);
assert.match(pending, /Required by role/);
assert.match(pending, /Set up an authenticator to continue/);
assert.match(pending, /data-two-factor-enable/);
const enabled = render(true);
assert.doesNotMatch(enabled, /data-two-factor-disable/);
assert.match(enabled, /data-two-factor-regenerate-codes/);
assert.match(enabled, />Manage<\/button>/);
});
test('account email status and verification action stay inline with the address field', () => {
const requirements = {
minimumLength: 10,
minimumCategories: 3,
requireLowercase: false,
requireUppercase: false,
requireNumber: false,
requireSymbol: false
};
const render = user => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements);
const verifiedHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', email_verified: true });
assert.match(verifiedHtml, /<label for="account-email"[^>]*>[\s\S]*<span class="small text-success">Verified<\/span>/);
assert.doesNotMatch(verifiedHtml, /badge|Send code|Resend code/);
const unverifiedHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', email_verified: false });
assert.match(unverifiedHtml, /<div class="input-group">[\s\S]*name="email"[\s\S]*form="account-email-verification-form">Send code/);
assert.match(unverifiedHtml, /action="\/account\/email\/send-verification"/);
const pendingHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', pending_email: 'new@example.test' });
assert.match(pendingHtml, /Change pending/);
assert.match(pendingHtml, /form="account-email-verification-form">Resend code/);
assert.match(pendingHtml, /action="\/account\/email"/);
assert.match(pendingHtml, /name="otp"/);
assert.doesNotMatch(pendingHtml, /Cancel change|Cancel verification|\/account\/email\/cancel/);
});
test('account page clears pending_email when its Better Auth OTP is missing or expired', async () => {
const handlers = {};
const queries = [];
let renderedUser = null;
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post() {}
};
const pool = {
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('SELECT id, setting_key, setting_value FROM o_app_settings')) {
return [[{ id: 1, setting_key: 'security.allow_user_session_revocation', setting_value: 'false' }]];
}
if (text.includes('UPDATE a_users') && text.includes('NOT EXISTS')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
};
registerAccountRoutes(app, {
pool: pool,
common: {},
pages: {
renderAccountPage(user) { renderedUser = Object.assign({}, user); return 'account'; }
}
});
const req = {
currentUser: { id: 7, username: 'demo', email: 'old@example.test', pending_email: 'new@example.test' },
query: {}
};
const res = { send(value) { this.body = value; }, status() { return this; } };
await handlers['/account'][0](req, res);
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_verifications WHERE identifier = ? AND expires_at <= NOW()') && params[0] === 'change-email-otp-old@example.test-new@example.test'));
assert.ok(queries.some(([sql, params]) => sql.includes('UPDATE a_users') && sql.includes('NOT EXISTS') && params[1] === 'new@example.test'));
assert.equal(renderedUser.pending_email, null);
assert.equal(res.body, 'account');
});
test('account page preserves pending_email while a Better Auth OTP is still active', async () => {
const handlers = {};
let renderedUser = null;
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post() {}
};
const pool = {
async query(sql) {
if (String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings')) {
return [[{ id: 1, setting_key: 'security.allow_user_session_revocation', setting_value: 'false' }]];
}
if (String(sql).includes('UPDATE a_users') && String(sql).includes('NOT EXISTS')) {
return [{ affectedRows: 0 }];
}
return [[]];
}
};
registerAccountRoutes(app, {
pool: pool,
common: {},
pages: {
renderAccountPage(user) { renderedUser = Object.assign({}, user); return 'account'; }
}
});
const pendingEmail = 'new@example.test';
const req = {
currentUser: { id: 7, username: 'demo', email: 'old@example.test', pending_email: pendingEmail },
query: {}
};
const res = { send(value) { this.body = value; }, status() { return this; } };
await handlers['/account'][0](req, res);
assert.equal(renderedUser.pending_email, pendingEmail);
});
test('required-password route renders the password-only page and redirects after the requirement clears', async () => { test('required-password route renders the password-only page and redirects after the requirement clears', async () => {
const handlers = {}; const handlers = {};
const app = { const app = {
@@ -67,8 +258,9 @@ test('required-password route renders the password-only page and redirects after
assert.equal(regularResponse.redirectedTo, '/account'); assert.equal(regularResponse.redirectedTo, '/account');
}); });
test('account password update redirects back to the account page', async () => { test('account password update delegates to Better Auth and signs out', async () => {
const handlers = {}; const handlers = {};
let changePasswordRequest = null;
const app = { const app = {
post(path, ...routeHandlers) { post(path, ...routeHandlers) {
handlers[path] = routeHandlers; handlers[path] = routeHandlers;
@@ -79,10 +271,7 @@ test('account password update redirects back to the account page', async () => {
const deps = { const deps = {
pool: { pool: {
async query(sql) { async query(sql) {
if (sql.includes('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1')) { if (sql.includes('UPDATE a_users SET must_change_password = 0')) {
return [[{ id: 5, password_hash: 'hash', password_salt: 'salt', password_iterations: 1 }]];
}
if (sql.includes('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }]; return [{ affectedRows: 1 }];
} }
if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) { if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) {
@@ -93,13 +282,16 @@ test('account password update redirects back to the account page', async () => {
}, },
common: {}, common: {},
pages: {}, pages: {},
betterAuth: {
api: {
async changePassword(request) { changePasswordRequest = request; }
}
},
fromNodeHeaders(headers) { return headers; },
formatDashboardDate: () => '', formatDashboardDate: () => '',
getAuditUserId: () => 9, getAuditUserId: () => 9,
verifyPassword: () => true,
validatePasswordStrength: validatePasswordStrength, validatePasswordStrength: validatePasswordStrength,
hashPassword: () => ({ hash: 'new-hash', salt: 'new-salt', iterations: 2 }), clearSessionCookie() {}
createUserSession: async () => 'session-token',
setSessionCookie: () => {}
}; };
registerAccountRoutes(app, deps); registerAccountRoutes(app, deps);
@@ -113,6 +305,7 @@ test('account password update redirects back to the account page', async () => {
new_password: 'NewPassword!1', new_password: 'NewPassword!1',
confirm_password: 'NewPassword!1' confirm_password: 'NewPassword!1'
}, },
headers: { cookie: 'digital_signage_session=signed-token' },
currentUser: { id: 5 } currentUser: { id: 5 }
}; };
const res = { const res = {
@@ -129,5 +322,60 @@ test('account password update redirects back to the account page', async () => {
await routeHandlers[0](req, res, () => {}); await routeHandlers[0](req, res, () => {});
assert.equal(res.redirectedTo, '/account?message=Password%20updated.'); assert.deepEqual(changePasswordRequest.body, {
currentPassword: 'current-password',
newPassword: 'NewPassword!1',
revokeOtherSessions: true
});
assert.deepEqual(changePasswordRequest.headers, req.headers);
assert.equal(res.redirectedTo, '/login?message=Password%20updated.%20Sign%20in%20again.');
});
test('account profile verifies the current password through Better Auth', async () => {
const handlers = {};
let verificationRequest = null;
const app = {
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
get() {}
};
registerAccountRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('SELECT id, username, name, email, pending_email FROM a_users')) {
return [[{ id: 5, username: 'demo', name: 'Demo', email: 'demo@example.test', pending_email: null }]];
}
return [{ affectedRows: 1 }];
}
},
common: {
fetchDuplicateName: async () => null,
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {},
betterAuth: {
api: {
async verifyPassword(request) {
verificationRequest = request;
return { status: true };
}
}
},
fromNodeHeaders(headers) { return { cookie: headers.cookie }; },
formatDashboardDate: () => '',
getAuditUserId: () => 5
});
const req = {
body: { name: 'Demo', username: 'demo', email: 'demo@example.test', current_password: 'current-password' },
headers: { cookie: 'auth-session=session-token' },
currentUser: { id: 5 }
};
const res = { redirect(url) { this.redirectedTo = url; } };
await handlers['/account/profile'][0](req, res, error => { throw error; });
assert.deepEqual(verificationRequest, {
body: { password: 'current-password' },
headers: { cookie: 'auth-session=session-token' }
});
assert.equal(res.redirectedTo, '/account?message=Account%20details%20updated.');
}); });
+12 -1
View File
@@ -15,6 +15,7 @@ test('user create route allows users without roles', async () => {
}; };
let insertedArgs = null; let insertedArgs = null;
let credentialPassword = null;
let syncedRoles = null; let syncedRoles = null;
const deps = { const deps = {
@@ -33,6 +34,13 @@ test('user create route allows users without roles', async () => {
insertedArgs = args; insertedArgs = args;
return [{ insertId: 11 }]; return [{ insertId: 11 }];
} }
if (sql.includes('SELECT id, created_at, modified_at FROM a_users')) {
return [[{ id: 11, created_at: new Date(), modified_at: new Date() }]];
}
if (sql.includes('INSERT INTO a_accounts')) {
credentialPassword = args[3];
return [{ affectedRows: 1 }];
}
return [[]]; return [[]];
}, },
async commit() {}, async commit() {},
@@ -81,7 +89,8 @@ test('user create route allows users without roles', async () => {
name: 'No Roles User', name: 'No Roles User',
username: 'noroles', username: 'noroles',
password: 'ValidPass!1', password: 'ValidPass!1',
confirm_password: 'ValidPass!1' confirm_password: 'ValidPass!1',
must_change_password: '1'
}, },
currentUser: { id: 1 } currentUser: { id: 1 }
}; };
@@ -100,6 +109,8 @@ test('user create route allows users without roles', async () => {
await routeHandlers[1](req, res, () => {}); await routeHandlers[1](req, res, () => {});
assert.ok(insertedArgs); assert.ok(insertedArgs);
assert.equal(insertedArgs[3], 1);
assert.match(credentialPassword, /^[a-f0-9]{32}:[a-f0-9]{128}$/);
assert.deepEqual(syncedRoles, { userId: 11, roleIds: [] }); assert.deepEqual(syncedRoles, { userId: 11, roleIds: [] });
assert.equal(res.redirectedTo, '/settings/users/11/edit?message=User%20created.'); assert.equal(res.redirectedTo, '/settings/users/11/edit?message=User%20created.');
}); });
+6 -2
View File
@@ -7,6 +7,7 @@ const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user edit save and new goes to the blank create page', async () => { test('user edit save and new goes to the blank create page', async () => {
const handlers = {}; const handlers = {};
let mustChangePasswordValue = null;
const app = { const app = {
get() {}, get() {},
post(path, ...routeHandlers) { post(path, ...routeHandlers) {
@@ -28,8 +29,9 @@ test('user edit save and new goes to the blank create page', async () => {
async getConnection() { async getConnection() {
return { return {
async beginTransaction() {}, async beginTransaction() {},
async query(sql) { async query(sql, params) {
if (sql.includes('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?')) { if (sql.includes('UPDATE a_users SET name = ?, username = ?, account_locked = ?, must_change_password = ?, modified_by = ? WHERE id = ?')) {
mustChangePasswordValue = params[3];
return [{ affectedRows: 1 }]; return [{ affectedRows: 1 }];
} }
return [[]]; return [[]];
@@ -75,6 +77,7 @@ test('user edit save and new goes to the blank create page', async () => {
body: { body: {
name: 'User', name: 'User',
username: 'user', username: 'user',
must_change_password: '1',
save_action: 'new' save_action: 'new'
}, },
currentUser: { id: 1 } currentUser: { id: 1 }
@@ -94,4 +97,5 @@ test('user edit save and new goes to the blank create page', async () => {
await routeHandlers[1](req, res, () => {}); await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/settings/users/new?message=User%20updated.'); assert.equal(res.redirectedTo, '/settings/users/new?message=User%20updated.');
assert.equal(mustChangePasswordValue, 1);
}); });
-192
View File
@@ -1,192 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('pending invitations route loads active invitations with display fields', async () => {
const handlers = {};
let invitationQuery = '';
const rendered = {};
const createdAt = new Date('2026-09-03T10:00:00Z');
const expiresAt = new Date('2026-09-04T10:00:00Z');
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post() {}
};
const requiredPermissions = [];
const deps = {
pool: { async query() { return [[]]; } },
common: {
getSearchQuery: () => '',
getSortQuery: () => '',
getSortDirectionQuery: () => 'asc'
},
pages: {
renderInvitationsPage(data, currentUser) {
rendered.invitations = data.invitations;
rendered.pagination = data.pagination;
rendered.currentUser = currentUser;
return '';
}
},
formatDashboardDate(value) {
return value === createdAt ? 'sent label' : 'expiry label';
},
rbacData: {
fetchRoles: async () => [{ id: 2, name: 'Viewer' }],
fetchInvitationsPage: async (_pool, page, pageSize, search, sort, direction) => {
invitationQuery = { page, pageSize, search, sort, direction };
return {
invitations: [{
id: 7,
email: 'person@example.com',
name: 'Person',
role_ids_json: '[2]',
created_at: createdAt,
expires_at: expiresAt,
created_by_username: 'admin'
}],
totalItems: 26,
currentPage: 2
};
}
},
requirePermission(permission) {
requiredPermissions.push(permission);
return function (_req, _res, next) {
next();
};
}
};
registerUsersRoutes(app, deps);
const routeHandlers = handlers['/settings/invitations'];
assert.ok(requiredPermissions.includes('invitations.read'));
const currentUser = { id: 1 };
const res = {
send(value) {
this.body = value;
}
};
await routeHandlers[1]({ currentUser, query: {} }, res, function (error) {
throw error;
});
assert.deepEqual(invitationQuery, { page: 1, pageSize: 25, search: '', sort: '', direction: 'asc' });
assert.equal(rendered.currentUser, currentUser);
assert.equal(rendered.pagination.totalItems, 26);
assert.deepEqual(rendered.invitations, [{
id: 7,
email: 'person@example.com',
name: 'Person',
role_ids_json: '[2]',
created_at: createdAt,
expires_at: expiresAt,
created_by_username: 'admin',
roleNames: 'Viewer',
createdAtLabel: 'sent label',
expiresAtLabel: 'expiry label',
createdByLabel: 'admin'
}]);
});
test('user invite route stores the invitation and sends an acceptance link', async () => {
const handlers = {};
const queries = [];
let sentEmail = null;
const app = {
get() {},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const deps = {
pool: {
async query(sql, args) {
queries.push({ sql, args });
if (sql.includes('o_app_settings')) return [[{ setting_key: 'email.smtp_enabled', setting_value: 'true' }]];
if (sql.includes('SELECT id FROM a_users WHERE email')) return [[]];
if (sql.includes('INSERT INTO a_user_invitations')) return [{ insertId: 4 }];
if (sql.includes('COUNT(*) AS invite_count')) return [[{ invite_count: 0 }]];
return [[]];
}
},
common: {
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {
renderUsersInvitePage() { return ''; }
},
formatDashboardDate: () => '',
getAuditUserId: () => 9,
sendAccountEmail: async (_settings, message) => { sentEmail = message; },
createOneTimeToken: () => 'invite-token',
hashSessionToken: (value) => 'hash:' + value,
getRequestOrigin: () => 'https://signage.example.com',
readArrayField: () => ['2'],
rbacData: {
fetchRoles: async () => [{ id: 2, name: 'Viewer' }]
},
requirePermission() {
return function (_req, _res, next) { next(); };
}
};
registerUsersRoutes(app, deps);
const res = {
redirect(url) { this.redirectedTo = url; },
status() { return this; },
send() { return this; }
};
let routeError = null;
await handlers['/settings/users/invite'][1]({
body: { email: ' Person@Example.COM ', name: 'Person' },
currentUser: { id: 9 }
}, res, (error) => { routeError = error; });
assert.equal(routeError, null);
const insert = queries.find((query) => query.sql.includes('INSERT INTO a_user_invitations'));
assert.deepEqual(insert.args, ['person@example.com', 'Person', '[2]', 'hash:invite-token', 9]);
assert.equal(sentEmail.to, 'person@example.com');
assert.match(sentEmail.html, /accept-invite\?token=invite-token/);
assert.equal(res.redirectedTo, '/settings/users?message=Invitation%20sent.');
});
test('user invite route rejects invalid email before sending', async () => {
const handlers = {};
const app = {
get() {},
post(path, ...routeHandlers) { handlers[path] = routeHandlers; }
};
let renderedMessage = '';
const deps = {
pool: { async query() { return [[]]; } },
common: {
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {
renderUsersInvitePage(message) { renderedMessage = message; return ''; }
},
formatDashboardDate: () => '',
getAuditUserId: () => 1,
readArrayField: () => [],
rbacData: { fetchRoles: async () => [] },
requirePermission() {
return function (_req, _res, next) { next(); };
}
};
registerUsersRoutes(app, deps);
const res = { status() { return this; }, send() {} };
await handlers['/settings/users/invite'][1]({ body: { email: 'invalid', name: 'Person' }, currentUser: { id: 1 } }, res, () => {});
assert.equal(renderedMessage, 'Email address is invalid.');
});
+128 -5
View File
@@ -8,6 +8,8 @@ const { validatePasswordStrength } = require('../src/auth');
test('user password reset redirects back to the selected user edit page', async () => { test('user password reset redirects back to the selected user edit page', async () => {
const handlers = {}; const handlers = {};
let savedMustChangePassword = null;
let credentialPassword = null;
const app = { const app = {
post(path, ...routeHandlers) { post(path, ...routeHandlers) {
handlers[path] = routeHandlers; handlers[path] = routeHandlers;
@@ -17,11 +19,19 @@ test('user password reset redirects back to the selected user edit page', async
const deps = { const deps = {
pool: { pool: {
async query(sql) { async query(sql, params) {
if (sql.includes('SELECT id FROM a_users WHERE id = ? LIMIT 1')) { if (sql.includes('SELECT id, username, must_change_password FROM a_users WHERE id = ? LIMIT 1')) {
return [[{ id: 7 }]]; return [[{ id: 7, username: 'user', must_change_password: 0 }]];
} }
if (sql.includes('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?')) { if (sql.includes('SELECT id, created_at, modified_at FROM a_users')) {
return [[{ id: 7, created_at: new Date(), modified_at: new Date() }]];
}
if (sql.includes('INSERT INTO a_accounts')) {
credentialPassword = params[3];
return [{ affectedRows: 1 }];
}
if (sql.includes('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?')) {
savedMustChangePassword = params[0];
return [{ affectedRows: 1 }]; return [{ affectedRows: 1 }];
} }
if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) { if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) {
@@ -59,7 +69,7 @@ test('user password reset redirects back to the selected user edit page', async
const req = { const req = {
params: { id: '7' }, params: { id: '7' },
body: { password: 'NewPassword!1', confirm_password: 'NewPassword!1' }, body: { password: 'NewPassword!1', confirm_password: 'NewPassword!1', must_change_password: '1' },
currentUser: { id: 1 } currentUser: { id: 1 }
}; };
const res = { const res = {
@@ -77,4 +87,117 @@ test('user password reset redirects back to the selected user edit page', async
await routeHandlers[1](req, res, () => {}); await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/settings/users/7/edit?message=Password%20updated.'); assert.equal(res.redirectedTo, '/settings/users/7/edit?message=Password%20updated.');
assert.equal(savedMustChangePassword, 1);
assert.match(credentialPassword, /^[a-f0-9]{32}:[a-f0-9]{128}$/);
});
test('admin unlinks another user two-factor data and revokes their sessions', async () => {
const handlers = {};
const queries = [];
let committed = false;
let auditedEvent = null;
const app = {
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
get() {}
};
const connection = {
async beginTransaction() {},
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('SELECT id FROM a_users')) return [[{ id: 7 }]];
if (text.includes('SELECT id FROM a_user_two_factors')) return [[{ id: 'factor-7' }]];
return [{ affectedRows: 1 }];
},
async commit() { committed = true; },
async rollback() {},
release() {}
};
const deps = {
pool: {
async query() { return [[]]; },
async getConnection() { return connection; }
},
common: {},
pages: {},
formatDashboardDate: () => '',
getAuditUserId: () => 3,
recordRequestAuditEvent: async (_pool, _req, event) => { auditedEvent = event; },
validatePasswordStrength: () => '',
readArrayField: () => [],
rbacData: {
fetchRoles: async () => [],
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
fetchUserWithRoles: async () => null,
fetchActiveUserSessions: async () => [],
syncUserRoles: async () => {}
},
requirePermission() { return (_req, _res, next) => next(); }
};
registerUsersRoutes(app, deps);
const response = { redirect(url) { this.redirectedTo = url; }, status() { return this; }, send(value) { this.body = value; } };
await handlers['/settings/users/:id/two-factor/unlink'][1]({ params: { id: '7' }, currentUser: { id: 3 } }, response, error => { throw error; });
assert.equal(committed, true);
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_user_two_factors WHERE user_id = ?') && params[0] === 7));
assert.ok(queries.some(([sql, params]) => sql.includes('UPDATE a_users SET two_factor_enabled = 0') && params[1] === 7));
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_sessions WHERE user_id = ?') && params[0] === 7));
assert.equal(auditedEvent.eventType, 'user.two_factor_unlinked');
assert.equal(response.redirectedTo, '/settings/users/7/edit?message=Two-step%20verification%20removed.%20The%20user%20must%20sign%20in%20again.');
});
test('admin unlinks a passkey only from the selected user and revokes their sessions', async () => {
const handlers = {};
const queries = [];
let committed = false;
let auditedEvent = null;
const app = {
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
get() {}
};
const connection = {
async beginTransaction() {},
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('SELECT id FROM a_users')) return [[{ id: 7 }]];
if (text.includes('FROM a_user_passkeys')) return [[{ id: 'passkey-7' }]];
return [{ affectedRows: 1 }];
},
async commit() { committed = true; },
async rollback() {},
release() {}
};
const deps = {
pool: {
async query() { return [[]]; },
async getConnection() { return connection; }
},
common: {},
pages: {},
formatDashboardDate: () => '',
getAuditUserId: () => 3,
recordRequestAuditEvent: async (_pool, _req, event) => { auditedEvent = event; },
validatePasswordStrength: () => '',
readArrayField: () => [],
rbacData: {
fetchRoles: async () => [],
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
fetchUserWithRoles: async () => null,
fetchActiveUserSessions: async () => [],
syncUserRoles: async () => {}
},
requirePermission() { return (_req, _res, next) => next(); }
};
registerUsersRoutes(app, deps);
const response = { redirect(url) { this.redirectedTo = url; }, status() { return this; }, send(value) { this.body = value; } };
await handlers['/settings/users/:id/passkeys/:passkeyId/unlink'][1]({ params: { id: '7', passkeyId: 'passkey-7' }, currentUser: { id: 3 } }, response, error => { throw error; });
assert.equal(committed, true);
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_user_passkeys WHERE id = ? AND user_id = ?') && params[0] === 'passkey-7' && params[1] === 7));
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_sessions WHERE user_id = ?') && params[0] === 7));
assert.equal(auditedEvent.eventType, 'user.passkey_unlinked');
assert.equal(response.redirectedTo, '/settings/users/7/edit?message=Passkey%20removed.%20The%20user%20must%20sign%20in%20again.');
}); });
+3 -7
View File
@@ -16,16 +16,12 @@ test('application settings expose typed defaults', () => {
assert.equal(settings['uploads.image_max_bytes'], 100 * 1024 * 1024); assert.equal(settings['uploads.image_max_bytes'], 100 * 1024 * 1024);
assert.deepEqual(settings['uploads.allowed_mime_types'], ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'video/mp4', 'video/webm', 'video/ogg']); assert.deepEqual(settings['uploads.allowed_mime_types'], ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'video/mp4', 'video/webm', 'video/ogg']);
assert.equal(settings['uploads.optimize_images'], true); assert.equal(settings['uploads.optimize_images'], true);
assert.equal(settings['security.session_lifetime_days'], 14);
assert.equal(settings['security.allow_user_session_revocation'], true); assert.equal(settings['security.allow_user_session_revocation'], true);
assert.equal(settings['security.max_active_sessions'], 0);
assert.equal(settings['security.password_min_length'], 10); assert.equal(settings['security.password_min_length'], 10);
assert.equal(settings['security.password_min_categories'], 3); assert.equal(settings['security.password_min_categories'], 3);
assert.equal(settings['security.require_password_change_for_new_users'], true); assert.equal(settings['security.require_password_change_for_new_users'], undefined);
assert.equal(settings['security.require_password_change_after_admin_reset'], true); assert.equal(settings['security.require_password_change_after_admin_reset'], undefined);
assert.equal(settings['security.login_max_attempts'], 5); assert.equal(settings['security.allow_admin_email_verification_bypass'], true);
assert.equal(settings['security.login_lockout_minutes'], 15);
assert.equal(settings['security.login_rate_limit_scope'], 'both');
assert.equal(settings['data-sources.rss_default_interval_value'], 60); assert.equal(settings['data-sources.rss_default_interval_value'], 60);
assert.equal(settings['data-sources.rss_default_interval_unit'], 'minutes'); assert.equal(settings['data-sources.rss_default_interval_unit'], 'minutes');
assert.equal(settings['data-sources.api_default_interval_value'], 60); assert.equal(settings['data-sources.api_default_interval_value'], 60);
+21
View File
@@ -51,6 +51,22 @@ test('audit log uses its own read permission and shared pagination partial', asy
logoPath: { logoPath: {
from: '', from: '',
to: '/media/logo.png' to: '/media/logo.png'
},
'content.heading.value': {
from: 'Old heading',
to: 'New heading'
},
'content.added.value': {
from: null,
to: 'Added text'
},
'content.removed.value': {
from: 'Removed text',
to: null
},
'content.qr.qr_svg': {
from: null,
to: '[Generated asset: 13 bytes; SHA-256 fingerprint]'
} }
} }
}) })
@@ -108,6 +124,11 @@ test('audit log uses its own read permission and shared pagination partial', asy
assert.match(response.body, /playlists\.read/); assert.match(response.body, /playlists\.read/);
assert.match(response.body, /backgroundColor added/); assert.match(response.body, /backgroundColor added/);
assert.match(response.body, /logoPath added/); assert.match(response.body, /logoPath added/);
assert.match(response.body, /content\.heading\.value/);
assert.match(response.body, />New heading</);
assert.match(response.body, /content\.added\.value added/);
assert.match(response.body, /content\.removed\.value removed/);
assert.match(response.body, /Generated asset: 13 bytes; SHA-256 fingerprint/);
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed')); assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
assert.match(response.body, />Export</); assert.match(response.body, />Export</);
assert.ok(handlers['/settings/audit-log/export']); assert.ok(handlers['/settings/audit-log/export']);
+137 -1
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
require('../src/common'); require('../src/common');
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log'); const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, buildAuditChanges, buildSlideAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
const { getDefaultAppSettings } = require('../src/data/app-settings'); const { getDefaultAppSettings } = require('../src/data/app-settings');
function createPool(settings) { function createPool(settings) {
@@ -68,6 +68,20 @@ test('audit writer omits request metadata when disabled', async () => {
assert.equal(pool.inserts[0][7], null); assert.equal(pool.inserts[0][7], null);
}); });
test('audit writer skips empty update diffs for content but preserves action events and other details', async () => {
const categories = ['slides', 'templates', 'playlists', 'screens', 'canvas-sizes', 'weather'];
const pool = createPool({ enabled: true, categories, includeMetadata: false });
for (const category of categories) {
await recordRequestAuditEvent(pool, request, { category, eventType: 'content.updated', details: { changes: {} } });
}
assert.equal(pool.inserts.length, 0);
await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.created' });
await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.deleted' });
await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.updated', details: { changes: {}, promotedSlideId: 7 } });
await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.updated', details: { changes: { 'regions.heading.x': { from: 1, to: 2 } } } });
assert.equal(pool.inserts.length, 4);
});
test('audit changes include only fields with different from and to values', () => { test('audit changes include only fields with different from and to values', () => {
assert.deepEqual(buildAuditChanges({ assert.deepEqual(buildAuditChanges({
name: 'Old name', name: 'Old name',
@@ -82,3 +96,125 @@ test('audit changes include only fields with different from and to values', () =
roleIds: { from: [1, 2], to: [2, 3] } roleIds: { from: [1, 2], to: [2, 3] }
}); });
}); });
test('slide audit stores only changed leaf fields without unchanged content', () => {
const previous = { title: 'Slide', content: { heading: { value: 'Old', font_size: 32 }, qr: { qr_svg: '<svg>' + 'x'.repeat(100000) + '</svg>' } } };
const next = structuredClone(previous);
next.content.heading.value = 'New';
const changes = buildSlideAuditChanges(previous, next);
assert.deepEqual(changes, { 'content.heading.value': { from: 'Old', to: 'New' } });
assert.ok(JSON.stringify(changes).length < 100);
});
test('slide audit ignores unchanged saves and object key order', () => {
assert.deepEqual(buildSlideAuditChanges({ content: { body: { value: 'Same', size: 32 } } }, { content: { body: { size: 32, value: 'Same' } } }), {});
});
test('slide audit preserves added and removed fields across JSON serialization', () => {
const changes = buildSlideAuditChanges({ content: { removed: { value: 'Old' }, body: { muted: false } } }, { content: { added: { value: 'New' }, body: { muted: true } } });
assert.deepEqual(JSON.parse(JSON.stringify(changes)), {
'content.removed.value': { from: 'Old', to: null },
'content.body.muted': { from: false, to: true },
'content.added.value': { from: null, to: 'New' }
});
});
test('slide audit fingerprints generated QR payloads including added and removed assets', () => {
const previous = { content: { qr: { qr_svg: '<svg>' + 'x'.repeat(100000) + '</svg>', qr_preview: 'data:image/png;base64,' + 'y'.repeat(100000), value: 'https://example.com/old' } } };
const next = { content: { qr: { qr_svg: '<svg>new</svg>', value: 'https://example.com/new' } } };
const changes = buildSlideAuditChanges(previous, next);
assert.match(changes['content.qr.qr_svg'].from, /^\[Generated asset: 100011 bytes; SHA-256 [a-f0-9]{64}\]$/);
assert.notEqual(changes['content.qr.qr_svg'].from, changes['content.qr.qr_svg'].to);
assert.equal(changes['content.qr.qr_preview'].to, null);
assert.deepEqual(changes['content.qr.value'], { from: 'https://example.com/old', to: 'https://example.com/new' });
assert.ok(JSON.stringify(changes).length < 1000);
const added = buildSlideAuditChanges({}, next);
assert.equal(added['content.qr.qr_svg'].from, null);
assert.match(added['content.qr.qr_svg'].to, /SHA-256/);
assert.equal(previous.content.qr.qr_svg.length, 100011);
});
test('slide save skips no-op audit events while retaining save side effects', async () => {
const registerContentRoutes = require('../src/web/routes/admin/content');
for (const changed of [false, true]) {
const handlers = {};
const calls = [];
const events = [];
const content = { heading: { type: 'text', value: 'Old' } };
const nextContent = { heading: { type: 'text', value: changed ? 'New' : 'Old' } };
const noop = async () => {};
const connection = {
beginTransaction: noop,
async commit() { calls.push('commit'); },
rollback: noop,
release() {},
async query(sql) {
if (sql.startsWith('SELECT id FROM c_slides')) return [[{ id: 7 }]];
if (sql.startsWith('UPDATE c_slides')) calls.push('update');
return [[]];
}
};
registerContentRoutes({
get() {},
post(route, ...actions) { handlers[route] = actions; }
}, {
pool: { async query() { return [[]]; }, async getConnection() { return connection; } },
common: {
fetchSlideById: async () => ({ id: 7, title: 'Slide', template_id: 2, content }),
buildSlidePayload: async () => ({ title: 'Slide', templateId: 2, contentJson: JSON.stringify(nextContent) }),
fetchDuplicateName: async () => null,
parseJsonSafe: JSON.parse
},
pages: {}, upload: { any: () => noop, single: () => noop },
fetchScreensBySlideId: async () => [], fetchScreensByTemplateId: async () => [],
collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: async () => { calls.push('sync'); },
getAuditUserId: () => 9,
recordRequestAuditEvent: async (_pool, _req, event) => { events.push(event); },
redirectAfterSave: () => { calls.push('redirect'); },
notifyPlayerScreens: noop,
broadcastDashboardState: async () => { calls.push('broadcast'); },
backgroundTaskQueue: { enqueueTask: async () => { calls.push('thumbnail'); } },
getSlideDeleteBlockMessage: noop, getTemplateDeleteBlockMessage: noop, getCanvasSizeDeleteBlockMessage: noop,
hasAnyPermission: () => false, requirePermission: () => noop
});
await handlers['/slides/:id'].at(-1)({ params: { id: '7' }, files: [] }, {}, error => { throw error; });
assert.deepEqual(calls, ['update', 'commit', 'sync', 'broadcast', 'thumbnail', 'redirect']);
assert.equal(events.length, changed ? 1 : 0);
if (changed) {
assert.equal(events[0].eventType, 'slide.updated');
assert.deepEqual(events[0].details.changes, { 'content.heading.value': { from: 'Old', to: 'New' } });
}
}
});
test('shared audit changes compact nested content across categories', () => {
assert.deepEqual(buildAuditChanges({ settings: { text: 'Old', keep: 'Same' } }, { settings: { text: 'New', keep: 'Same' } }), {
'settings.text': { from: 'Old', to: 'New' }
});
});
test('template auditing matches regions by stable key and summarizes default QR assets', () => {
const heading = { region_key: 'heading', x: 10, default_content: { value: 'Same' } };
const qr = { region_key: 'qr', default_content: { qr_svg: '<svg>old</svg>' } };
assert.deepEqual(buildAuditChanges({ regions: [heading, qr] }, { regions: [qr, heading] }), {});
const changes = buildAuditChanges({ regions: [heading, qr] }, { regions: [qr, { ...heading, x: 20 }] });
assert.deepEqual(changes, { 'regions.heading.x': { from: 10, to: 20 } });
const assets = buildAuditChanges({ regions: [qr] }, { regions: [{ ...qr, default_content: { qr_svg: '<svg>new</svg>' } }] });
assert.match(assets['regions.qr.default_content.qr_svg'].from, /SHA-256/);
assert.match(assets['regions.qr.default_content.qr_svg'].to, /SHA-256/);
});
test('ordered content collections retain reordering and added or removed item differences', () => {
const first = { slideId: 1, duration: 10 };
const second = { slideId: 2, duration: 10 };
assert.deepEqual(buildAuditChanges({ items: [first, second] }, { items: [second, first] }), {
'items[0].slideId': { from: 1, to: 2 },
'items[1].slideId': { from: 2, to: 1 }
});
assert.deepEqual(buildAuditChanges({ items: [first, second] }, { items: [first] }), {
'items[1].slideId': { from: 2, to: null },
'items[1].duration': { from: 10, to: null }
});
});
+27
View File
@@ -0,0 +1,27 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createSyntheticAuthEmail, isSyntheticAuthEmail, toVisibleEmail, setCredentialPassword } = require('../src/data/auth-accounts');
test('credential password is stored directly on the Better Auth account', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push([String(sql), params]);
if (String(sql).includes('SELECT id, created_at, modified_at FROM a_users')) return [[{ id: 12, created_at: new Date(), modified_at: new Date() }]];
return [{ affectedRows: 1 }];
}
};
assert.equal(await setCredentialPassword(pool, 12, 'pbkdf2-sha256$310000$salt$hash'), true);
assert.ok(queries.some(([sql, params]) => sql.includes('INSERT INTO a_accounts') && params[3] === 'pbkdf2-sha256$310000$salt$hash'));
assert.equal(queries.some(([sql]) => sql.includes('auth_email') || sql.includes('email_verified_at')), false);
});
test('synthetic Better Auth emails stay hidden from app-facing views', () => {
const syntheticEmail = createSyntheticAuthEmail(13);
assert.match(syntheticEmail, /@identity\.invalid$/);
assert.equal(isSyntheticAuthEmail(syntheticEmail), true);
assert.equal(toVisibleEmail(syntheticEmail), '');
assert.equal(isSyntheticAuthEmail('person@example.com'), false);
assert.equal(toVisibleEmail('person@example.com'), 'person@example.com');
});
+99 -209
View File
@@ -8,13 +8,91 @@ const pages = require('../src/web/pages');
const renderLoginPage = require('../src/web/routes/auth/login'); const renderLoginPage = require('../src/web/routes/auth/login');
const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session'); const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session');
test('two-factor login enables device trust, submits it, and resets it on back', async () => {
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const nodes = {};
const requests = [];
let initialMessageRemoved = false;
let redirectedTo;
let submit;
const form = {
action: '/api/auth/sign-in/username',
elements: {
username: { value: 'demo' },
password: { value: 'password' },
remember_me: { checked: false },
trust_device: { disabled: true, checked: false },
two_factor_code: { disabled: true, value: '', focus() {} }
},
querySelector(selector) {
return nodes[selector] ||= { addEventListener(event, handler) { this[event] = handler; } };
},
addEventListener(event, handler) { submit = handler; }
};
const scriptPath = path.join(__dirname, '../src/web/public/js/auth/login.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), {
document: {
querySelector(selector) {
if (selector === '[data-better-auth-login]') return form;
if (selector === '[data-login-initial-message]' && !initialMessageRemoved) {
return { remove() { initialMessageRemoved = true; } };
}
return null;
}
},
window: { location: { search: '', assign(value) { redirectedTo = value; } } },
URLSearchParams,
async fetch(endpoint, options) {
assert.equal(initialMessageRemoved, true);
requests.push({ endpoint, payload: JSON.parse(options.body) });
return { ok: true, async json() { return requests.length === 1 ? { twoFactorRedirect: true, twoFactorMethods: ['totp'] } : {}; } };
}
});
await submit({ preventDefault() {} });
assert.equal(initialMessageRemoved, true);
assert.equal(form.elements.trust_device.disabled, false);
assert.equal(nodes['[data-login-feedback]'].hidden, true);
assert.equal(redirectedTo, undefined);
form.elements.trust_device.checked = true;
form.elements.two_factor_code.value = '123456';
await submit({ preventDefault() {} });
assert.equal(requests[1].endpoint, '/api/auth/two-factor/verify-totp');
assert.equal(requests[1].payload.trustDevice, true);
assert.equal(redirectedTo, '/');
nodes['[data-two-factor-back]'].click();
assert.equal(form.elements.trust_device.disabled, true);
assert.equal(form.elements.trust_device.checked, false);
});
test('login page preserves the return target in a hidden field', () => { test('login page preserves the return target in a hidden field', () => {
const html = renderLoginPage('', '/slides/6/edit', 'demo'); const html = renderLoginPage('', '/slides/6/edit', 'demo');
assert.match(html, /name="returnTo"/); assert.match(html, /name="returnTo"/);
assert.match(html, /value="\/slides\/6\/edit"/); assert.match(html, /value="\/slides\/6\/edit"/);
assert.match(html, /name="remember_me"/); assert.match(html, /name="remember_me"/);
assert.match(html, /action="\/api\/auth\/sign-in\/username"/);
assert.match(html, /js\/auth\/login\.js/);
assert.match(html, /name="username"[^>]*value="demo"/); assert.match(html, /name="username"[^>]*value="demo"/);
assert.match(html, /data-login-two-factor/);
assert.match(html, /name="two_factor_code"/);
assert.match(html, /data-two-factor-toggle-method/);
assert.doesNotMatch(html, /Sign in to start your session/);
assert.match(html, /<label for="username" class="visually-hidden">Username or email<\/label>/);
assert.match(html, /<label for="password" class="visually-hidden">Password<\/label>/);
assert.match(html, /class="btn btn-secondary w-100" data-passkey-login/);
assert.doesNotMatch(html, /class="btn btn-outline-secondary w-100" data-passkey-login/);
assert.match(html, /data-passkey-login/);
assert.match(html, /js\/auth\/passkey-client\.js/);
});
test('login messages render inline without toast markup or scripts', () => {
const html = renderLoginPage('You have been signed out.');
assert.match(html, /<div class="alert alert-warning py-2" data-login-initial-message>You have been signed out\.<\/div>/);
assert.doesNotMatch(html, /app-toast|js\/shared\/ui\/toast\.js/);
}); });
test('requireAuth preserves the original request path when redirecting to login', () => { test('requireAuth preserves the original request path when redirecting to login', () => {
@@ -46,7 +124,7 @@ test('requireAuth preserves the original request path when redirecting to login'
assert.equal(res.redirectedTo, '/login?returnTo=%2Fslides%2F6%2Fedit%3Ftab%3Dcontent'); assert.equal(res.redirectedTo, '/login?returnTo=%2Fslides%2F6%2Fedit%3Ftab%3Dcontent');
}); });
test('session loader keeps the administrator identity while loading preview-user permissions', async () => { test('session loader keeps the administrator identity and aggregates role two-factor requirements', async () => {
const service = createSessionService({ const service = createSessionService({
sessionCookieName: 'pulse_session', sessionCookieName: 'pulse_session',
sessionMaxAgeMs: 60 * 60 * 1000, sessionMaxAgeMs: 60 * 60 * 1000,
@@ -71,7 +149,7 @@ test('session loader keeps the administrator identity while loading preview-user
}]]; }]];
} }
if (sql.includes('SELECT r.role_key')) { if (sql.includes('SELECT r.role_key')) {
return [[{ role_key: Number(params[0]) === 7 ? 'super-admin' : 'operator' }]]; return [[{ role_key: Number(params[0]) === 7 ? 'super-admin' : 'operator', require_two_factor: 0 }, { role_key: 'secure', require_two_factor: Number(params[0]) === 7 ? 1 : 0 }]];
} }
if (sql.includes('SELECT p.permission_key')) { if (sql.includes('SELECT p.permission_key')) {
return [[ return [[
@@ -91,6 +169,8 @@ test('session loader keeps the administrator identity while loading preview-user
assert.deepEqual(currentUser.permissionKeys, ['screens.read']); assert.deepEqual(currentUser.permissionKeys, ['screens.read']);
assert.equal(currentUser.isViewingAs, true); assert.equal(currentUser.isViewingAs, true);
assert.equal(currentUser.authenticatedUser.id, 7); assert.equal(currentUser.authenticatedUser.id, 7);
assert.equal(currentUser.requiresTwoFactor, false);
assert.equal(currentUser.authenticatedUser.requiresTwoFactor, true);
assert.deepEqual(currentUser.authenticatedUser.permissionKeys, ['impersonate.allow']); assert.deepEqual(currentUser.authenticatedUser.permissionKeys, ['impersonate.allow']);
}); });
@@ -151,11 +231,12 @@ test('view-as session helpers bind changes to the authenticated session', async
} }
}; };
const req = { headers: { cookie: 'pulse_session=token' } }; const req = { headers: { cookie: 'pulse_session=token' } };
req.betterAuthSession = { session: { token: 'token' } };
assert.equal(await service.startViewAsUser(pool, req, 7, 19), true); assert.equal(await service.startViewAsUser(pool, req, 7, 19), true);
assert.equal(await service.stopViewAsUser(pool, req, 7), true); assert.equal(await service.stopViewAsUser(pool, req, 7), true);
assert.deepEqual(queries[0][1], [19, 7, 'hash:token', 7]); assert.deepEqual(queries[0][1], [19, 7, 'token', 7]);
assert.deepEqual(queries[1][1], [7, 'hash:token', 7]); assert.deepEqual(queries[1][1], [7, 'token', 7]);
}); });
test('logout audit records the authenticated administrator during a preview', async () => { test('logout audit records the authenticated administrator during a preview', async () => {
@@ -231,7 +312,7 @@ test('no-access page explains the missing permissions and offers account and sig
assert.match(html, /class="btn btn-primary" href="\/logout"/); assert.match(html, /class="btn btn-primary" href="\/logout"/);
}); });
test('successful login returns to the requested link instead of the dashboard', async () => { test('custom login POST is removed in favor of Better Auth', () => {
const handlers = {}; const handlers = {};
const app = { const app = {
get(path, ...routeHandlers) { get(path, ...routeHandlers) {
@@ -243,218 +324,27 @@ test('successful login returns to the requested link instead of the dashboard',
}; };
registerAuthRoutes(app, { registerAuthRoutes(app, {
pool: { pool: { async query() { return [[]]; } },
async query(sql) { pages: {},
if (sql.includes('FROM a_users WHERE username = ? LIMIT 1')) {
return [[{ id: 7, name: 'Demo User', username: 'demo', password_hash: 'hash', password_salt: 'salt', password_iterations: 1 }]];
}
return [[]];
}
},
pages: {
renderLoginPage(message, returnTo) {
return { message, returnTo };
}
},
createUserSession: async () => 'session-token',
getSessionMaxAgeMs: async () => 7 * 24 * 60 * 60 * 1000,
setSessionCookie(res, token, maxAgeMs) {
res.sessionToken = token;
res.sessionMaxAgeMs = maxAgeMs;
},
clearSessionCookie() {}, clearSessionCookie() {},
parseCookies() { parseCookies() { return {}; },
return {}; consumeAuthMessageCookie() { return ''; },
},
consumeAuthMessageCookie() {
return '';
},
setAuthMessageCookie() {}, setAuthMessageCookie() {},
hashSessionToken(value) { hashSessionToken(value) { return value; },
return value;
},
verifyPassword() {
return true;
},
sessionCookieName: 'pulse_session' sessionCookieName: 'pulse_session'
}); });
const req = { assert.equal(handlers['POST /login'], undefined);
body: {
username: 'demo',
password: 'secret',
returnTo: '/slides/6/edit?tab=content'
},
query: {
returnTo: '/slides/6/edit?tab=content'
}
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send(value) {
this.sentValue = value;
return this;
}
};
await handlers['POST /login'][0](req, res, () => {});
assert.equal(res.sessionToken, 'session-token');
assert.equal(res.sessionMaxAgeMs, null);
assert.equal(res.redirectedTo, '/slides/6/edit?tab=content');
const rememberedRequest = {
body: {
username: 'demo',
password: 'secret',
remember_me: '1'
},
query: {}
};
const rememberedResponse = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send(value) {
this.sentValue = value;
return this;
}
};
await handlers['POST /login'][0](rememberedRequest, rememberedResponse, () => {});
assert.equal(rememberedResponse.sessionMaxAgeMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(rememberedResponse.redirectedTo, '/');
}); });
test('accept invite page renders a valid invitation with password requirements', async () => { test('invitation acceptance endpoints are not registered', () => {
const handlers = {}; const routes = [];
let rendered = null;
const app = { const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; }, get(path) { routes.push('GET ' + path); },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; } post(path) { routes.push('POST ' + path); }
}; };
registerAuthRoutes(app, { registerAuthRoutes(app, {});
pool: {
async query(sql) {
if (sql.includes('a_user_invitations')) return [[{ email: 'person@example.com', name: 'Person' }]];
return [[{ setting_key: 'security.password_min_length', setting_value: '12' }]];
}
},
pages: {
renderAcceptInvitePage(...args) { rendered = args; return 'invite form'; }
},
hashSessionToken: (value) => 'hash:' + value
});
const res = { send(value) { this.body = value; }, status() { return this; } }; assert.equal(routes.includes('GET /accept-invite'), false);
await handlers['GET /accept-invite'][0]({ query: { token: 'invite-token' } }, res, (error) => { throw error; }); assert.equal(routes.includes('POST /accept-invite'), false);
assert.equal(res.body, 'invite form');
assert.equal(rendered[0], '');
assert.equal(rendered[1], 'invite-token');
assert.equal(rendered[2], 'person@example.com');
assert.equal(rendered[3], 'Person');
assert.equal(rendered[4].minimumLength, 12);
assert.equal(rendered[5], true);
});
test('accept invite creates the account, assigns roles, and consumes the invitation', async () => {
const handlers = {};
const queries = [];
let committed = false;
let syncedRoles = null;
const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
const connection = {
async query(sql, args) {
queries.push({ sql, args });
if (sql.includes('a_user_invitations')) return [[{ id: 8, email: 'person@example.com', name: 'Invited name', role_ids_json: '[2, 3]' }]];
if (sql.includes('SELECT id FROM a_users')) return [[]];
if (sql.includes('INSERT INTO a_users')) return [{ insertId: 21 }];
return [[]];
},
async beginTransaction() {},
async commit() { committed = true; },
async rollback() {},
release() {}
};
registerAuthRoutes(app, {
pool: {
async query() { return [[]]; },
async getConnection() { return connection; }
},
pages: {
renderAcceptInvitePage() { return ''; }
},
getConnection: async () => connection,
hashSessionToken: (value) => 'hash:' + value,
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1000 }),
validatePasswordStrength: () => '',
rbacData: {
async syncUserRoles(_connection, userId, roleIds) { syncedRoles = { userId, roleIds }; }
}
});
const req = {
body: {
token: 'invite-token',
username: 'person',
name: 'Person',
password: 'ValidPass!1',
confirm_password: 'ValidPass!1'
}
};
const res = {
redirect(url) { this.redirectedTo = url; },
status() { return this; },
send() { return this; }
};
await handlers['POST /accept-invite'][0](req, res, (error) => { throw error; });
const insert = queries.find((query) => query.sql.includes('INSERT INTO a_users'));
const consumed = queries.find((query) => query.sql.includes('UPDATE a_user_invitations'));
assert.deepEqual(insert.args, ['Person', 'person', 'person@example.com', 'hash', 'salt', 1000]);
assert.deepEqual(syncedRoles, { userId: 21, roleIds: [2, 3] });
assert.deepEqual(consumed.args, [8]);
assert.equal(committed, true);
assert.equal(res.redirectedTo, '/login?message=Account%20created.%20You%20can%20now%20sign%20in.');
});
test('accept invite rejects an invalid token without creating an account', async () => {
const handlers = {};
let renderedMessage = '';
const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
const connection = {
async query(sql) {
if (sql.includes('a_user_invitations')) return [[]];
return [[]];
},
async rollback() {},
release() {}
};
registerAuthRoutes(app, {
pool: { async query() { return [[]]; } },
pages: { renderAcceptInvitePage(message) { renderedMessage = message; return ''; } },
getConnection: async () => connection,
hashSessionToken: (value) => 'hash:' + value
});
const res = { status() { return this; }, send() {} };
await handlers['POST /accept-invite'][0]({ body: { token: 'expired-token' } }, res, (error) => { throw error; });
assert.equal(renderedMessage, 'This invitation is invalid or has expired.');
}); });
+142
View File
@@ -0,0 +1,142 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { hashPassword, encodePasswordRecord, verifyEncodedPassword, hashScryptPassword, verifyCredentialPassword } = require('../src/auth');
const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError } = require('../src/web/lib/auth/better-auth');
test('passkey RP ID uses localhost for local HTTP-IP configuration and the canonical hostname otherwise', () => {
assert.equal(resolvePasskeyRpID('http://192.168.0.80:8080'), 'localhost');
assert.equal(resolvePasskeyRpID('http://localhost:8080'), 'localhost');
assert.equal(resolvePasskeyRpID('https://signage.example.com'), 'signage.example.com');
});
test('passkey authentication is blocked for accounts with individual or role-required two-factor authentication', async () => {
const queries = [];
let account = { two_factor_enabled: 1, role_requires_two_factor: 0 };
class TestAPIError extends Error {
constructor(code, details) { super(details.message); this.code = code; }
}
const guard = createPasskeyAuthenticationGuard({
async query(sql, params) {
queries.push([String(sql), params]);
return [[account]];
}
}, TestAPIError);
await assert.rejects(guard({ clientData: { id: 'credential-id' } }), error => error.code === 'FORBIDDEN');
account = { two_factor_enabled: 0, role_requires_two_factor: 1 };
await assert.rejects(guard({ clientData: { id: 'credential-id' } }), error => error.code === 'FORBIDDEN');
account = { two_factor_enabled: 0, role_requires_two_factor: 0 };
await guard({ clientData: { id: 'credential-id' } });
assert.deepEqual(queries[0][1], ['credential-id']);
assert.match(queries[0][0], /r\.require_two_factor = 1/);
assert.equal(queries.length, 3);
});
test('required two-factor policy blocks disabling and restricts pending enrollment auth endpoints', () => {
const actor = { requiresTwoFactor: true, twoFactorEnabled: false };
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/two-factor/disable').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
assert.equal(getRoleTwoFactorAuthError({ ...actor, twoFactorEnabled: true }, '/api/auth/two-factor/disable/').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/change-email').code, 'TWO_FACTOR_ENROLLMENT_REQUIRED');
for (const endpoint of ['two-factor/enable', 'two-factor/get-totp-uri', 'two-factor/verify-totp', 'two-factor/generate-backup-codes', 'get-session', 'sign-out', 'verify-password']) {
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/' + endpoint), null);
}
assert.equal(getRoleTwoFactorAuthError(null, '/api/auth/sign-in/username'), null);
assert.equal(getRoleTwoFactorAuthError({ requiresTwoFactor: false }, '/api/auth/two-factor/disable'), null);
assert.equal(getRoleTwoFactorAuthError({ ...actor, twoFactorEnabled: true }, '/api/auth/change-email'), null);
assert.equal(getRoleTwoFactorAuthError({ requiresTwoFactor: false, authenticatedUser: actor }, '/api/auth/two-factor/disable').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
});
test('successful sign-in clears only the client login and two-factor rate limits', async () => {
const queries = [];
const hook = createLoginRateLimitResetHook({ async query(sql, params) { queries.push({ sql, params }); } });
const paths = ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code'];
for (const path of paths) {
await hook({ path, context: { newSession: { session: { ipAddress: '192.0.2.1' } } } });
}
assert.equal(queries.length, paths.length);
for (const query of queries) {
assert.equal(query.sql, 'DELETE FROM a_login_attempts WHERE rate_key IN (?, ?, ?, ?)');
assert.deepEqual(query.params, paths.map(path => '192.0.2.1|' + path));
}
});
test('failed login, pending two-factor, and unrelated endpoints do not clear rate limits', async () => {
const hook = createLoginRateLimitResetHook({ async query() { assert.fail('Rate limits must remain intact'); } });
await hook({ path: '/sign-in/username', context: { newSession: null } });
await hook({ path: '/two-factor/verify-totp', context: { newSession: null } });
await hook({ path: '/email-otp/reset-password', context: { newSession: { session: { ipAddress: '192.0.2.1' } } } });
});
test('Better Auth password adapter verifies existing PBKDF2 records', () => {
const password = 'Strong-password-123!';
const encoded = encodePasswordRecord(hashPassword(password));
assert.equal(verifyEncodedPassword(password, encoded), true);
assert.equal(verifyEncodedPassword('incorrect-password', encoded), false);
});
test('Better Auth password adapter rejects unknown stored hash formats', () => {
assert.equal(verifyEncodedPassword('password', 'unknown$format'), false);
assert.equal(verifyEncodedPassword('password', 'app-user:7'), false);
});
test('Better Auth upgrades valid PBKDF2 passwords to its native scrypt hash on login', async () => {
const password = 'Strong-password-123!';
const legacyHash = encodePasswordRecord(hashPassword(password));
const updates = [];
const crypto = await import('better-auth/crypto');
const callbacks = createPasswordCallbacks({
async query(sql, params) {
updates.push([String(sql), params]);
return [{ affectedRows: 1 }];
}
}, crypto);
assert.equal(await callbacks.verify({ password: password, hash: legacyHash }), true);
assert.equal(updates.length, 1);
assert.match(updates[0][1][0], /^[a-f0-9]{32}:[a-f0-9]{128}$/);
assert.deepEqual(updates[0][1].slice(1), ['credential', legacyHash]);
assert.equal(await crypto.verifyPassword({ hash: updates[0][1][0], password: password }), true);
});
test('Better Auth does not upgrade an incorrect legacy password', async () => {
let updateCount = 0;
const crypto = await import('better-auth/crypto');
const callbacks = createPasswordCallbacks({
async query() { updateCount += 1; return [{ affectedRows: 1 }]; }
}, crypto);
const legacyHash = encodePasswordRecord(hashPassword('Correct-password-123!'));
assert.equal(await callbacks.verify({ password: 'incorrect-password', hash: legacyHash }), false);
assert.equal(updateCount, 0);
});
test('shared auth utility verifies Better Auth native scrypt hashes', async () => {
const password = 'Strong-password-123!';
const hash = await hashScryptPassword(password);
const crypto = await import('better-auth/crypto');
assert.equal(await crypto.verifyPassword({ hash: hash, password: password }), true);
assert.equal(await verifyCredentialPassword(password, hash), true);
assert.equal(await verifyCredentialPassword('incorrect-password', hash), false);
});
test('Better Auth session hook blocks locked users for every sign-in method', async () => {
let accountLocked = true;
const pool = {
async query(_sql, params) {
assert.deepEqual(params, [7]);
return [[{ account_locked: accountLocked }]];
}
};
class TestAPIError extends Error {
constructor(_status, body) {
super(body.message);
}
}
const lockHook = createAccountLockSessionHook(pool, TestAPIError);
await assert.rejects(lockHook({ userId: 7 }), /account is locked/i);
accountLocked = false;
await assert.doesNotReject(lockHook({ userId: 7 }));
});
+137
View File
@@ -0,0 +1,137 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerAuthRoutes = require('../src/web/routes/auth');
const registerAccountRoutes = require('../src/web/routes/admin/account');
function createApp() {
const handlers = {};
return {
handlers: handlers,
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
}
function settingsQuery(sql) {
return String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings');
}
test('forgot password requests a Better Auth OTP for a verified account', async () => {
let requestedEmail = '';
let renderedResetPage = null;
const app = createApp();
const pool = {
async query(sql) {
if (settingsQuery(sql)) return [[{ setting_key: 'email.smtp_enabled', setting_value: true }]];
if (String(sql).includes('SELECT id, email, email_verified FROM a_users')) {
return [[{ id: 9, email: 'person@example.test', email_verified: true }]];
}
return [[]];
}
};
registerAuthRoutes(app, {
pool: pool,
pages: {
renderResetPasswordPage(message, email) {
renderedResetPage = { message: message, email: email };
return 'reset-page';
}
},
betterAuth: {
api: {
async requestPasswordResetEmailOTP({ body }) { requestedEmail = body.email; }
}
},
parseCookies() { return {}; },
hashSessionToken(value) { return value; },
sessionCookieName: 'session'
});
const response = { send(value) { this.body = value; return this; } };
await app.handlers['POST /forgot-password'][0]({ body: { identity: 'person' } }, response, (error) => { throw error; });
assert.equal(requestedEmail, 'person@example.test');
assert.equal(response.body, 'reset-page');
assert.equal(renderedResetPage.email, '');
assert.match(renderedResetPage.message, /reset code/);
});
test('password reset verifies the OTP through Better Auth', async () => {
let resetBody = null;
const app = createApp();
const pool = { async query(sql) { return settingsQuery(sql) ? [[]] : [[]]; } };
registerAuthRoutes(app, {
pool: pool,
pages: {
renderResetPasswordPage(message, email) { return message + ':' + email; },
renderForgotPasswordPage(message) { return message; }
},
betterAuth: {
api: {
async resetPasswordEmailOTP({ body }) { resetBody = body; }
}
},
validatePasswordStrength() { return ''; },
parseCookies() { return {}; },
hashSessionToken(value) { return value; },
sessionCookieName: 'session'
});
const response = { send(value) { this.body = value; return this; } };
await app.handlers['POST /reset-password'][0]({
body: { email: 'person@example.test', otp: '123456', password: 'NewPassword!123', confirm_password: 'NewPassword!123' }
}, response, (error) => { throw error; });
assert.deepEqual(resetBody, { email: 'person@example.test', otp: '123456', password: 'NewPassword!123' });
assert.match(response.body, /Password updated/);
});
test('pending email change verifies through Better Auth and syncs the app email', async () => {
const calls = [];
const app = createApp();
const pool = {
async query(sql, params) {
calls.push([String(sql), params]);
const text = String(sql);
if (settingsQuery(text)) return [[{ setting_key: 'email.smtp_enabled', setting_value: true }]];
if (text.includes('SELECT email, email_verified, pending_email FROM a_users')) {
return [[{ email: 'old@example.test', pending_email: 'new@example.test' }]];
}
if (text.includes('SELECT id, created_at, modified_at FROM a_users')) {
return [[{ id: 7, created_at: new Date(), modified_at: new Date() }]];
}
return [{ affectedRows: 1, insertId: 1 }];
}
};
let verifiedBody = null;
registerAccountRoutes(app, {
pool: pool,
common: {},
pages: {},
betterAuth: {
api: {
async changeEmailEmailOTP({ body, headers }) {
verifiedBody = { body: body, headers: headers };
}
}
},
fromNodeHeaders(headers) { return { cookie: headers.cookie }; },
parseCookies() { return {}; },
sessionCookieName: 'session'
});
const response = { redirect(url) { this.redirectedTo = url; } };
await app.handlers['POST /account/email/verify'][0]({
body: { otp: '654321' },
headers: { cookie: 'session=token' },
currentUser: { id: 7 }
}, response, (error) => { throw error; });
assert.deepEqual(verifiedBody, { body: { newEmail: 'new@example.test', otp: '654321' }, headers: { cookie: 'session=token' } });
assert.ok(calls.some(([sql, params]) => sql.includes('UPDATE a_users SET email = ?, email_verified = 1, pending_email = NULL') && params[0] === 'new@example.test'));
assert.match(response.redirectedTo, /Email%20address%20verified/);
});
+2 -4
View File
@@ -12,8 +12,7 @@ test('player control sync sends minimized authorization data to remote players o
async query() { async query() {
return [[{ return [[{
username: 'operator', username: 'operator',
password_hash: 'hash', password_hash: 'pbkdf2-sha256$310000$salt$hash'
password_salt: 'salt'
}]]; }]];
} }
}, },
@@ -44,8 +43,7 @@ test('player control sync sends minimized authorization data to remote players o
command: 'sync-local-control', command: 'sync-local-control',
users: [{ users: [{
username_hash: crypto.createHash('sha256').update('operator').digest('hex'), username_hash: crypto.createHash('sha256').update('operator').digest('hex'),
password_hash: 'hash', password_hash: 'pbkdf2-sha256$310000$salt$hash'
password_salt: 'salt'
}] }]
} }
}]); }]);
+11 -16
View File
@@ -9,7 +9,7 @@ const { WebSocket } = require('ws');
require('../src/common'); require('../src/common');
const { hashPassword } = require('../src/auth'); const { hashScryptPassword } = require('../src/auth');
const { createLocalControlService } = require('../src/player/local-control'); const { createLocalControlService } = require('../src/player/local-control');
async function createTestService(options) { async function createTestService(options) {
@@ -52,15 +52,13 @@ test('local control leaves cached player media available to the static media rou
test('local control authenticates only cached eligible users and scopes commands to local runtime', async () => { test('local control authenticates only cached eligible users and scopes commands to local runtime', async () => {
const { app, service, temporaryDirectory } = await createTestService(); const { app, service, temporaryDirectory } = await createTestService();
const password = hashPassword('CorrectHorseBatteryStaple!'); const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
await service.saveUsers([{ await service.saveUsers([{
id: 7, id: 7,
name: 'Operator', name: 'Operator',
username: 'operator', username: 'operator',
email: 'operator@example.com', email: 'operator@example.com',
password_hash: password.hash, password_hash: password
password_salt: password.salt,
password_iterations: password.iterations
}]); }]);
await service.loadCache(); await service.loadCache();
@@ -69,7 +67,8 @@ test('local control authenticates only cached eligible users and scopes commands
assert.equal(cachedUsers[0].name, undefined); assert.equal(cachedUsers[0].name, undefined);
assert.equal(cachedUsers[0].email, undefined); assert.equal(cachedUsers[0].email, undefined);
assert.equal(cachedUsers[0].username, undefined); assert.equal(cachedUsers[0].username, undefined);
assert.equal(cachedUsers[0].password_iterations, undefined); assert.equal(cachedUsers[0].password_hash, password);
assert.equal(cachedUsers[0].password_salt, undefined);
const server = app.listen(0); const server = app.listen(0);
try { try {
const address = server.address(); const address = server.address();
@@ -126,14 +125,12 @@ test('local control authenticates only cached eligible users and scopes commands
test('local control refuses login when the cached authorization is stale', async () => { test('local control refuses login when the cached authorization is stale', async () => {
const { app, service, temporaryDirectory } = await createTestService({ cacheMaxAgeMs: 1 }); const { app, service, temporaryDirectory } = await createTestService({ cacheMaxAgeMs: 1 });
const password = hashPassword('CorrectHorseBatteryStaple!'); const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
await service.saveUsers([{ await service.saveUsers([{
id: 7, id: 7,
name: 'Operator', name: 'Operator',
username: 'operator', username: 'operator',
password_hash: password.hash, password_hash: password
password_salt: password.salt,
password_iterations: password.iterations
}]); }]);
await service.loadCache(); await service.loadCache();
await new Promise(function (resolve) { setTimeout(resolve, 5); }); await new Promise(function (resolve) { setTimeout(resolve, 5); });
@@ -155,11 +152,10 @@ test('local control refuses login when the cached authorization is stale', async
test('local control throttles repeated failed logins', async () => { test('local control throttles repeated failed logins', async () => {
const { app, service, temporaryDirectory } = await createTestService({ loginRateLimitMaxAttempts: 2, loginRateLimitWindowMs: 60 * 1000 }); const { app, service, temporaryDirectory } = await createTestService({ loginRateLimitMaxAttempts: 2, loginRateLimitWindowMs: 60 * 1000 });
const password = hashPassword('CorrectHorseBatteryStaple!'); const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
await service.saveUsers([{ await service.saveUsers([{
username: 'operator', username: 'operator',
password_hash: password.hash, password_hash: password
password_salt: password.salt
}]); }]);
await service.loadCache(); await service.loadCache();
@@ -186,11 +182,10 @@ test('local control throttles repeated failed logins', async () => {
test('local control closes live sockets when authorization is revoked', async () => { test('local control closes live sockets when authorization is revoked', async () => {
const { server, service, temporaryDirectory } = await createTestService(); const { server, service, temporaryDirectory } = await createTestService();
const password = hashPassword('CorrectHorseBatteryStaple!'); const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
await service.saveUsers([{ await service.saveUsers([{
username: 'operator', username: 'operator',
password_hash: password.hash, password_hash: password
password_salt: password.salt
}]); }]);
await service.loadCache(); await service.loadCache();
+62
View File
@@ -198,3 +198,65 @@ test('playlist create and update redirect to the canonical edit path', async ()
assert.equal(invalidRes.statusCode, 400); assert.equal(invalidRes.statusCode, 400);
} }
}); });
test('playlist audit compares persisted items and schedules without recreated row IDs', async () => {
let items = [{ id: 101, slide_id: 11, duration_seconds: 10 }];
let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
const events = [];
const connection = {
async commit() {}, async rollback() {}, release() {},
async query(sql, params) {
if (sql.includes('FROM c_playlist_slides WHERE playlist_id') && sql.startsWith('SELECT id, slide_id')) return [structuredClone(items)];
if (sql.includes('FROM c_playlist_slide_schedule_rules r')) return [structuredClone(rules)];
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }]];
if (sql.startsWith('DELETE FROM c_playlist_slides')) { items = []; rules = []; }
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
items = [{ id: 202, slide_id: params[1], duration_seconds: params[3] }];
return [{ insertId: 202 }];
}
if (sql.startsWith('INSERT INTO c_playlist_slide_schedule_rules')) {
rules.push({ playlist_slide_id: params[0], start_time: params[4] + ':00', end_time: params[5] + ':00', schedule_days_json: params[6] });
}
return [[]];
}
};
const { app, handlers } = createAppAndHandlers();
registerPlaylistRoutes(app, {
pool: { async getConnection() { return connection; } },
common: {
async fetchDuplicateName() { return null; },
async fetchPlaylistById() { return { id: 4, name: 'Loop', canvas_id: 7, fade_between_slides: 0, skip_unavailable_rtmp: 0 }; }
},
readArrayField(body, keys) {
for (const key of keys) {
if (Object.hasOwn(body, key)) return Array.isArray(body[key]) ? body[key] : [body[key]];
}
return [];
},
fetchScreensByPlaylistId: async () => [], getAuditUserId: () => 12,
recordRequestAuditEvent: async (_pool, _req, event) => { events.push(event); },
redirectAfterSave() {}, notifyPlayerScreens: async () => {}, broadcastDashboardState: async () => {},
requirePermission: () => () => {}
});
const body = {
name: 'Loop', slide_id: ['11'], row_key: ['row-11'], duration_seconds: ['10'],
schedule_rule_row_key: ['row-11'], schedule_rule_start_time: ['08:00'], schedule_rule_end_time: ['17:00'], schedule_rule_days_csv: ['1,2']
};
const save = async () => {
const response = createResponse();
await handlers['/playlists/:id'].at(-1)({ params: { id: '4' }, body }, response, error => { throw error; });
assert.equal(response.statusCode, 200);
return events.at(-1).details.changes;
};
assert.deepEqual(await save(), {});
body.duration_seconds = ['20'];
assert.deepEqual(await save(), { 'items[0].duration_seconds': { from: 10, to: 20 } });
body.schedule_rule_start_time = ['09:00'];
assert.deepEqual(await save(), { 'items[0].scheduleRules[0].start_time': { from: '08:00:00', to: '09:00:00' } });
body.slide_id = [];
body.row_key = [];
body.duration_seconds = [];
const removed = await save();
assert.deepEqual(removed['items[0].slide_id'], { from: 11, to: null });
assert.deepEqual(removed['items[0].scheduleRules[0].start_time'], { from: '09:00:00', to: null });
});
+71
View File
@@ -8,6 +8,77 @@ const rbacRoutesSource = fs.readFileSync(require.resolve('../src/web/routes/admi
const { buildPermissionSections, buildRbacEditViewModel } = require('../src/web/routes/settings/rbac/form-view-model'); const { buildPermissionSections, buildRbacEditViewModel } = require('../src/web/routes/settings/rbac/form-view-model');
const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8'); const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8');
const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8'); const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8');
for (const create of [true, false]) {
for (const enabled of [true, false]) {
test((create ? 'create' : 'update') + ' role persists two-factor requirement ' + enabled, async () => {
require('../src/common');
const registerRbacRoutes = require('../src/web/routes/admin/rbac');
const handlers = {};
const writes = [];
let auditEvent;
const connection = {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async query(sql, params) { writes.push({ sql, params }); return [{ insertId: 9 }]; }
};
registerRbacRoutes({
get() {},
post(path, ...routeHandlers) { handlers[path] = routeHandlers; }
}, {
pool: { async query() { return [[]]; }, async getConnection() { return connection; } },
common: {
validateMaxLength: value => value,
getSearchQuery: () => '',
getSortQuery: () => '',
getSortDirectionQuery: () => '',
fetchDuplicateName: async () => null
},
pages: {},
requirePermission: () => (_req, _res, next) => next(),
getAuditUserId: () => 3,
recordRequestAuditEvent: async (_pool, _req, event) => { auditEvent = event; },
readArrayField: () => [],
normalizePermissionKeys: value => value,
permissions: [],
rbacData: {
fetchRoleById: async () => ({ id: 9, name: 'Secure', description: '', require_two_factor: enabled ? 0 : 1 }),
fetchPermissions: async () => [],
fetchUsersWithRolesPage: async () => ({ users: [], currentPage: 1, totalItems: 0 }),
fetchUsersWithRoles: async () => [],
syncRoleUsers: async () => {}
}
});
const response = { redirect(url) { this.redirectedTo = url; } };
await handlers[create ? '/settings/roles' : '/settings/roles/:id'][1]({
params: { id: '9' }, query: {}, currentUser: { id: 3 },
body: { name: 'Secure', description: '', ...(enabled ? { require_two_factor: '1' } : {}) }
}, response, error => { throw error; });
assert.equal(writes.length, 1);
assert.match(writes[0].sql, /require_two_factor/);
assert.equal(writes[0].params[create ? 3 : 2], enabled ? 1 : 0);
assert.match(response.redirectedTo, /^\/settings\/roles\/9\/edit/);
if (create) assert.equal(auditEvent.details.requireTwoFactor, enabled);
else assert.deepEqual(auditEvent.details.changes.requireTwoFactor, { from: !enabled, to: enabled });
});
}
}
test('duplicating a role retains its two-factor requirement and the editor renders the toggle', () => {
require('../src/common');
const { buildDuplicateRole } = require('../src/web/routes/settings/rbac/duplicate');
const pages = require('../src/web/pages');
for (const enabled of [true, false]) {
const role = { id: 9, name: 'Secure', require_two_factor: enabled };
assert.equal(buildDuplicateRole(role).require_two_factor, enabled);
const html = pages.renderRbacEditPage(role, '', null, [], [], null);
const toggle = html.match(/<input id="role-require-two-factor"[^>]+>/)[0];
assert.equal(toggle.includes('checked'), enabled);
}
});
test('rbac form exposes bulk permission controls', () => { test('rbac form exposes bulk permission controls', () => {
assert.match(rbacFormTemplate, /name="permissions_present" value="1"/); assert.match(rbacFormTemplate, /name="permissions_present" value="1"/);
assert.match(rbacFormTemplate, /name="user_ids_present\[\]" value="\{\{id\}\}"/); assert.match(rbacFormTemplate, /name="user_ids_present\[\]" value="\{\{id\}\}"/);
+4
View File
@@ -532,6 +532,9 @@ test('shared layout route is permission protected and updates existing rows with
assert.ok(calls.some(call => call[0] === 'thumbnail')); assert.ok(calls.some(call => call[0] === 'thumbnail'));
assert.ok(calls.some(call => call[0] === 'notify')); assert.ok(calls.some(call => call[0] === 'notify'));
assert.ok(calls.some(call => call[0] === 'audit')); assert.ok(calls.some(call => call[0] === 'audit'));
const auditChanges = calls.find(call => call[0] === 'audit')[1].details.changes;
assert.deepEqual(auditChanges['regions.hero.x'], { from: 10, to: 120 });
assert.equal(auditChanges.regions, undefined);
const writes = calls.filter(call => /^UPDATE|DELETE|INSERT/.test(call[0])); const writes = calls.filter(call => /^UPDATE|DELETE|INSERT/.test(call[0]));
assert.equal(writes.length, 2); assert.equal(writes.length, 2);
assert.ok(writes.every(call => !/content_json|animation_json|DELETE|INSERT/.test(call[0]))); assert.ok(writes.every(call => !/content_json|animation_json|DELETE|INSERT/.test(call[0])));
@@ -579,6 +582,7 @@ test('shared save clears only the current slide geometry and preserves saved con
const persisted = JSON.parse(writes[0][1][0]); const persisted = JSON.parse(writes[0][1][0]);
assert.deepEqual(persisted.hero, { ...savedContent.hero, layout: { animation_json: { loop: { preset: 'pulse' } } } }); assert.deepEqual(persisted.hero, { ...savedContent.hero, layout: { animation_json: { loop: { preset: 'pulse' } } } });
assert.deepEqual(persisted.other, savedContent.other); assert.deepEqual(persisted.other, savedContent.other);
assert.equal(calls.find(call => call[0] === 'audit')[1].details.promotedSlideId, 42);
assert.deepEqual(writes[0][1].slice(2), [42, 7]); assert.deepEqual(writes[0][1].slice(2), [42, 7]);
assert.ok(calls.findIndex(call => call[0] === 'commit') > calls.findIndex(call => call === writes[0])); assert.ok(calls.findIndex(call => call[0] === 'commit') > calls.findIndex(call => call === writes[0]));
}); });
+372
View File
@@ -5,6 +5,13 @@ require('../src/common');
const { getPendingMigrations } = require('../src/db/migrations'); const { getPendingMigrations } = require('../src/db/migrations');
async function getAuthMigrationStep(pool) {
const pending = await getPendingMigrations(pool, { currentVersion: '2.19.1' });
const migration = pending.find(item => item.version === '2.20.0');
assert.ok(migration, 'Expected the consolidated 2.20.0 migration');
return migration;
}
function createPool(responses) { function createPool(responses) {
const queries = []; const queries = [];
return { return {
@@ -22,6 +29,41 @@ function createPool(responses) {
}; };
} }
test('role two-factor migration adds an opt-in requirement for existing and fresh schemas', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_roles ADD COLUMN require_two_factor TINYINT(1) NOT NULL DEFAULT 0')));
const fs = require('node:fs');
assert.match(fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8'), /require_two_factor TINYINT\(1\) NOT NULL DEFAULT 0/);
});
test('passkey credential storage is defined once in the current schema snapshot', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
assert.equal(statements.some(sql => /CREATE TABLE IF NOT EXISTS a_(?:user_)?passkeys/.test(sql)), false);
assert.equal(statements.some(sql => /(?:RENAME TABLE a_passkeys|INSERT IGNORE INTO a_user_passkeys)/.test(sql)), false);
const fs = require('node:fs');
const schema = fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8');
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_passkeys/);
assert.match(schema, /credential_id VARCHAR\(512\) NOT NULL/);
});
test('the consolidated migration does not create and then rename user auth tables', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
assert.equal(statements.some(sql => /CREATE TABLE IF NOT EXISTS a_(?:two_factors|passkeys)/.test(sql)), false);
assert.equal(statements.some(sql => /(?:RENAME TABLE a_(?:two_factors|passkeys)|INSERT IGNORE INTO a_user_(?:two_factors|passkeys))/.test(sql)), false);
const fs = require('node:fs');
const schema = fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8');
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_two_factors/);
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_passkeys/);
});
test('pending migrations are empty when the schema already matches the app version', async () => { test('pending migrations are empty when the schema already matches the app version', async () => {
const pool = createPool([ const pool = createPool([
{ {
@@ -49,6 +91,336 @@ test('pending migrations are empty when the schema already matches the app versi
assert.equal(pendingMigrations.length, 0); assert.equal(pendingMigrations.length, 0);
}); });
test('2.20.1 release follows the consolidated 2.20.0 migration', async () => {
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.19.1' });
assert.deepEqual(pending.map(migration => migration.version), ['2.20.0', '2.20.1']);
});
test('2.20.1 removes retired security setting rows', async () => {
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.20.0' });
const migration = pending.find(item => item.version === '2.20.1');
assert.ok(migration, 'Expected the 2.20.1 security settings cleanup migration');
const pool = createPool([]);
await migration.run(pool);
assert.deepEqual(pool.queries, [[
'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'
]
]]);
});
test('Better Auth migration extends app users and sessions with auth account tables', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && params && params[0] === 'a_users' && params[1] === 'email_verified_at';
},
result: [[{ column_count: 1 }]]
},
{
match(sql) {
return sql.includes('SELECT id, email, email_verified_at FROM a_users');
},
result: [[
{ id: 1, email: 'first@example.com', email_verified_at: new Date() },
{ id: 2, email: 'second@example.com', email_verified_at: new Date() },
{ id: 3, email: 'verified@example.com', email_verified_at: new Date() },
{ id: 4, email: 'pending@example.com', email_verified_at: null }
]]
}
]);
const authMigration = await getAuthMigrationStep(pool);
await authMigration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
const schema = require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8');
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_accounts/);
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_verifications/);
assert.equal(statements.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS a_accounts')), false);
assert.equal(statements.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS a_verifications')), false);
assert.ok(statements.some(sql => sql.includes('ADD COLUMN email_verified')));
assert.ok(statements.some(sql => sql.includes('ADD COLUMN last_request')));
assert.ok(statements.includes('DELETE FROM a_sessions'));
assert.equal(statements.some(sql => sql.includes('CREATE TRIGGER')), false);
const emailUpdates = pool.queries
.filter(([sql]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?'))
.map(([, params]) => params);
assert.deepEqual(emailUpdates, [
['first@example.com', true, 1],
['second@example.com', true, 2],
['verified@example.com', true, 3],
['pending@example.com', false, 4]
]);
assert.equal(statements.some(sql => sql.includes('auth_email')), false);
assert.equal(statements.some(sql => sql.includes('uq_a_users_auth_email')), false);
assert.equal(statements.some(sql => sql.includes('app-user:%')), false);
assert.doesNotMatch(schema, /auth_email/);
});
test('consolidated Better Auth migration removes legacy password columns after credential migration', async () => {
const pool = createPool([
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] }
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_users DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations')));
assert.equal(pool.queries.some(([sql]) => String(sql).includes('MODIFY COLUMN password_hash CHAR(64) NULL')), false);
});
test('Better Auth passwordless migration tolerates fresh schemas without legacy password columns', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users';
},
result: [[{ column_count: 0 }]]
},
{ match(sql) { return sql.includes('information_schema.COLUMNS'); }, result: [[{ column_count: 1 }]] }
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.equal(pool.queries.some(([sql]) => String(sql).includes('MODIFY COLUMN password_hash')), false);
});
test('Email OTP migration removes obsolete link and lockout storage only', async () => {
const pool = createPool([
{
match(sql) { return sql.includes('information_schema.COLUMNS'); },
result: [[{ column_count: 1 }]]
},
{
match(_sql, params) { return Array.isArray(params) && params[0] === 'a_account_tokens'; },
result: [[{ table_count: 1 }]]
},
{
match(sql) { return sql.includes('SELECT LOWER(TRIM(email)) AS email_key'); },
result: [[]]
},
{
match() { return true; },
result: [[{ table_count: 0 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN pending_email_token_hash')));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN pending_email_expires_at')));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_login_at')));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_login_ip')));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_failed_at')));
assert.ok(statements.some(sql => sql.includes('DROP TABLE a_account_tokens')));
assert.ok(statements.some(sql => sql.includes("DELETE FROM o_app_settings WHERE setting_key IN ('email.verification_button_alignment'")));
assert.ok(!statements.some(sql => sql.includes('DROP TABLE a_verifications')));
});
test('follow-up migration removes unused admin last-login fields', async () => {
const pool = createPool([
{
match(sql) { return sql.includes('information_schema.COLUMNS'); },
result: [[{ column_count: 1 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const alterations = pool.queries.map(([sql]) => String(sql));
assert.ok(alterations.includes('ALTER TABLE a_users DROP COLUMN last_login_at'));
assert.ok(alterations.includes('ALTER TABLE a_users DROP COLUMN last_login_ip'));
});
test('OTP expiry migration consolidates old settings and removes unused session creator', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('SELECT setting_key, setting_value FROM o_app_settings') && params[0] === 'email.otp_expiry_minutes';
},
result: [[
{ setting_key: 'email.verification_expiry_minutes', setting_value: 8 },
{ setting_key: 'email.reset_expiry_minutes', setting_value: 15 }
]]
},
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && params[0] === 'a_sessions' && params[1] === 'created_by';
},
result: [[{ column_count: 1 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO o_app_settings') && params[0] === 'email.otp_expiry_minutes' && params[1] === '8'));
assert.ok(pool.queries.some(([sql]) => String(sql).includes('DELETE FROM o_app_settings WHERE setting_key IN (?, ?)')));
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_sessions DROP COLUMN created_by')));
});
test('per-user password policy migrations remove both old global settings', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const deletedSettingKeys = pool.queries.filter(([sql]) => String(sql).includes('DELETE FROM o_app_settings WHERE setting_key = ?')).map(([, params]) => params[0]);
assert.ok(deletedSettingKeys.includes('security.require_password_change_after_admin_reset'));
assert.ok(deletedSettingKeys.includes('security.require_password_change_for_new_users'));
});
test('canonical email migration backfills synthetic addresses and drops duplicate email fields', async () => {
const pool = createPool([
{
match(sql) { return sql.includes('information_schema.COLUMNS'); },
result: [[{ column_count: 1 }]]
},
{
match(sql) {
return sql.includes('SELECT LOWER(TRIM(email)) AS email_key');
},
result: [[]]
},
{
match(sql, params) {
return sql.includes('SELECT id, email, email_verified_at FROM a_users') && params === undefined;
},
result: [[
{ id: 5, email: null, email_verified_at: null },
{ id: 6, email: 'verified@example.test', email_verified_at: new Date() }
]]
},
{
match(sql) { return sql.includes('information_schema.STATISTICS'); },
result: [[{ index_count: 0 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const queries = pool.queries;
assert.ok(queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'app-user-5@identity.invalid' && params[1] === false));
assert.ok(queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'verified@example.test' && params[1] === true));
assert.equal(queries.some(([sql]) => String(sql).includes('auth_email')), false);
assert.ok(queries.some(([sql]) => String(sql).includes('ALTER TABLE a_users DROP COLUMN email_verified_at')));
});
test('canonical email migration refuses duplicate real email addresses', async () => {
const pool = createPool([
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] },
{
match: sql => sql.includes('SELECT LOWER(TRIM(email)) AS email_key'),
result: [[{ email_key: 'duplicate@example.test', duplicate_count: 2 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await assert.rejects(migration.run(pool), /duplicate user email addresses/);
});
test('canonical email migration preserves email_verified on fresh schemas', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users' && params[1] === 'email_verified_at';
},
result: [[{ column_count: 0 }]]
},
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users' && params[1] === 'auth_email';
},
result: [[{ column_count: 0 }]]
},
{
match(sql) { return sql.includes('information_schema.COLUMNS'); },
result: [[{ column_count: 1 }]]
},
{
match(sql) { return sql.includes('SELECT LOWER(TRIM(email)) AS email_key'); },
result: [[]]
},
{
match(sql) { return sql.includes('SELECT id, email, email_verified FROM a_users'); },
result: [[{ id: 9, email: 'canonical@example.test', email_verified: 1 }]]
},
{
match(sql, params) { return sql.includes('information_schema.STATISTICS') && Array.isArray(params) && params[1] === 'uq_a_users_auth_email'; },
result: [[{ index_count: 0 }]]
},
{
match(sql) { return sql.includes("COLUMN_NAME = 'email'"); },
result: [[{ index_count: 1 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'canonical@example.test' && params[1] === true));
assert.equal(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN email_verified_at')), false);
});
test('credential migration moves existing password records into Better Auth accounts', async () => {
const passwordHash = 'a'.repeat(64);
const passwordSalt = 'b'.repeat(32);
const pool = createPool([
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] },
{
match: sql => sql.includes('SELECT id, password_hash, password_salt, password_iterations, created_at, modified_at FROM a_users'),
result: [[
{ id: 12, password_hash: passwordHash, password_salt: passwordSalt, password_iterations: 310000, created_at: new Date(), modified_at: new Date() },
{ id: 13, password_hash: null, password_salt: null, password_iterations: null, created_at: new Date(), modified_at: new Date() }
]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO a_accounts') && params[3] === 'pbkdf2-sha256$310000$' + passwordSalt + '$' + passwordHash));
assert.equal(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO a_accounts') && params[2] === 13), false);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations')));
});
test('credential migration leaves fresh Better Auth schemas without legacy password columns intact', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users';
},
result: [[{ column_count: 0 }]]
},
{ match(sql) { return sql.includes('information_schema.COLUMNS'); }, result: [[{ column_count: 1 }]] }
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.equal(pool.queries.some(([sql]) => String(sql).includes('SELECT id, password_hash, password_salt')), false);
assert.equal(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN password_hash')), false);
});
test('invitation removal migration drops the legacy table, permissions, and settings', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
assert.ok(statements.includes('DROP TABLE IF EXISTS a_user_invitations'));
assert.ok(statements.some(sql => sql.includes('DELETE FROM a_role_permissions') && sql.includes("permission_key LIKE 'invitations.%'")));
assert.ok(statements.some(sql => sql.includes("DELETE FROM a_permissions WHERE permission_key LIKE 'invitations.%'")));
assert.ok(statements.some(sql => sql.includes("DELETE FROM o_app_settings WHERE setting_key LIKE 'email.invitation_%'")));
});
test('Better Auth two-factor migration adds user state while the table comes from the schema snapshot', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ADD COLUMN two_factor_enabled')));
assert.equal(pool.queries.some(([sql]) => String(sql).includes('CREATE TABLE IF NOT EXISTS a_two_factors')), false);
assert.match(require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8'), /CREATE TABLE IF NOT EXISTS a_user_two_factors/);
});
test('query index migration upgrades a 2.17.4 installation', async () => { test('query index migration upgrades a 2.17.4 installation', async () => {
const queries = []; const queries = [];
const pool = { const pool = {
+110 -7
View File
@@ -6,6 +6,70 @@ require('../src/common');
const registerSettingsRoutes = require('../src/web/routes/settings/routes'); const registerSettingsRoutes = require('../src/web/routes/settings/routes');
const { ANNOUNCEMENT_ICON_CATALOG } = require('../src/data/announcement-icons'); const { ANNOUNCEMENT_ICON_CATALOG } = require('../src/data/announcement-icons');
for (const failReset of [false, true]) {
test('global login lockout reset ' + (failReset ? 'forwards failures' : 'uses its own permission and audits the action'), async () => {
const handlers = {};
const permissions = [];
const queries = [];
const resetError = new Error('Reset failed');
let auditEvent;
let forwardedError;
registerSettingsRoutes({
get() {},
post(path, ...routeHandlers) { handlers[path] = routeHandlers; }
}, {
pages: {},
pool: {
async query(sql, params) {
queries.push({ sql, params });
if (failReset) throw resetError;
return [{ affectedRows: 3 }];
}
},
requirePermission(permission) {
const middleware = (_req, _res, next) => next();
permissions.push({ permission, middleware });
return middleware;
},
recordRequestAuditEvent: async (_pool, _req, event) => { auditEvent = event; }
});
const route = handlers['/settings/system/login-lockouts/reset'];
assert.equal(permissions.find(entry => entry.middleware === route[0]).permission, 'login-lockouts.allow');
const response = { redirect(url) { this.redirectedTo = url; } };
await route[1]({ currentUser: { id: 3 } }, response, error => { forwardedError = error; });
assert.deepEqual(queries, [{
sql: 'DELETE FROM a_login_attempts WHERE rate_key LIKE ? OR rate_key LIKE ?',
params: ['%|/sign-in/%', '%|/two-factor/verify-%']
}]);
if (failReset) {
assert.equal(forwardedError, resetError);
assert.equal(auditEvent, undefined);
assert.equal(response.redirectedTo, undefined);
} else {
assert.equal(auditEvent.eventType, 'system-settings.login_lockouts_reset');
assert.equal(auditEvent.actorUserId, 3);
assert.equal(response.redirectedTo, '/settings/system?message=Sign-in%20and%20two-factor%20request%20rate%20limits%20reset%20for%20all%20clients.#security-sessions');
}
});
}
test('login lockout reset permission is separate from settings update in the UI', () => {
const { renderView } = require('../src/web/view');
const { PERMISSIONS } = require('../src/rbac');
assert.ok(PERMISSIONS.some(permission => permission.key === 'login-lockouts.allow'));
for (const permissionKeys of [['system-settings.read'], ['system-settings.read', 'system-settings.update'], ['system-settings.read', 'login-lockouts.allow']]) {
const html = renderView('settings/system/index', { currentUser: { id: 3, permissionKeys }, mediaSettings: {} });
const canReset = permissionKeys.includes('login-lockouts.allow');
assert.equal(html.includes('id="reset-login-lockouts-form"'), canReset);
assert.equal(html.includes('form="reset-login-lockouts-form"'), canReset);
if (canReset) assert.match(html, /data-confirm-message="Clear sign-in and two-factor request rate limits for all clients\?/);
assert.equal(html.includes('name="session_lifetime_days"'), false);
assert.equal(html.includes('name="max_active_sessions"'), false);
assert.equal(html.includes('name="login_max_attempts"'), false);
assert.equal(html.includes('name="allow_admin_email_verification_bypass"'), true);
}
});
test('settings route renders the overview', async () => { test('settings route renders the overview', async () => {
const handlers = {}; const handlers = {};
const queries = []; const queries = [];
@@ -31,7 +95,7 @@ test('settings route renders the overview', async () => {
} }
}, },
requirePermission(permissionKey) { requirePermission(permissionKey) {
assert.ok(['system-settings.read', 'system-settings.update'].includes(permissionKey)); assert.ok(['system-settings.read', 'system-settings.update', 'login-lockouts.allow'].includes(permissionKey));
return function (_req, _res, next) { return function (_req, _res, next) {
next(); next();
}; };
@@ -48,7 +112,7 @@ test('settings route renders the overview', async () => {
}); });
assert.equal(pageResponse.body, 'settings-page'); assert.equal(pageResponse.body, 'settings-page');
assert.equal(Object.keys(handlers).length, 2); assert.equal(Object.keys(handlers).length, 3);
assert.ok(handlers['/settings/system']); assert.ok(handlers['/settings/system']);
assert.ok(handlers['POST /settings/system']); assert.ok(handlers['POST /settings/system']);
@@ -70,7 +134,7 @@ test('settings route renders the overview', async () => {
return entry.sql && /DELETE FROM o_app_settings/.test(entry.sql); return entry.sql && /DELETE FROM o_app_settings/.test(entry.sql);
}).forEach(function (entry) { }).forEach(function (entry) {
assert.equal((entry.sql.match(/\?/g) || []).length, entry.params.length); assert.equal((entry.sql.match(/\?/g) || []).length, entry.params.length);
assert.ok(entry.params.includes('email.invitation_body')); assert.equal(entry.params.includes('email.invitation_body'), false);
}); });
assert.match(saveResponse.redirectedTo, /^\/settings\/system\?message=/); assert.match(saveResponse.redirectedTo, /^\/settings\/system\?message=/);
@@ -103,18 +167,21 @@ test('settings route renders the overview', async () => {
} }
}; };
await handlers['POST /settings/system'][1]({ await handlers['POST /settings/system'][1]({
body: { settings_section: 'security', session_lifetime_days: '30', allow_user_session_revocation: '1', max_active_sessions: '5', login_max_attempts: '5', login_lockout_minutes: '15', login_rate_limit_scope: 'both', password_min_length: '12', password_min_categories: '3' }, body: { settings_section: 'security', allow_user_session_revocation: '1', password_min_length: '12', password_min_categories: '3', allow_admin_email_verification_bypass: '1' },
currentUser: { id: 7 } currentUser: { id: 7 }
}, securityResponse, function (error) { }, securityResponse, function (error) {
throw error; throw error;
}); });
assert.match(securityResponse.redirectedTo, /security-sessions/); assert.match(securityResponse.redirectedTo, /security-sessions/);
assert.ok(queries.some(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.session_lifetime_days';
}));
assert.ok(queries.some(function (entry) { assert.ok(queries.some(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.password_min_length'; return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.password_min_length';
})); }));
assert.ok(queries.some(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.allow_admin_email_verification_bypass' && entry.params[1] === 'true';
}));
assert.equal(queries.some(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && ['security.session_lifetime_days', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope'].includes(entry.params[0]);
}), false);
const mediaInsertQueries = queries.filter(function (entry) { const mediaInsertQueries = queries.filter(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings'); return entry.sql && entry.sql.includes('INSERT INTO o_app_settings');
@@ -178,3 +245,39 @@ test('email settings can send a test email to the logged-in user', async () => {
assert.equal(sentMessages[0].message.subject, 'Pulse Signage SMTP test email'); assert.equal(sentMessages[0].message.subject, 'Pulse Signage SMTP test email');
assert.match(response.redirectedTo, /Test%20email%20sent%20to%20admin%40example\.test/); assert.match(response.redirectedTo, /Test%20email%20sent%20to%20admin%40example\.test/);
}); });
test('email settings save one shared OTP expiry value', async () => {
const handlers = {};
const queries = [];
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
registerSettingsRoutes(app, {
pages: {},
pool: {
async query(sql, params) {
queries.push([String(sql), params]);
if (String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings')) return [[]];
return [{ affectedRows: 1 }];
}
},
requirePermission() { return function (_req, _res, next) { next(); }; }
});
const response = { redirect(url) { this.redirectedTo = url; } };
await handlers['POST /settings/system'][1]({
body: {
settings_section: 'email',
smtp_enabled: '0',
smtp_port: '587',
smtp_security: 'starttls',
otp_expiry_minutes: '12',
},
currentUser: { id: 7 }
}, response, (error) => { throw error; });
assert.ok(queries.some(([sql, params]) => sql.includes('INSERT INTO o_app_settings') && params[0] === 'email.otp_expiry_minutes' && params[1] === '12'));
assert.ok(!queries.some(([, params]) => Array.isArray(params) && ['email.verification_expiry_minutes', 'email.reset_expiry_minutes'].includes(params[0])));
assert.match(response.redirectedTo, /#email-delivery$/);
});
+2 -2
View File
@@ -227,7 +227,7 @@ test('2.19.1 migration rebuilds only managed font references for all content in
]); ]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.19.0' }); const pending = await getPendingMigrations(pool, { currentVersion: '2.19.0' });
assert.deepEqual(pending.map(migration => migration.version), ['2.19.1']); assert.deepEqual(pending.map(migration => migration.version), ['2.19.1', '2.20.0', '2.20.1']);
await pending[0].run(pool); await pending[0].run(pool);
const deletions = pool.queries.filter(([sql]) => sql.startsWith('DELETE')); const deletions = pool.queries.filter(([sql]) => sql.startsWith('DELETE'));
@@ -243,7 +243,7 @@ test('2.19.1 migration rebuilds only managed font references for all content in
assert.ok(pool.queries.some(([sql, params]) => sql.startsWith('SELECT id, template_id, region_key') && params[0] === 900)); assert.ok(pool.queries.some(([sql, params]) => sql.startsWith('SELECT id, template_id, region_key') && params[0] === 900));
assert.ok(!pool.queries.some(([sql]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql))); assert.ok(!pool.queries.some(([sql]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql)));
assert.deepEqual(pool.queries.slice(-1), [['COMMIT']]); assert.deepEqual(pool.queries.slice(-1), [['COMMIT']]);
assert.deepEqual(await getPendingMigrations(pool, { currentVersion: '2.19.1' }), []); assert.deepEqual((await getPendingMigrations(pool, { currentVersion: '2.19.1' })).map(migration => migration.version), ['2.20.0', '2.20.1']);
}); });
test('2.19.1 font repair rolls back without releasing the caller-owned connection on failure', async () => { test('2.19.1 font repair rolls back without releasing the caller-owned connection on failure', async () => {
+77
View File
@@ -2,7 +2,10 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
require('../src/common');
const { buildUsersEditViewModel, buildUsersAddViewModel } = require('../src/web/routes/settings/users/form-view-model'); const { buildUsersEditViewModel, buildUsersAddViewModel } = require('../src/web/routes/settings/users/form-view-model');
const rbacData = require('../src/web/lib/auth/rbac-data');
const usersFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/users/form.hbs'), 'utf8'); const usersFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/users/form.hbs'), 'utf8');
test('user edit view model includes delete confirmation text', () => { test('user edit view model includes delete confirmation text', () => {
@@ -36,3 +39,77 @@ test('user form keeps username editable in the admin section', () => {
assert.ok(usersFormTemplate.includes('required />')); assert.ok(usersFormTemplate.includes('required />'));
assert.match(usersFormTemplate, /class="btn-group ms-auto" role="group" aria-label=/); assert.match(usersFormTemplate, /class="btn-group ms-auto" role="group" aria-label=/);
}); });
test('user edit view model carries Better Auth sign-in methods without provider account ids', async () => {
const pool = {
async query(sql) {
if (String(sql).includes('FROM a_users u')) {
return [[{ id: 12, name: 'User', username: 'user', email: 'user@example.test', email_verified: 1, two_factor_enabled: 0, role_ids_csv: '' }]];
}
if (String(sql).includes('FROM a_accounts')) {
return [[{ provider_id: 'credential', account_id: '12' }, { provider_id: 'google', account_id: 'google-subject' }]];
}
if (String(sql).includes('FROM a_user_passkeys')) return [[{ id: 'passkey-1', name: 'Work laptop', created_at: new Date() }]];
return [[]];
}
};
const user = await rbacData.fetchUserWithRoles(pool, 12);
const model = buildUsersEditViewModel(user, '', { id: 1 }, []);
assert.deepEqual(model.authMethods, [
{ providerId: 'credential', label: 'Password', isCredential: true },
{ providerId: 'google', label: 'Google', isCredential: false }
]);
assert.equal(model.user.twoFactorEnabled, false);
assert.deepEqual(model.passkeys, [{ id: 'passkey-1', name: 'Work laptop', created_at: model.passkeys[0].created_at }]);
assert.equal(model.user.canUnlinkPasskeys, true);
assert.doesNotMatch(JSON.stringify(model.authMethods), /google-subject/);
assert.ok(usersFormTemplate.includes('Sign-in methods'));
assert.ok(usersFormTemplate.includes('{{#if isEdit}}'));
assert.doesNotMatch(usersFormTemplate, /Linked SSO or OAuth accounts will appear here when configured\./);
assert.ok(usersFormTemplate.includes('user.passkeys'));
assert.ok(usersFormTemplate.includes('data-admin-passkey-group'));
assert.ok(usersFormTemplate.includes('data-admin-passkey-list'));
assert.ok(usersFormTemplate.includes('bi-arrow-return-right'));
assert.ok(usersFormTemplate.includes('/passkeys/{{id}}/unlink'));
assert.ok(usersFormTemplate.includes('list-group-item d-flex align-items-start gap-3 px-0'));
});
test('password-only users still see the available two-factor option', () => {
const model = buildUsersEditViewModel({
id: 12,
inUse: false,
roleIds: [],
twoFactorEnabled: false,
authMethods: [{ providerId: 'credential', label: 'Password' }]
}, '', { id: 1 }, []);
assert.equal(model.user.twoFactorEnabled, false);
assert.ok(usersFormTemplate.includes('Two-step verification'));
assert.ok(usersFormTemplate.includes('Not enabled'));
});
test('admin edit shows two-factor status as an additional sign-in method', async () => {
const pool = {
async query(sql) {
if (String(sql).includes('FROM a_users u')) {
return [[{ id: 13, name: 'User', username: 'user', email: 'user@example.test', email_verified: 1, two_factor_enabled: 1, role_ids_csv: '' }]];
}
if (String(sql).includes('FROM a_accounts')) return [[{ provider_id: 'credential', account_id: '13' }]];
return [[]];
}
};
const user = await rbacData.fetchUserWithRoles(pool, 13);
const model = buildUsersEditViewModel(user, '', { id: 1 }, []);
assert.equal(model.user.twoFactorEnabled, true);
assert.ok(usersFormTemplate.includes('Enabled'));
});
test('user edit view model withholds passkey unlink from the account owner', () => {
const user = { id: 12, passkeys: [{ id: 'passkey-1', name: 'Work laptop' }] };
const model = buildUsersEditViewModel(user, '', { id: 12 }, []);
assert.equal(model.user.canUnlinkPasskeys, false);
assert.equal(model.passkeys.length, 1);
});
+39
View File
@@ -4,6 +4,45 @@ const path = require('path');
const registerMiddleware = require('../src/web/middleware'); const registerMiddleware = require('../src/web/middleware');
test('role-required two-factor enrollment gates pages and APIs while allowing setup and logout', () => {
const app = { middlewares: [], use(...args) { this.middlewares.push(args); } };
registerMiddleware(app, {
pool: {},
loadCurrentUser: async () => null,
requireAuth(_req, _res, next) { next(); },
MEDIA_DIR: path.join(process.cwd(), 'media'),
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
});
const gate = app.middlewares[app.middlewares.length - 1][0];
const actor = { id: 7, requiresTwoFactor: true, twoFactorEnabled: false };
for (const requestPath of ['/dashboard', '/slides/12', '/account/profile', '/api/clients']) {
let passed = false;
const res = {
redirect(url) { this.redirectedTo = url; },
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; }
};
gate({ method: 'GET', path: requestPath, currentUser: actor, authenticatedUser: actor }, res, () => { passed = true; });
assert.equal(passed, false);
if (requestPath.startsWith('/api/')) {
assert.equal(res.statusCode, 403);
assert.equal(res.body.code, 'TWO_FACTOR_ENROLLMENT_REQUIRED');
} else assert.equal(res.redirectedTo, '/account');
}
for (const requestPath of ['/account', '/logout', '/account/password']) {
let passed = false;
gate({ method: 'GET', path: requestPath, currentUser: actor, authenticatedUser: actor }, {}, () => { passed = true; });
assert.equal(passed, true);
}
for (const actorUser of [{ ...actor, twoFactorEnabled: true }, { ...actor, requiresTwoFactor: false }]) {
let passed = false;
gate({ method: 'GET', path: '/dashboard', currentUser: { id: 19, requiresTwoFactor: true, twoFactorEnabled: false }, authenticatedUser: actorUser }, {}, () => { passed = true; });
assert.equal(passed, true);
}
});
test('web middleware bypasses auth for internal player sync routes', () => { test('web middleware bypasses auth for internal player sync routes', () => {
const calls = []; const calls = [];
const app = { const app = {