Release v2.20.1: migrate authentication to Better Auth
This commit is contained in:
1 parent
e9a53f577c
commit
fba4f99ff7
92 files changed
+5085
-1943
No files matched your search
@@ -2,6 +2,24 @@
|
||||
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.19.3",
|
||||
"version": "2.20.1",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.19.3",
|
||||
"version": "2.20.1",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
@@ -18,6 +18,8 @@
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"@better-auth/passkey": "^1.7.13",
|
||||
"better-auth": "^1.7.13",
|
||||
"handlebars": "^4.7.8",
|
||||
"multer": "^2.2.0",
|
||||
"mysql2": "^3.23.3",
|
||||
|
||||
@@ -4,6 +4,8 @@ PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:lat
|
||||
|
||||
# Shared security
|
||||
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
|
||||
DB_HOST="mysql"
|
||||
|
||||
@@ -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
|
||||
PLAYER_PUBLIC_URL="http://remote-player.example.com:8081"
|
||||
|
||||
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
|
||||
|
||||
# Remote bridge connectivity
|
||||
BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090"
|
||||
@@ -60,6 +60,8 @@ Key configuration:
|
||||
|
||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
||||
- `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`
|
||||
- `BRIDGE_INTERNAL_URL`
|
||||
- `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 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`
|
||||
|
||||
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_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. |
|
||||
| `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_PORT` | MySQL port. | `3306` |
|
||||
| `DB_NAME` | MySQL database name. | `pulse-signage` |
|
||||
|
||||
@@ -15,6 +15,8 @@ services:
|
||||
DB_USER: ${DB_USER:-pulse-signage}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
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}
|
||||
BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
|
||||
+40
-19
@@ -7,45 +7,60 @@ Tables generally use a numeric auto-increment `id` primary key. The relationship
|
||||
|
||||
## 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_permissions` - permission catalog seeded from the application constants.
|
||||
- `a_role_permissions` - many-to-many mapping between roles and permissions.
|
||||
- `a_user_roles` - many-to-many mapping between users and roles.
|
||||
- `a_sessions` - persisted admin session tokens.
|
||||
- `a_account_tokens` - short-lived account verification and password-reset tokens.
|
||||
- `a_user_invitations` - pending user invitations and assigned role ids.
|
||||
- `a_login_attempts` - login rate-limit and lockout state.
|
||||
- `a_sessions` - Better Auth session tokens with application View-as metadata.
|
||||
- `a_accounts` - Better Auth credentials and linked identity-provider accounts.
|
||||
- `a_verifications` - Better Auth email OTP verification and reset challenges.
|
||||
- `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`
|
||||
|
||||
- `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.
|
||||
- `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`
|
||||
- `token_hash` is unique.
|
||||
- `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`
|
||||
- `(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:
|
||||
- `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`
|
||||
- `token_hash` is unique.
|
||||
- Indexed by `(email, used_at, expires_at)` and `(created_by, created_at)`.
|
||||
- `id`, `identifier`, `value`, `expires_at`, `created_at`, `updated_at`
|
||||
- Indexed by `identifier`; Better Auth stores one-time email codes and their expiry here.
|
||||
|
||||
### `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`
|
||||
|
||||
- `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.
|
||||
|
||||
### `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.
|
||||
- `name` is unique.
|
||||
- `require_two_factor` defaults to false; users must enroll when any assigned role requires two-factor authentication.
|
||||
|
||||
### `a_permissions`
|
||||
|
||||
@@ -294,9 +309,13 @@ erDiagram
|
||||
}
|
||||
A_SESSIONS {
|
||||
}
|
||||
A_ACCOUNT_TOKENS {
|
||||
A_ACCOUNTS {
|
||||
}
|
||||
A_USER_INVITATIONS {
|
||||
A_VERIFICATIONS {
|
||||
}
|
||||
A_USER_TWO_FACTORS {
|
||||
}
|
||||
A_USER_PASSKEYS {
|
||||
}
|
||||
A_LOGIN_ATTEMPTS {
|
||||
}
|
||||
@@ -354,7 +373,9 @@ erDiagram
|
||||
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
|
||||
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
|
||||
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
|
||||
I_API_SOURCE_GROUPS ||--o{ I_API_SOURCES : contains
|
||||
|
||||
|
||||
+14
-15
@@ -4,18 +4,7 @@ This guide covers the administrative tasks that support a Pulse Signage deployme
|
||||
|
||||
## 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.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
|
||||
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 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 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
- Who changed a user, setting, source, or player-related value?
|
||||
|
||||
Generated
+1145
-7
File diff suppressed because it is too large.
Load diff
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.19.3",
|
||||
"version": "2.20.1",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
@@ -21,10 +21,13 @@
|
||||
"start:player": "node -r dotenv/config src/player.js",
|
||||
"dev:web": "nodemon -r dotenv/config src/web.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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/passkey": "^1.7.13",
|
||||
"animate.css": "^4.1.1",
|
||||
"better-auth": "^1.7.13",
|
||||
"bootstrap-icons": "1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"cropperjs": "^1.6.2",
|
||||
@@ -41,6 +44,7 @@
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.2",
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
}
|
||||
+56
@@ -5,6 +5,7 @@ const crypto = require('crypto');
|
||||
const PASSWORD_ITERATIONS = 310000;
|
||||
const PASSWORD_KEY_LENGTH = 32;
|
||||
const PASSWORD_DIGEST = 'sha256';
|
||||
const SCRYPT_OPTIONS = { N: 16384, r: 16, p: 1, keyLength: 64, maxmem: 128 * 16384 * 16 * 2 };
|
||||
const SESSION_BYTES = 32;
|
||||
|
||||
function createOneTimeToken() {
|
||||
@@ -71,6 +72,56 @@ function hashPassword(password, salt) {
|
||||
};
|
||||
}
|
||||
|
||||
function encodePasswordRecord(record) {
|
||||
return ['pbkdf2-sha256', Number(record.iterations), record.salt, record.hash].join('$');
|
||||
}
|
||||
|
||||
function verifyEncodedPassword(password, encodedPassword) {
|
||||
const [algorithm, iterations, salt, hash] = String(encodedPassword || '').split('$');
|
||||
if (algorithm !== 'pbkdf2-sha256' || !iterations || !salt || !hash) {
|
||||
return false;
|
||||
}
|
||||
return verifyPassword(password, {
|
||||
password_hash: hash,
|
||||
password_salt: salt,
|
||||
password_iterations: Number(iterations)
|
||||
});
|
||||
}
|
||||
|
||||
function deriveScryptKey(password, salt) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
crypto.scrypt(String(password || '').normalize('NFKC'), salt, SCRYPT_OPTIONS.keyLength, SCRYPT_OPTIONS, function (error, key) {
|
||||
if (error) return reject(error);
|
||||
resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function hashScryptPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const key = await deriveScryptKey(password, salt);
|
||||
return salt + ':' + key.toString('hex');
|
||||
}
|
||||
|
||||
async function verifyScryptPassword(password, encodedPassword) {
|
||||
const [salt, hash] = String(encodedPassword || '').split(':');
|
||||
if (!/^[a-f0-9]{32}$/i.test(salt || '') || !/^[a-f0-9]{128}$/i.test(hash || '')) return false;
|
||||
const expected = Buffer.from(hash, 'hex');
|
||||
const actual = await deriveScryptKey(password, salt);
|
||||
return crypto.timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
async function verifyCredentialPassword(password, encodedPassword) {
|
||||
if (String(encodedPassword || '').startsWith('pbkdf2-sha256$')) {
|
||||
return verifyEncodedPassword(password, encodedPassword);
|
||||
}
|
||||
try {
|
||||
return await verifyScryptPassword(password, encodedPassword);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPassword(password, record) {
|
||||
if (!record || !record.password_salt || !record.password_hash) {
|
||||
return false;
|
||||
@@ -93,6 +144,11 @@ function hashSessionToken(token) {
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
encodePasswordRecord,
|
||||
verifyEncodedPassword,
|
||||
hashScryptPassword,
|
||||
verifyScryptPassword,
|
||||
verifyCredentialPassword,
|
||||
validatePasswordStrength,
|
||||
createSessionToken,
|
||||
hashSessionToken,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const DEFAULT_ACCOUNT_EMAIL_TEMPLATES = {
|
||||
verificationSubject: 'Verify your Pulse Signage email address',
|
||||
verificationBody: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nConfirm this email address: [[url]]',
|
||||
verificationBody: 'Hello [b][[display_name]][/b]!\nYour [u]verification code[/u] is [b][[otp]][/b].\nThis code expires in [i][[expiry_time]][/i].',
|
||||
resetSubject: 'Reset your Pulse Signage password',
|
||||
resetBody: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nChoose a new password: [[url]]'
|
||||
resetBody: 'Hello [b][[display_name]][/b]!\nYour [u]password reset code[/u] is [b][[otp]][/b].\nThis code expires in [i][[expiry_time]][/i].'
|
||||
};
|
||||
|
||||
function formatAccountEmailExpiry(value, unit) {
|
||||
@@ -12,6 +12,18 @@ function formatAccountEmailExpiry(value, unit) {
|
||||
return normalizedAmount + ' ' + normalizedUnit + (normalizedAmount === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
function normalizeOtpEmailBody(body) {
|
||||
let normalizedBody = String(body || '')
|
||||
.replace(/\[\[(?:action_button|action_link)\]\]/g, '')
|
||||
.replace(/\[\[url\]\]/g, '[[otp]]')
|
||||
.replace(/\b(?:verification )?links?\b/gi, 'verification code')
|
||||
.replace(/\bURLs?\b/gi, 'code');
|
||||
if (!normalizedBody.includes('[[otp]]')) {
|
||||
normalizedBody += '\nYour one-time code is [[otp]].';
|
||||
}
|
||||
return normalizedBody;
|
||||
}
|
||||
|
||||
function renderAccountEmailTemplate(subject, body, variables) {
|
||||
const values = variables || {};
|
||||
const replaceVariables = function (value) {
|
||||
@@ -51,4 +63,4 @@ function renderAccountEmailTemplate(subject, body, variables) {
|
||||
return { subject: renderedSubject, text: plainText, html: html };
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, renderAccountEmailTemplate };
|
||||
module.exports = { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTemplate };
|
||||
@@ -7,20 +7,13 @@ const SETTING_DEFINITIONS = [
|
||||
{ key: 'locale.timezone', type: 'string', defaultValue: 'Europe/London' },
|
||||
{ key: 'locale.language', type: 'string', defaultValue: 'en' },
|
||||
{ key: 'ui.theme', type: 'enum', values: ['dark', 'light', 'auto'], defaultValue: 'dark' },
|
||||
{ key: 'security.session_lifetime_days', type: 'integer', min: 1, defaultValue: 14 },
|
||||
{ key: 'security.allow_user_session_revocation', type: 'boolean', defaultValue: true },
|
||||
{ key: 'security.max_active_sessions', type: 'integer', min: 0, defaultValue: 0 },
|
||||
{ key: 'security.password_min_length', type: 'integer', min: 8, defaultValue: 10 },
|
||||
{ key: 'security.password_min_categories', type: 'integer', min: 1, defaultValue: 3 },
|
||||
{ key: 'security.password_require_lowercase', type: 'boolean', defaultValue: false },
|
||||
{ key: 'security.password_require_uppercase', type: 'boolean', defaultValue: false },
|
||||
{ key: 'security.password_require_number', type: 'boolean', defaultValue: false },
|
||||
{ key: 'security.password_require_symbol', type: 'boolean', defaultValue: false },
|
||||
{ key: 'security.require_password_change_for_new_users', type: 'boolean', defaultValue: true },
|
||||
{ key: 'security.require_password_change_after_admin_reset', type: 'boolean', defaultValue: true },
|
||||
{ key: 'security.login_max_attempts', type: 'integer', min: 1, defaultValue: 5 },
|
||||
{ key: 'security.login_lockout_minutes', type: 'integer', min: 1, defaultValue: 15 },
|
||||
{ key: 'security.login_rate_limit_scope', type: 'enum', values: ['both', 'username', 'ip'], defaultValue: 'both' },
|
||||
{ key: 'security.allow_admin_email_verification_bypass', type: 'boolean', defaultValue: true },
|
||||
{ key: 'email.smtp_enabled', type: 'boolean', defaultValue: false },
|
||||
{ key: 'email.smtp_host', type: 'string', defaultValue: '' },
|
||||
@@ -30,21 +23,11 @@ const SETTING_DEFINITIONS = [
|
||||
{ key: 'email.smtp_password', type: 'string', defaultValue: '' },
|
||||
{ key: 'email.from_address', type: 'string', defaultValue: '' },
|
||||
{ key: 'email.from_name', type: 'string', defaultValue: 'Pulse Signage' },
|
||||
{ key: 'email.verification_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 },
|
||||
{ key: 'email.reset_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 },
|
||||
{ key: 'email.invitation_expiry_hours', type: 'integer', min: 1, defaultValue: 24 },
|
||||
{ key: 'email.otp_expiry_minutes', type: 'integer', min: 1, defaultValue: 30 },
|
||||
{ key: 'email.verification_subject', type: 'string', defaultValue: 'Verify your Pulse Signage email address' },
|
||||
{ key: 'email.verification_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nConfirm this email address: [[url]]' },
|
||||
{ key: 'email.verification_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\nYour [u]verification code[/u] is [b][[otp]][/b].\nThis code expires in [i][[expiry_time]][/i].' },
|
||||
{ key: 'email.reset_subject', type: 'string', defaultValue: 'Reset your Pulse Signage password' },
|
||||
{ key: 'email.reset_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]link[/u] expires in [i][[expiry_time]][/i].\nChoose a new password: [[url]]' },
|
||||
{ key: 'email.verification_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
|
||||
{ key: 'email.verification_button_text', type: 'string', defaultValue: 'Verify email address' },
|
||||
{ key: 'email.reset_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
|
||||
{ key: 'email.reset_button_text', type: 'string', defaultValue: 'Reset password' },
|
||||
{ key: 'email.invitation_subject', type: 'string', defaultValue: 'You have been invited to Pulse Signage' },
|
||||
{ key: 'email.invitation_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\n[[action_button]]\nThis [u]invitation link[/u] expires in [i][[expiry_time]][/i].\nCreate your account: [[url]]' },
|
||||
{ key: 'email.invitation_button_alignment', type: 'enum', values: ['left', 'center', 'right'], defaultValue: 'center' },
|
||||
{ key: 'email.invitation_button_text', type: 'string', defaultValue: 'Accept invitation' },
|
||||
{ key: 'email.reset_body', type: 'string', defaultValue: 'Hello [b][[display_name]][/b]!\nYour [u]password reset code[/u] is [b][[otp]][/b].\nThis code expires in [i][[expiry_time]][/i].' },
|
||||
{ key: 'audit.enabled', type: 'boolean', defaultValue: true },
|
||||
{ key: 'audit.categories', type: 'string_array', defaultValue: ['authentication', 'security', 'sessions', 'users', 'roles', 'system-settings'] },
|
||||
{ key: 'audit.screen_control_commands', type: 'string_array', defaultValue: [] },
|
||||
|
||||
+61
-10
@@ -27,7 +27,7 @@ const AUDIT_CATEGORY_LABELS = Object.freeze({
|
||||
sessions: 'Sessions',
|
||||
users: 'Users',
|
||||
roles: 'Roles',
|
||||
'system-settings': 'System Settings',
|
||||
'system-settings': 'System settings',
|
||||
slides: 'Slides',
|
||||
templates: 'Templates',
|
||||
playlists: 'Playlists',
|
||||
@@ -51,6 +51,7 @@ const SCREEN_CONTROL_COMMAND_LABELS = Object.freeze({
|
||||
setclientname: 'Rename client'
|
||||
});
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { createHash } = require('node:crypto');
|
||||
|
||||
function formatUserAgentLabel(userAgent) {
|
||||
const value = String(userAgent || '').trim();
|
||||
@@ -81,20 +82,64 @@ function normalizeDetails(details) {
|
||||
}
|
||||
|
||||
function buildAuditChanges(previousValues, nextValues) {
|
||||
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
|
||||
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
|
||||
const changes = {};
|
||||
const keys = new Set(Object.keys(previous).concat(Object.keys(next)));
|
||||
|
||||
keys.forEach(function (key) {
|
||||
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
|
||||
changes[key] = { from: previous[key], to: next[key] };
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
function summarize(value, key) {
|
||||
if (value === undefined) return null;
|
||||
if ((key === 'qr_svg' || key === 'qr_preview') && typeof value === 'string' && value) {
|
||||
return '[Generated asset: ' + Buffer.byteLength(value, 'utf8') + ' bytes; SHA-256 ' + createHash('sha256').update(value).digest('hex') + ']';
|
||||
}
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
function compare(previous, next, path, key) {
|
||||
if (Object.is(previous, next)) return;
|
||||
if ((Array.isArray(previous) || previous == null) && (Array.isArray(next) || next == null) && (Array.isArray(previous) || Array.isArray(next))) {
|
||||
const previousItems = previous || [];
|
||||
const nextItems = next || [];
|
||||
if (key === 'regions' && previousItems.concat(nextItems).every(function (item) { return isRecord(item) && typeof item.region_key === 'string'; }) &&
|
||||
new Set(previousItems.map(function (item) { return item.region_key; })).size === previousItems.length &&
|
||||
new Set(nextItems.map(function (item) { return item.region_key; })).size === nextItems.length) {
|
||||
const previousRegions = new Map(previousItems.map(function (item) { return [item.region_key, item]; }));
|
||||
const nextRegions = new Map(nextItems.map(function (item) { return [item.region_key, item]; }));
|
||||
const regionKeys = new Set([...previousRegions.keys(), ...nextRegions.keys()]);
|
||||
regionKeys.forEach(function (regionKey) {
|
||||
compare(previousRegions.get(regionKey), nextRegions.get(regionKey), path + '.' + regionKey, regionKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (previousItems.concat(nextItems).some(function (item) { return item !== null && typeof item === 'object'; })) {
|
||||
for (let index = 0; index < Math.max(previousItems.length, nextItems.length); index += 1) {
|
||||
compare(previousItems[index], nextItems[index], path + '[' + index + ']', key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((isRecord(previous) || previous == null) && (isRecord(next) || next == null) && (isRecord(previous) || isRecord(next))) {
|
||||
const previousRecord = isRecord(previous) ? previous : {};
|
||||
const nextRecord = isRecord(next) ? next : {};
|
||||
const keys = new Set(Object.keys(previousRecord).concat(Object.keys(nextRecord)));
|
||||
if (keys.size) {
|
||||
keys.forEach(function (childKey) {
|
||||
compare(previousRecord[childKey], nextRecord[childKey], path ? path + '.' + childKey : childKey, childKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(previous) !== JSON.stringify(next)) {
|
||||
Object.defineProperty(changes, path, {
|
||||
value: { from: summarize(previous, key), to: summarize(next, key) },
|
||||
enumerable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
compare(previousValues || {}, nextValues || {}, '', '');
|
||||
return changes;
|
||||
}
|
||||
|
||||
const buildSlideAuditChanges = buildAuditChanges;
|
||||
|
||||
function getRequestMetadata(req) {
|
||||
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
return {
|
||||
@@ -110,6 +155,11 @@ async function recordAuditEvent(pool, event) {
|
||||
if (!category || !eventType) {
|
||||
throw new Error('Audit events require a category and event type.');
|
||||
}
|
||||
const details = input.details;
|
||||
if (eventType.endsWith('.updated') && details && Object.keys(details).length === 1 &&
|
||||
details.changes && typeof details.changes === 'object' && !Array.isArray(details.changes) && !Object.keys(details.changes).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO o_audit_events
|
||||
@@ -162,6 +212,7 @@ module.exports = {
|
||||
formatUserAgentLabel,
|
||||
getRequestMetadata,
|
||||
buildAuditChanges,
|
||||
buildSlideAuditChanges,
|
||||
recordAuditEvent,
|
||||
recordRequestAuditEvent
|
||||
};
|
||||
@@ -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 };
|
||||
Vendored
+9
-5
@@ -1,5 +1,6 @@
|
||||
const { hashPassword } = require('#src/auth');
|
||||
// Startup seeding creates the default admin only for empty installs; migrations and user routes handle existing accounts.
|
||||
const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
|
||||
const { createSyntheticAuthEmail, hashCredentialPassword, setCredentialPassword } = require('#src/data/auth-accounts');
|
||||
|
||||
async function bootstrapDatabase(pool) {
|
||||
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
|
||||
@@ -36,11 +37,14 @@ async function bootstrapDatabase(pool) {
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
|
||||
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
|
||||
const encodedPassword = await hashCredentialPassword(password);
|
||||
const [result] = await pool.query(
|
||||
'INSERT IGNORE INTO a_users (name, username, email, email_verified, created_by, modified_by) VALUES (?, ?, ?, 0, NULL, NULL)',
|
||||
[name, username, createSyntheticAuthEmail()]
|
||||
);
|
||||
if (result && result.insertId) {
|
||||
await setCredentialPassword(pool, result.insertId, encodedPassword);
|
||||
}
|
||||
}
|
||||
|
||||
const [legacyRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', ['administrators']);
|
||||
|
||||
+63
-36
@@ -420,20 +420,15 @@ async function ensureSchema(pool, options) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
email VARCHAR(320) NULL,
|
||||
email_verified_at DATETIME NULL,
|
||||
email VARCHAR(320) NOT NULL UNIQUE,
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
image TEXT NULL,
|
||||
pending_email VARCHAR(320) NULL,
|
||||
pending_email_token_hash CHAR(64) NULL,
|
||||
pending_email_expires_at DATETIME NULL,
|
||||
password_hash CHAR(64) NOT NULL,
|
||||
password_salt VARCHAR(64) NOT NULL,
|
||||
password_iterations INT NOT NULL,
|
||||
must_change_password TINYINT(1) NOT NULL DEFAULT 0,
|
||||
account_locked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
last_login_at DATETIME NULL,
|
||||
last_login_ip VARCHAR(255) NULL,
|
||||
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -442,33 +437,35 @@ async function ensureSchema(pool, options) {
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_account_tokens (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
CREATE TABLE IF NOT EXISTS a_user_two_factors (
|
||||
id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_type VARCHAR(32) NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_account_tokens_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
|
||||
INDEX idx_account_tokens_lookup (token_type, token_hash, expires_at),
|
||||
INDEX idx_account_tokens_user_type (user_id, token_type)
|
||||
secret TEXT NOT NULL,
|
||||
backup_codes TEXT NOT NULL,
|
||||
verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
failed_verification_count INT NOT NULL DEFAULT 0,
|
||||
locked_until DATETIME NULL,
|
||||
UNIQUE KEY uq_a_user_two_factors_user_id (user_id),
|
||||
CONSTRAINT fk_a_user_two_factors_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_user_invitations (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
CREATE TABLE IF NOT EXISTS a_user_passkeys (
|
||||
id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(255) NULL,
|
||||
role_ids_json TEXT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
INDEX idx_user_invitations_email (email, used_at, expires_at),
|
||||
INDEX idx_user_invitations_created_by (created_by, created_at)
|
||||
public_key TEXT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
credential_id VARCHAR(512) NOT NULL,
|
||||
counter BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
device_type VARCHAR(32) NOT NULL,
|
||||
backed_up TINYINT(1) NOT NULL DEFAULT 0,
|
||||
transports TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
aaguid VARCHAR(36) NULL,
|
||||
UNIQUE KEY uq_a_user_passkeys_credential_id (credential_id),
|
||||
INDEX idx_a_user_passkeys_user_id (user_id),
|
||||
CONSTRAINT fk_a_user_passkeys_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
@@ -478,6 +475,7 @@ async function ensureSchema(pool, options) {
|
||||
role_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
require_two_factor TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -540,7 +538,6 @@ async function ensureSchema(pool, options) {
|
||||
user_agent VARCHAR(512) NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
|
||||
@@ -548,15 +545,45 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_accounts (
|
||||
id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
account_id VARCHAR(191) NOT NULL,
|
||||
provider_id VARCHAR(191) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
access_token TEXT NULL,
|
||||
refresh_token TEXT NULL,
|
||||
id_token TEXT NULL,
|
||||
access_token_expires_at DATETIME NULL,
|
||||
refresh_token_expires_at DATETIME NULL,
|
||||
scope TEXT NULL,
|
||||
password TEXT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uq_accounts_provider_account (provider_id, account_id),
|
||||
INDEX idx_accounts_user_id (user_id),
|
||||
CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_verifications (
|
||||
id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
identifier VARCHAR(191) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
INDEX idx_verifications_identifier (identifier)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_login_attempts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
rate_key VARCHAR(600) NOT NULL UNIQUE,
|
||||
failed_count INT NOT NULL DEFAULT 0,
|
||||
last_failed_at DATETIME NULL,
|
||||
locked_until DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
last_request BIGINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
|
||||
+153
-1
@@ -1,6 +1,6 @@
|
||||
// Ordered database migrations kept independent from the application version.
|
||||
|
||||
const { version: appVersion } = require('#root/package.json');
|
||||
const { encodePasswordRecord } = require('#src/auth');
|
||||
const { parseJsonSafe } = require('#src/data/utils');
|
||||
const { collectContentReferences, insertContentReferences } = require('#src/data/content-references');
|
||||
const TIMETABLE_TIME_ZONE = 'Europe/London';
|
||||
@@ -874,6 +874,158 @@ const VERSIONED_MIGRATIONS = [
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.20.0',
|
||||
label: 'v2.20.0 Better Auth account schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'a_users', 'email_verified', 'TINYINT(1) NOT NULL DEFAULT 0', 'email');
|
||||
await ensureColumn(pool, 'a_users', 'image', 'TEXT NULL', 'email_verified');
|
||||
await ensureColumn(pool, 'a_login_attempts', 'last_request', 'BIGINT NOT NULL DEFAULT 0', 'failed_count');
|
||||
await pool.query("UPDATE a_users SET name = COALESCE(NULLIF(name, ''), username) WHERE name IS NULL OR name = ''");
|
||||
await pool.query('ALTER TABLE a_users MODIFY COLUMN name VARCHAR(255) NOT NULL');
|
||||
await pool.query('DELETE FROM a_sessions');
|
||||
|
||||
const obsoleteColumns = [
|
||||
['a_users', 'pending_email_token_hash'],
|
||||
['a_users', 'pending_email_expires_at'],
|
||||
['a_users', 'last_login_at'],
|
||||
['a_users', 'last_login_ip'],
|
||||
['a_login_attempts', 'last_failed_at'],
|
||||
['a_login_attempts', 'locked_until'],
|
||||
['a_login_attempts', 'created_at'],
|
||||
['a_login_attempts', 'modified_at']
|
||||
];
|
||||
for (const [tableName, columnName] of obsoleteColumns) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
|
||||
}
|
||||
}
|
||||
if (await tableExists(pool, 'a_account_tokens')) {
|
||||
await pool.query('DROP TABLE a_account_tokens');
|
||||
}
|
||||
await pool.query("DELETE FROM o_app_settings WHERE setting_key IN ('email.verification_button_alignment', 'email.verification_button_text', 'email.reset_button_alignment', 'email.reset_button_text')");
|
||||
|
||||
const oldExpiryKeys = ['email.verification_expiry_minutes', 'email.reset_expiry_minutes'];
|
||||
const [expiryRows] = await pool.query(
|
||||
'SELECT setting_key, setting_value FROM o_app_settings WHERE setting_key IN (?, ?, ?)',
|
||||
['email.otp_expiry_minutes'].concat(oldExpiryKeys)
|
||||
);
|
||||
const hasOtpExpiry = (expiryRows || []).some(row => row.setting_key === 'email.otp_expiry_minutes');
|
||||
if (!hasOtpExpiry) {
|
||||
const oldExpiries = (expiryRows || [])
|
||||
.filter(row => oldExpiryKeys.includes(row.setting_key))
|
||||
.map(row => Number(parseJsonSafe(row.setting_value)))
|
||||
.filter(value => Number.isInteger(value) && value > 0);
|
||||
if (oldExpiries.length) {
|
||||
await pool.query(
|
||||
'INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by) VALUES (?, ?, NULL, NULL)',
|
||||
['email.otp_expiry_minutes', JSON.stringify(Math.min.apply(null, oldExpiries))]
|
||||
);
|
||||
}
|
||||
}
|
||||
await pool.query('DELETE FROM o_app_settings WHERE setting_key IN (?, ?)', oldExpiryKeys);
|
||||
if (await columnExists(pool, 'a_sessions', 'created_by')) {
|
||||
await pool.query('ALTER TABLE a_sessions DROP COLUMN created_by');
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM o_app_settings WHERE setting_key = ?', ['security.require_password_change_after_admin_reset']);
|
||||
await pool.query('DELETE FROM o_app_settings WHERE setting_key = ?', ['security.require_password_change_for_new_users']);
|
||||
|
||||
const [duplicateEmailRows] = await pool.query(`
|
||||
SELECT LOWER(TRIM(email)) AS email_key, COUNT(*) AS duplicate_count
|
||||
FROM a_users
|
||||
WHERE email IS NOT NULL AND TRIM(email) <> ''
|
||||
GROUP BY LOWER(TRIM(email))
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 1
|
||||
`);
|
||||
if (duplicateEmailRows && duplicateEmailRows.length) {
|
||||
throw new Error('Cannot migrate to canonical Better Auth emails while duplicate user email addresses exist. Resolve duplicate emails and retry.');
|
||||
}
|
||||
|
||||
const hasLegacyVerificationTimestamp = await columnExists(pool, 'a_users', 'email_verified_at');
|
||||
const [canonicalEmailUsers] = await pool.query(hasLegacyVerificationTimestamp
|
||||
? 'SELECT id, email, email_verified_at FROM a_users ORDER BY id ASC'
|
||||
: 'SELECT id, email, email_verified FROM a_users ORDER BY id ASC');
|
||||
for (const user of canonicalEmailUsers || []) {
|
||||
const email = String(user.email || '').trim().toLowerCase() || 'app-user-' + user.id + '@identity.invalid';
|
||||
const emailVerified = Boolean((hasLegacyVerificationTimestamp ? user.email_verified_at : user.email_verified) && !email.endsWith('@identity.invalid'));
|
||||
await pool.query('UPDATE a_users SET email = ?, email_verified = ? WHERE id = ?', [email, emailVerified, user.id]);
|
||||
}
|
||||
if (await columnExists(pool, 'a_users', 'email_verified_at')) await pool.query('ALTER TABLE a_users DROP COLUMN email_verified_at');
|
||||
await pool.query('ALTER TABLE a_users MODIFY COLUMN email VARCHAR(320) NOT NULL');
|
||||
const [emailIndexRows] = await pool.query(`
|
||||
SELECT COUNT(*) AS index_count
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'a_users' AND COLUMN_NAME = 'email' AND NON_UNIQUE = 0
|
||||
`);
|
||||
if (!Number(emailIndexRows && emailIndexRows[0] && emailIndexRows[0].index_count)) {
|
||||
await pool.query('ALTER TABLE a_users ADD UNIQUE INDEX uq_a_users_email (email)');
|
||||
}
|
||||
|
||||
const passwordColumns = await Promise.all([
|
||||
columnExists(pool, 'a_users', 'password_hash'),
|
||||
columnExists(pool, 'a_users', 'password_salt'),
|
||||
columnExists(pool, 'a_users', 'password_iterations')
|
||||
]);
|
||||
if (passwordColumns.some(Boolean) && !passwordColumns.every(Boolean)) {
|
||||
throw new Error('Cannot move password records to Better Auth accounts because the legacy password columns are incomplete.');
|
||||
}
|
||||
if (passwordColumns.every(Boolean)) {
|
||||
const [passwordUsers] = await pool.query('SELECT id, password_hash, password_salt, password_iterations, created_at, modified_at FROM a_users ORDER BY id ASC');
|
||||
for (const user of passwordUsers || []) {
|
||||
const hash = String(user.password_hash || '').trim();
|
||||
const salt = String(user.password_salt || '').trim();
|
||||
const iterations = Number(user.password_iterations);
|
||||
if (!hash && !salt && !iterations) {
|
||||
continue;
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/i.test(hash) || !salt || !Number.isSafeInteger(iterations) || iterations <= 0) {
|
||||
throw new Error('Cannot move an incomplete password record for user ' + user.id + '.');
|
||||
}
|
||||
const encodedPassword = encodePasswordRecord({ hash: hash, salt: salt, iterations: iterations });
|
||||
const createdAt = user.created_at || new Date();
|
||||
const updatedAt = user.modified_at || createdAt;
|
||||
await pool.query(
|
||||
`INSERT INTO a_accounts (id, account_id, provider_id, user_id, password, created_at, updated_at)
|
||||
VALUES (?, ?, 'credential', ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE password = VALUES(password), updated_at = VALUES(updated_at)`,
|
||||
['credential-' + user.id, String(user.id), user.id, encodedPassword, createdAt, updatedAt]
|
||||
);
|
||||
}
|
||||
await pool.query('ALTER TABLE a_users DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations');
|
||||
}
|
||||
|
||||
await pool.query('DROP TABLE IF EXISTS a_user_invitations');
|
||||
await pool.query(`
|
||||
DELETE FROM a_role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM a_permissions WHERE permission_key LIKE 'invitations.%'
|
||||
)
|
||||
`);
|
||||
await pool.query("DELETE FROM a_permissions WHERE permission_key LIKE 'invitations.%'");
|
||||
await pool.query("DELETE FROM o_app_settings WHERE setting_key LIKE 'email.invitation_%'");
|
||||
|
||||
await ensureColumn(pool, 'a_users', 'two_factor_enabled', 'TINYINT(1) NOT NULL DEFAULT 0', 'account_locked');
|
||||
await ensureColumn(pool, 'a_roles', 'require_two_factor', 'TINYINT(1) NOT NULL DEFAULT 0', 'description');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.20.1',
|
||||
label: 'v2.20.1 remove retired security settings',
|
||||
run: async function (pool) {
|
||||
await pool.query(
|
||||
'DELETE FROM o_app_settings WHERE setting_key IN (?, ?, ?, ?, ?)',
|
||||
[
|
||||
'security.session_lifetime_days',
|
||||
'security.max_active_sessions',
|
||||
'security.login_max_attempts',
|
||||
'security.login_lockout_minutes',
|
||||
'security.login_rate_limit_scope'
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken } = require('#src/auth');
|
||||
const { verifyCredentialPassword, createSessionToken, hashSessionToken } = require('#src/auth');
|
||||
const { verifyRequestAuth } = require('#src/request-auth');
|
||||
|
||||
const DEFAULT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -66,11 +66,10 @@ function createLocalControlService(options) {
|
||||
return (Array.isArray(users) ? users : []).map(function (user) {
|
||||
return {
|
||||
username_hash: String(user && (user.username_hash || fingerprintUsername(user.username)) || '').trim(),
|
||||
password_hash: String(user && user.password_hash || '').trim(),
|
||||
password_salt: String(user && user.password_salt || '').trim()
|
||||
password_hash: String(user && user.password_hash || '').trim()
|
||||
};
|
||||
}).filter(function (user) {
|
||||
return Boolean(user.username_hash && user.password_hash && user.password_salt);
|
||||
return Boolean(user.username_hash && user.password_hash);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -509,7 +508,7 @@ function createLocalControlService(options) {
|
||||
const user = cache.users.find(function (candidate) {
|
||||
return candidate.username_hash === fingerprintUsername(username);
|
||||
});
|
||||
if (!user || !verifyPassword(String(req.body && req.body.password || ''), user)) {
|
||||
if (!user || !(await verifyCredentialPassword(String(req.body && req.body.password || ''), user.password_hash))) {
|
||||
recordLoginFailure(req, username);
|
||||
return res.status(401).json({ error: 'Invalid local control credentials.' });
|
||||
}
|
||||
|
||||
+8
-11
@@ -201,17 +201,6 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'invitations',
|
||||
order: 15,
|
||||
name: 'Invitations',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View pending user invitations.' },
|
||||
{ key: 'create', name: 'Create', description: 'Send new user invitations.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete pending user invitations.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Resend pending user invitations.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'impersonate',
|
||||
order: 25,
|
||||
@@ -220,6 +209,14 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'allow', name: 'Allow', description: 'Preview a user account in read-only mode.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'login-lockouts',
|
||||
order: 26,
|
||||
name: 'Login lockouts',
|
||||
permissions: [
|
||||
{ key: 'allow', name: 'Allow', description: 'Reset sign-in and two-factor request lockouts for all clients.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 20,
|
||||
|
||||
+43
-22
@@ -6,7 +6,7 @@ const multer = require('multer');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const common = require('#src/common');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength, createOneTimeToken } = require('#src/auth');
|
||||
const { verifyPassword, hashSessionToken, hashPassword, validatePasswordStrength, createOneTimeToken } = require('#src/auth');
|
||||
const pages = require('#src/web/pages/index');
|
||||
const registerMiddleware = require('#src/web/middleware');
|
||||
const registerNotFoundHandler = require('#src/web/middleware/not-found');
|
||||
@@ -24,6 +24,7 @@ const { rbacData } = require('#src/web/lib/auth/index');
|
||||
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index');
|
||||
const { createBetterAuth, getRoleTwoFactorAuthError } = require('#src/web/lib/auth/better-auth');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { sendAccountEmail } = require('#src/data/mailer');
|
||||
const { recordRequestAuditEvent } = require('#src/data/audit-log');
|
||||
@@ -51,6 +52,20 @@ async function start() {
|
||||
const server = http.createServer(app);
|
||||
const pool = common.createPool();
|
||||
const webConfig = createWebConfig();
|
||||
fs.mkdirSync(webConfig.mediaDir, { recursive: true });
|
||||
await ensureFontLibrary(webConfig.mediaDir);
|
||||
await common.ensureSchema(pool, { mediaDir: webConfig.mediaDir });
|
||||
await common.bootstrapDatabase(pool);
|
||||
const betterAuthService = await createBetterAuth(pool, {
|
||||
secret: process.env.AUTH_SECRET,
|
||||
baseURL: process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL,
|
||||
sessionCookieName: webConfig.sessionCookieName,
|
||||
sendAccountEmail: function (settings, message) { return sendAccountEmail(settings, message); }
|
||||
});
|
||||
const betterAuth = betterAuthService.auth;
|
||||
const toNodeHandler = betterAuthService.toNodeHandler;
|
||||
const fromNodeHeaders = betterAuthService.fromNodeHeaders;
|
||||
const betterAuthHandler = toNodeHandler(betterAuth);
|
||||
|
||||
// Background services and request forwarding helpers.
|
||||
const backgroundTaskQueue = createBackgroundTaskQueue({
|
||||
@@ -99,30 +114,38 @@ async function start() {
|
||||
// Session and auth helpers for route registration.
|
||||
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
|
||||
const sessionService = createSessionService({
|
||||
sessionCookieName: webConfig.sessionCookieName,
|
||||
sessionMaxAgeMs: webConfig.sessionMaxAgeMs,
|
||||
getConfiguredSessionMaxAgeMs: async function () {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return Number(settings['security.session_lifetime_days']) * 24 * 60 * 60 * 1000;
|
||||
},
|
||||
getConfiguredMaxActiveSessions: async function () {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return Number(settings['security.max_active_sessions']);
|
||||
},
|
||||
hashSessionToken: hashSessionToken,
|
||||
createSessionToken: createSessionToken
|
||||
sessionCookieName: webConfig.sessionCookieName
|
||||
});
|
||||
const parseCookies = sessionService.parseCookies;
|
||||
const createUserSession = sessionService.createUserSession;
|
||||
const clearSessionCookie = sessionService.clearSessionCookie;
|
||||
const setSessionCookie = sessionService.setSessionCookie;
|
||||
const getSessionMaxAgeMs = sessionService.getSessionMaxAgeMs;
|
||||
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
|
||||
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
|
||||
const loadCurrentUser = sessionService.loadCurrentUser;
|
||||
const loadCurrentUser = async function (pool, req) {
|
||||
const authSession = await betterAuth.api.getSession({ headers: fromNodeHeaders(req.headers) });
|
||||
if (!authSession || !authSession.session || !authSession.session.token) return null;
|
||||
req.betterAuthSession = authSession;
|
||||
return sessionService.loadCurrentUser(pool, req, authSession.session.token);
|
||||
};
|
||||
const requireAuth = sessionService.requireAuth;
|
||||
const startViewAsUser = sessionService.startViewAsUser;
|
||||
const stopViewAsUser = sessionService.stopViewAsUser;
|
||||
app.all('/api/auth/*splat', async function (req, res, next) {
|
||||
try {
|
||||
const currentUser = await loadCurrentUser(pool, req);
|
||||
const policyError = getRoleTwoFactorAuthError(currentUser, req.path);
|
||||
if (policyError) return res.status(403).json(policyError);
|
||||
const authPath = String(req.path || '');
|
||||
if (currentUser && currentUser.isViewingAs && (authPath.startsWith('/api/auth/two-factor/') || authPath.startsWith('/api/auth/passkey/'))) {
|
||||
return res.status(403).json({ message: 'Exit View as before changing authentication settings.' });
|
||||
}
|
||||
if (!req.headers['x-forwarded-for'] && req.socket && req.socket.remoteAddress) {
|
||||
req.headers['x-forwarded-for'] = req.socket.remoteAddress;
|
||||
}
|
||||
return betterAuthHandler(req, res, next);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
// Middleware and route registration.
|
||||
registerMiddleware(app, {
|
||||
pool: pool,
|
||||
@@ -137,16 +160,15 @@ async function start() {
|
||||
|
||||
registerRoutes(app, {
|
||||
pool: pool,
|
||||
betterAuth: betterAuth,
|
||||
fromNodeHeaders: fromNodeHeaders,
|
||||
common: common,
|
||||
pages: pages,
|
||||
upload: upload,
|
||||
mediaDir: webConfig.mediaDir,
|
||||
uploadDir: webConfig.uploadsDir,
|
||||
createUserSession: createUserSession,
|
||||
startViewAsUser: startViewAsUser,
|
||||
stopViewAsUser: stopViewAsUser,
|
||||
setSessionCookie: setSessionCookie,
|
||||
getSessionMaxAgeMs: getSessionMaxAgeMs,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
@@ -235,11 +257,10 @@ async function start() {
|
||||
});
|
||||
|
||||
// Bootstrap and startup tasks.
|
||||
fs.mkdirSync(webConfig.mediaDir, { recursive: true });
|
||||
await ensureFontLibrary(webConfig.mediaDir);
|
||||
await initializeWebServer({
|
||||
common: common,
|
||||
pool: pool,
|
||||
databaseInitialized: true,
|
||||
mediaDir: webConfig.mediaDir,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
webBootstrap: webBootstrap,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
const { fetchPagedRows } = require('#src/data/utils');
|
||||
const { toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
@@ -57,35 +58,9 @@ async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDir
|
||||
return Object.assign({ roles: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchInvitationsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT i.id, i.email, i.name, i.role_ids_json, i.created_at, i.expires_at, u.username AS created_by_username
|
||||
FROM a_user_invitations i
|
||||
LEFT JOIN a_users u ON u.id = i.created_by
|
||||
WHERE i.used_at IS NULL AND i.expires_at > NOW()
|
||||
ORDER BY i.created_at DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM a_user_invitations WHERE used_at IS NULL AND expires_at > NOW()',
|
||||
searchColumns: ['i.email', 'i.name', 'u.username'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
email: 'i.email',
|
||||
name: 'i.name',
|
||||
created: 'i.created_at',
|
||||
expires: 'i.expires_at',
|
||||
createdBy: 'u.username'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ invitations: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchRoleById(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.require_two_factor, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
@@ -168,7 +143,7 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
||||
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
|
||||
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT u.id, u.name, u.username, u.email, u.email_verified_at, u.account_locked, u.created_at, u.modified_at,
|
||||
selectSql: `SELECT u.id, u.name, u.username, u.email, u.email_verified, u.account_locked, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM a_users u
|
||||
@@ -202,6 +177,8 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
||||
return {
|
||||
users: (paged.rows || []).map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
email: toVisibleEmail(row.email),
|
||||
email_verified: Boolean(row.email_verified),
|
||||
roleIds: parseCsvIds(row.role_ids_csv),
|
||||
roleNames: String(row.role_names || '').trim()
|
||||
});
|
||||
@@ -215,10 +192,12 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
||||
|
||||
async function fetchUserWithRoles(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.username, u.email, u.email_verified_at, u.account_locked, u.created_at, u.modified_at,
|
||||
`SELECT u.id, u.name, u.username, u.email, u.email_verified, u.account_locked, u.must_change_password, u.two_factor_enabled, u.created_at, u.modified_at,
|
||||
tf.id AS two_factor_id, tf.verified AS two_factor_verified,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM a_users u
|
||||
LEFT JOIN a_user_two_factors tf ON tf.user_id = u.id
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
@@ -236,7 +215,22 @@ async function fetchUserWithRoles(pool, userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [accountRows] = await pool.query('SELECT provider_id FROM a_accounts WHERE user_id = ? ORDER BY created_at ASC', [userId]);
|
||||
const authMethods = (accountRows || []).map(function (account) {
|
||||
return mapAuthMethod(account && account.provider_id);
|
||||
}).filter(Boolean);
|
||||
const [passkeyRows] = await pool.query('SELECT id, name, created_at FROM a_user_passkeys WHERE user_id = ? ORDER BY created_at ASC', [userId]);
|
||||
const twoFactorEnabled = Boolean(rows[0].two_factor_enabled);
|
||||
const twoFactorSetupPending = Boolean(rows[0].two_factor_id && !rows[0].two_factor_verified);
|
||||
|
||||
return Object.assign({}, rows[0], {
|
||||
email: toVisibleEmail(rows[0].email),
|
||||
email_verified: Boolean(rows[0].email_verified),
|
||||
twoFactorEnabled: twoFactorEnabled,
|
||||
twoFactorSetupPending: twoFactorSetupPending,
|
||||
canUnlinkTwoFactor: twoFactorEnabled || twoFactorSetupPending,
|
||||
authMethods: authMethods,
|
||||
passkeys: passkeyRows || [],
|
||||
roleIds: parseCsvIds(rows[0].role_ids_csv),
|
||||
roleNames: String(rows[0].role_names || '').trim()
|
||||
});
|
||||
@@ -325,7 +319,6 @@ module.exports = {
|
||||
fetchPermissions,
|
||||
fetchRoles,
|
||||
fetchRolesPage,
|
||||
fetchInvitationsPage,
|
||||
fetchRoleById,
|
||||
fetchRolePermissionKeys,
|
||||
fetchRoleUserIds,
|
||||
|
||||
+32
-74
@@ -1,6 +1,7 @@
|
||||
// Session cookie and current-user helpers for the web auth flow.
|
||||
|
||||
const { hasPermission, normalizePermissionKeys } = require('#src/rbac');
|
||||
const { toVisibleEmail } = require('#src/data/auth-accounts');
|
||||
|
||||
function getRequestOrigin(req) {
|
||||
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
@@ -33,15 +34,10 @@ function normalizeReturnToPath(value, baseUrl) {
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||
const getConfiguredSessionMaxAgeMs = options && options.getConfiguredSessionMaxAgeMs;
|
||||
const getConfiguredMaxActiveSessions = options && options.getConfiguredMaxActiveSessions;
|
||||
const hashSessionToken = options && options.hashSessionToken;
|
||||
const createSessionToken = options && options.createSessionToken;
|
||||
const authMessageCookieName = 'pulse_auth_message';
|
||||
|
||||
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
|
||||
throw new Error('createSessionService requires the session dependencies.');
|
||||
if (!sessionCookieName) {
|
||||
throw new Error('createSessionService requires a session cookie name.');
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
@@ -89,23 +85,6 @@ function createSessionService(options) {
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token) {
|
||||
const hasRequestedMaxAge = arguments.length > 2;
|
||||
const requestedMaxAgeMs = hasRequestedMaxAge ? Number(arguments[2]) : sessionMaxAgeMs;
|
||||
const cookieOptions = hasRequestedMaxAge && arguments[2] === null
|
||||
? {}
|
||||
: { maxAge: Number.isFinite(requestedMaxAgeMs) && requestedMaxAgeMs > 0 ? requestedMaxAgeMs : sessionMaxAgeMs };
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, token, cookieOptions));
|
||||
}
|
||||
|
||||
async function getSessionMaxAgeMs() {
|
||||
const configuredValue = typeof getConfiguredSessionMaxAgeMs === 'function'
|
||||
? await getConfiguredSessionMaxAgeMs()
|
||||
: sessionMaxAgeMs;
|
||||
const normalizedValue = Number(configuredValue);
|
||||
return Number.isFinite(normalizedValue) && normalizedValue > 0 ? normalizedValue : sessionMaxAgeMs;
|
||||
}
|
||||
|
||||
function setAuthMessageCookie(res, message) {
|
||||
appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 }));
|
||||
}
|
||||
@@ -119,27 +98,35 @@ function createSessionService(options) {
|
||||
return message;
|
||||
}
|
||||
|
||||
async function loadCurrentUser(pool, req) {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
function getSessionToken(req, resolvedSessionToken) {
|
||||
if (resolvedSessionToken) return String(resolvedSessionToken);
|
||||
const betterAuthSessionToken = req && req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token;
|
||||
if (betterAuthSessionToken) return String(betterAuthSessionToken);
|
||||
const cookies = parseCookies(req && req.headers && req.headers.cookie || '');
|
||||
return cookies[sessionCookieName] || '';
|
||||
}
|
||||
|
||||
async function loadCurrentUser(pool, req, resolvedSessionToken) {
|
||||
const token = getSessionToken(req, resolvedSessionToken);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const sessionToken = token;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.user_id, s.view_as_user_id, s.view_as_expires_at,
|
||||
u.id, u.name, u.username, u.email, u.email_verified_at, u.pending_email, u.must_change_password,
|
||||
u.id, u.name, u.username, u.email, u.email_verified, u.pending_email, u.must_change_password, u.two_factor_enabled,
|
||||
preview.id AS preview_id, preview.name AS preview_name, preview.username AS preview_username,
|
||||
preview.email AS preview_email, preview.email_verified_at AS preview_email_verified_at,
|
||||
preview.pending_email AS preview_pending_email, preview.must_change_password AS preview_must_change_password
|
||||
preview.email AS preview_email, preview.email_verified AS preview_email_verified,
|
||||
preview.pending_email AS preview_pending_email, preview.must_change_password AS preview_must_change_password,
|
||||
preview.two_factor_enabled AS preview_two_factor_enabled
|
||||
FROM a_sessions s
|
||||
JOIN a_users u ON u.id = s.user_id
|
||||
LEFT JOIN a_users preview ON preview.id = s.view_as_user_id AND s.view_as_expires_at > NOW()
|
||||
WHERE s.session_hash = ?
|
||||
AND s.expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[tokenHash]
|
||||
[sessionToken]
|
||||
);
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
@@ -148,7 +135,7 @@ function createSessionService(options) {
|
||||
const userId = Number(rows[0].id);
|
||||
async function loadAccess(userId) {
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
`SELECT r.role_key, r.require_two_factor
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
@@ -165,6 +152,7 @@ function createSessionService(options) {
|
||||
[userId]
|
||||
);
|
||||
return {
|
||||
requiresTwoFactor: roleRows.some(function (row) { return Boolean(row.require_two_factor); }),
|
||||
roleKeys: roleRows.map(function (row) {
|
||||
return String(row.role_key || '').trim();
|
||||
}).filter(Boolean),
|
||||
@@ -176,7 +164,11 @@ function createSessionService(options) {
|
||||
|
||||
const actorAccess = await loadAccess(userId);
|
||||
const authenticatedUser = Object.assign({}, rows[0], {
|
||||
email: toVisibleEmail(rows[0].email),
|
||||
email_verified: Boolean(rows[0].email_verified),
|
||||
mustChangePassword: Boolean(rows[0].must_change_password),
|
||||
twoFactorEnabled: Boolean(rows[0].two_factor_enabled),
|
||||
requiresTwoFactor: actorAccess.requiresTwoFactor,
|
||||
roleKeys: actorAccess.roleKeys,
|
||||
permissionKeys: actorAccess.permissionKeys
|
||||
});
|
||||
@@ -188,11 +180,11 @@ function createSessionService(options) {
|
||||
if (Number(rows[0].view_as_user_id) > 0 && !canContinuePreview) {
|
||||
await pool.query(
|
||||
'UPDATE a_sessions SET view_as_user_id = NULL, view_as_expires_at = NULL WHERE session_hash = ? AND user_id = ?',
|
||||
[tokenHash, userId]
|
||||
[sessionToken, userId]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, sessionToken]);
|
||||
|
||||
if (!canContinuePreview) {
|
||||
return authenticatedUser;
|
||||
@@ -203,10 +195,12 @@ function createSessionService(options) {
|
||||
id: previewUserId,
|
||||
name: rows[0].preview_name,
|
||||
username: rows[0].preview_username,
|
||||
email: rows[0].preview_email,
|
||||
email_verified_at: rows[0].preview_email_verified_at,
|
||||
email: toVisibleEmail(rows[0].preview_email),
|
||||
email_verified: Boolean(rows[0].preview_email_verified),
|
||||
pending_email: rows[0].preview_pending_email,
|
||||
mustChangePassword: Boolean(rows[0].preview_must_change_password),
|
||||
twoFactorEnabled: Boolean(rows[0].preview_two_factor_enabled),
|
||||
requiresTwoFactor: previewAccess.requiresTwoFactor,
|
||||
roleKeys: previewAccess.roleKeys,
|
||||
permissionKeys: previewAccess.permissionKeys,
|
||||
authenticatedUser: authenticatedUser,
|
||||
@@ -216,9 +210,7 @@ function createSessionService(options) {
|
||||
}
|
||||
|
||||
function getSessionHash(req) {
|
||||
const cookies = parseCookies(req && req.headers && req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
return token ? hashSessionToken(token) : '';
|
||||
return getSessionToken(req);
|
||||
}
|
||||
|
||||
async function startViewAsUser(pool, req, actorUserId, targetUserId) {
|
||||
@@ -249,37 +241,6 @@ function createSessionService(options) {
|
||||
return Number(result && result.affectedRows) === 1;
|
||||
}
|
||||
|
||||
async function createUserSession(pool, userId, configuredMaxAgeMs, metadata) {
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const maxAgeMs = Number.isFinite(Number(configuredMaxAgeMs)) && Number(configuredMaxAgeMs) > 0
|
||||
? Number(configuredMaxAgeMs)
|
||||
: await getSessionMaxAgeMs();
|
||||
const expiresAt = new Date(Date.now() + maxAgeMs);
|
||||
const sessionMetadata = metadata && typeof metadata === 'object' ? metadata : {};
|
||||
await pool.query(
|
||||
'INSERT INTO a_sessions (session_hash, user_id, ip_address, user_agent, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[tokenHash, userId, String(sessionMetadata.ipAddress || '').slice(0, 255) || null, String(sessionMetadata.userAgent || '').slice(0, 512) || null, expiresAt, userId, userId]
|
||||
);
|
||||
|
||||
if (typeof getConfiguredMaxActiveSessions === 'function') {
|
||||
const maxActiveSessions = Number(await getConfiguredMaxActiveSessions());
|
||||
if (Number.isInteger(maxActiveSessions) && maxActiveSessions > 0) {
|
||||
const [sessionRows] = await pool.query(
|
||||
'SELECT id, session_hash FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at ASC, created_at ASC, id ASC',
|
||||
[userId]
|
||||
);
|
||||
const sessionsToRemove = (sessionRows || []).filter(function (session) {
|
||||
return session.session_hash !== tokenHash;
|
||||
}).slice(0, Math.max(0, sessionRows.length - maxActiveSessions));
|
||||
for (const session of sessionsToRemove) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE id = ? AND user_id = ?', [session.id, userId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.currentUser) {
|
||||
return next();
|
||||
@@ -293,14 +254,11 @@ function createSessionService(options) {
|
||||
parseCookies: parseCookies,
|
||||
serializeCookie: serializeCookie,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setSessionCookie: setSessionCookie,
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
startViewAsUser: startViewAsUser,
|
||||
stopViewAsUser: stopViewAsUser,
|
||||
createUserSession: createUserSession,
|
||||
getSessionMaxAgeMs: getSessionMaxAgeMs,
|
||||
requireAuth: requireAuth,
|
||||
getRequestOrigin: getRequestOrigin
|
||||
};
|
||||
|
||||
@@ -2,24 +2,27 @@ const crypto = require('crypto');
|
||||
|
||||
async function collectLocalControlUsers(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DISTINCT u.username, u.password_hash, u.password_salt
|
||||
`SELECT DISTINCT u.username, a.password AS password_hash
|
||||
FROM a_users u
|
||||
JOIN a_accounts a ON a.user_id = u.id AND a.provider_id = 'credential'
|
||||
JOIN a_user_roles ur ON ur.user_id = u.id
|
||||
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE u.account_locked = 0
|
||||
AND u.must_change_password = 0
|
||||
AND a.password IS NOT NULL
|
||||
AND a.password <> ''
|
||||
AND a.password NOT LIKE 'app-user:%'
|
||||
AND p.permission_key = 'clients.allow'
|
||||
ORDER BY u.username ASC`
|
||||
);
|
||||
return (rows || []).map(function (user) {
|
||||
return {
|
||||
username_hash: crypto.createHash('sha256').update(String(user && user.username || '').trim()).digest('hex'),
|
||||
password_hash: String(user && user.password_hash || '').trim(),
|
||||
password_salt: String(user && user.password_salt || '').trim()
|
||||
password_hash: String(user && user.password_hash || '').trim()
|
||||
};
|
||||
}).filter(function (user) {
|
||||
return Boolean(user.username_hash && user.password_hash && user.password_salt);
|
||||
return Boolean(user.username_hash && user.password_hash);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ async function initializeWebServer(options) {
|
||||
throw new Error('initializeWebServer requires the web startup dependencies.');
|
||||
}
|
||||
|
||||
await common.ensureSchema(pool, { mediaDir: mediaDir });
|
||||
await common.bootstrapDatabase(pool);
|
||||
if (!(options && options.databaseInitialized)) {
|
||||
await common.ensureSchema(pool, { mediaDir: mediaDir });
|
||||
await common.bootstrapDatabase(pool);
|
||||
}
|
||||
await backgroundTaskQueue.initialize();
|
||||
|
||||
await initializeBackgroundTasks({
|
||||
|
||||
@@ -43,7 +43,7 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
return next(error);
|
||||
}
|
||||
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/verify-email' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
return res.redirect('/account/password');
|
||||
}
|
||||
|
||||
const actor = req.authenticatedUser || req.currentUser;
|
||||
if (actor && actor.requiresTwoFactor && !actor.twoFactorEnabled && req.path !== '/account' && req.path !== '/account/password' && req.path !== '/settings/view-as/stop') {
|
||||
if (req.path.indexOf('/api/') === 0) {
|
||||
return res.status(403).json({ code: 'TWO_FACTOR_ENROLLMENT_REQUIRED', message: 'Set up two-factor authentication in My account to continue.' });
|
||||
}
|
||||
return res.redirect('/account');
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,19 +10,14 @@ module.exports = {
|
||||
renderLoginPage: require(routePath('auth', 'login')),
|
||||
renderForgotPasswordPage: require(routePath('auth', 'forgot-password')),
|
||||
renderResetPasswordPage: require(routePath('auth', 'reset-password')),
|
||||
renderAcceptInvitePage: require(routePath('auth', 'accept-invite')),
|
||||
renderNoAccessPage: require(routePath('auth', 'no-access')),
|
||||
renderEmailVerifiedPage: require(routePath('auth', 'email-verified')),
|
||||
renderEmailVerificationErrorPage: require(routePath('auth', 'email-verification-error')),
|
||||
renderAccountPage: require(routePath('account', 'password')),
|
||||
renderRequiredPasswordPage: require(routePath('account', 'password-required')),
|
||||
renderSettingsPage: require(routePath('settings', 'index')),
|
||||
renderAboutPage: require(routePath('settings', 'about', 'index')),
|
||||
renderChangelogPage: require(routePath('settings', 'changelog', 'index')),
|
||||
renderUsersPage: require(routePath('settings', 'users', 'list')),
|
||||
renderInvitationsPage: require(routePath('settings', 'invitations', 'list')),
|
||||
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
|
||||
renderUsersInvitePage: require(routePath('settings', 'users', 'invite')),
|
||||
renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
|
||||
renderDashboardPage: require(routePath('signage', 'dashboard', 'index')),
|
||||
renderConnectedClientsPage: require(routePath('signage', 'clients', 'list')),
|
||||
|
||||
@@ -447,6 +447,49 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-card .login-box-msg {
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card .form-label {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.login-card .input-group-text {
|
||||
min-width: 2.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-or-divider {
|
||||
align-items: center;
|
||||
color: var(--bs-secondary-color);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.login-or-divider::before,
|
||||
.login-or-divider::after {
|
||||
background: var(--bs-border-color);
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 359.98px) {
|
||||
.login-card [data-login-actions] {
|
||||
align-items: flex-start !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-card [data-login-submit] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.email-verified-card {
|
||||
max-width: 26rem;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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()]
|
||||
});
|
||||
@@ -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
|
||||
@@ -390,20 +390,13 @@
|
||||
username: previewUser ? previewUser.getAttribute('data-username') : '',
|
||||
display_name: previewUser ? previewUser.getAttribute('data-display-name') : '',
|
||||
email: previewUser ? previewUser.getAttribute('data-email') : '',
|
||||
verification_expiry: previewUser ? previewUser.getAttribute('data-verification-expiry') : '30',
|
||||
reset_expiry: previewUser ? previewUser.getAttribute('data-reset-expiry') : '30',
|
||||
invitation_expiry: previewUser ? previewUser.getAttribute('data-invitation-expiry') : '24'
|
||||
otp_expiry: previewUser ? previewUser.getAttribute('data-otp-expiry') : '30'
|
||||
};
|
||||
document.querySelectorAll('[data-email-template-editor]').forEach(function (editor) {
|
||||
var subject = editor.querySelector('[data-email-template-subject]');
|
||||
var body = editor.querySelector('[data-email-template-body]');
|
||||
var alignment = editor.querySelector('[data-email-template-alignment]');
|
||||
var buttonText = editor.querySelector('[data-email-template-button-text]');
|
||||
var preview = editor.querySelector('[data-email-template-preview]');
|
||||
if (!subject || !body || !preview) return;
|
||||
var isVerificationTemplate = Boolean(editor.querySelector('[name="verification_subject"]'));
|
||||
var isResetTemplate = Boolean(editor.querySelector('[name="reset_subject"]'));
|
||||
var isInvitationTemplate = Boolean(editor.querySelector('[name="invitation_subject"]'));
|
||||
|
||||
function render() {
|
||||
preview.innerHTML = '';
|
||||
@@ -418,45 +411,16 @@
|
||||
username: signedInUser.username || 'username',
|
||||
display_name: signedInUser.display_name || signedInUser.username || 'Your name',
|
||||
email: signedInUser.email || 'your-email@example.com',
|
||||
expiry_time: isInvitationTemplate ? signedInUser.invitation_expiry + ' hours' : (isResetTemplate ? signedInUser.reset_expiry : signedInUser.verification_expiry) + ' minutes'
|
||||
otp: '123456',
|
||||
expiry_time: signedInUser.otp_expiry + ' minutes'
|
||||
};
|
||||
var button = document.createElement('div');
|
||||
button.className = 'email-template-preview__button';
|
||||
var buttonAlignment = alignment ? alignment.value : 'center';
|
||||
var buttonWrap = document.createElement('div');
|
||||
buttonWrap.className = 'email-template-preview__button-wrap';
|
||||
buttonWrap.style.textAlign = buttonAlignment;
|
||||
buttonWrap.appendChild(button);
|
||||
var previewActionPlaceholder = '[[url]]';
|
||||
button.textContent = buttonText && buttonText.value ? buttonText.value : (isVerificationTemplate ? 'Verify email address' : (isInvitationTemplate ? 'Accept invitation' : 'Reset password'));
|
||||
var previewOrigin = window.location.origin || 'https://pulse-signage.example';
|
||||
var previewVerificationUrl = previewOrigin + '/verify-email?token=preview-token';
|
||||
var previewResetUrl = previewOrigin + '/reset-password?token=preview-token';
|
||||
var previewActionUrl = isVerificationTemplate ? previewVerificationUrl : (isInvitationTemplate ? previewOrigin + '/accept-invite?token=preview-token' : previewResetUrl);
|
||||
var hasExplicitButton = body.value.indexOf('[[action_button]]') !== -1;
|
||||
body.value.split(/\r?\n(?:[ \t]*\r?\n)+/).forEach(function (sourceParagraph) {
|
||||
var line = sourceParagraph.replace(/\[\[(username|display_name|email|expiry_time)\]\]/g, function (_match, key) { return sampleValues[key]; }).replace(previewActionPlaceholder, previewActionUrl);
|
||||
var hasButtonToken = sourceParagraph.indexOf('[[action_button]]') !== -1;
|
||||
var hasUrlToken = !hasExplicitButton && sourceParagraph.indexOf(previewActionPlaceholder) !== -1;
|
||||
if (hasButtonToken) {
|
||||
var buttonParts = line.split('[[action_button]]');
|
||||
if (buttonParts[0]) appendPreviewParagraph(buttonParts[0]);
|
||||
message.appendChild(buttonWrap);
|
||||
if (buttonParts[1]) appendPreviewParagraph(buttonParts[1]);
|
||||
return;
|
||||
}
|
||||
if (hasUrlToken) {
|
||||
var legacyParts = sourceParagraph.split(previewActionPlaceholder);
|
||||
if (legacyParts[0]) appendPreviewParagraph(legacyParts[0]);
|
||||
message.appendChild(buttonWrap);
|
||||
appendPreviewParagraph(previewActionUrl + legacyParts[1]);
|
||||
return;
|
||||
}
|
||||
var line = sourceParagraph.replace(/\[\[(username|display_name|email|expiry_time|otp)\]\]/g, function (_match, key) { return sampleValues[key]; });
|
||||
appendPreviewParagraph(line);
|
||||
});
|
||||
function appendPreviewParagraph(line) {
|
||||
var paragraph = document.createElement('p');
|
||||
paragraph.innerHTML = (line || '\u00a0').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\r?\n/g, '<br>').replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '<strong>$1</strong>').replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '<em>$1</em>').replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '<u>$1</u>');
|
||||
message.appendChild(paragraph);
|
||||
}
|
||||
preview.appendChild(brand);
|
||||
@@ -466,8 +430,6 @@
|
||||
|
||||
subject.addEventListener('input', render);
|
||||
body.addEventListener('input', render);
|
||||
if (alignment) alignment.addEventListener('change', render);
|
||||
if (buttonText) buttonText.addEventListener('input', render);
|
||||
editor.querySelectorAll('[data-email-format]').forEach(function (formatButton) {
|
||||
formatButton.addEventListener('click', function () {
|
||||
var tag = formatButton.getAttribute('data-email-format');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Account password page renderer and return URL normalization.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { resolvePasskeyRpID } = require('#src/web/lib/auth/better-auth');
|
||||
|
||||
function buildPasswordRequirementsText(requirements) {
|
||||
const requiredCategories = [];
|
||||
@@ -26,7 +27,7 @@ function normalizeReturnUrl(value) {
|
||||
return url;
|
||||
}
|
||||
|
||||
module.exports = function renderAccountPage(currentUser, message, returnUrl, allowUserSessionRevocation, sessions, passwordRequirements) {
|
||||
module.exports = function renderAccountPage(currentUser, message, returnUrl, allowUserSessionRevocation, sessions, passwordRequirements, authMethods, passkeys) {
|
||||
const requirements = passwordRequirements || {
|
||||
minimumLength: 10,
|
||||
minimumCategories: 3,
|
||||
@@ -45,13 +46,20 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl, all
|
||||
email: currentUser ? String(currentUser.email || '') : '',
|
||||
emailPending: Boolean(currentUser && currentUser.pending_email),
|
||||
pendingEmail: currentUser ? String(currentUser.pending_email || '') : '',
|
||||
emailVerified: Boolean(currentUser && currentUser.email_verified_at),
|
||||
pendingEmailChange: Boolean(currentUser && currentUser.pending_email && String(currentUser.pending_email).trim().toLowerCase() !== String(currentUser.email || '').trim().toLowerCase()),
|
||||
emailVerified: Boolean(currentUser && currentUser.email_verified),
|
||||
authMethods: Array.isArray(authMethods) ? authMethods : [],
|
||||
passkeys: Array.isArray(passkeys) ? passkeys : [],
|
||||
passkeyRpID: resolvePasskeyRpID(process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL),
|
||||
returnUrl: normalizeReturnUrl(returnUrl),
|
||||
allowUserSessionRevocation: Boolean(allowUserSessionRevocation),
|
||||
sessions: Array.isArray(sessions) ? sessions : [],
|
||||
passwordMinimumLength: requirements.minimumLength,
|
||||
passwordRequirementsText: buildPasswordRequirementsText(requirements),
|
||||
scripts: ['js/account/account-page.js']
|
||||
twoFactorEnabled: Boolean(currentUser && currentUser.twoFactorEnabled),
|
||||
requiresTwoFactor: Boolean(currentUser && currentUser.requiresTwoFactor),
|
||||
canManageTwoFactor: Boolean(currentUser && !currentUser.isViewingAs),
|
||||
scripts: ['vendor/qr-code-styling/qr-code-styling-loader.js', 'js/auth/passkey-client.js', 'js/account/two-factor.js', 'js/account/passkeys.js']
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+149
-89
@@ -1,33 +1,50 @@
|
||||
// Admin account route registration and profile helpers.
|
||||
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
const { formatUserAgentLabel } = require('#src/data/audit-log');
|
||||
const { createSyntheticAuthEmail, toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
|
||||
|
||||
module.exports = function registerAccountRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const betterAuth = deps.betterAuth;
|
||||
const fromNodeHeaders = deps.fromNodeHeaders;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
|
||||
const clearSessionCookie = deps.clearSessionCookie;
|
||||
const parseCookies = deps.parseCookies;
|
||||
const hashSessionToken = deps.hashSessionToken;
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
const sendAccountEmail = deps.sendAccountEmail;
|
||||
const createOneTimeToken = deps.createOneTimeToken;
|
||||
|
||||
async function verifyCurrentPassword(req, password) {
|
||||
try {
|
||||
const result = await betterAuth.api.verifyPassword({
|
||||
body: { password: password },
|
||||
headers: fromNodeHeaders(req.headers)
|
||||
});
|
||||
return !result || result.status !== false;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPasswordRequirements() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return buildPasswordRequirements(settings);
|
||||
}
|
||||
|
||||
async function getAuthMethods(userId) {
|
||||
const [rows] = await pool.query('SELECT provider_id FROM a_accounts WHERE user_id = ? ORDER BY created_at ASC', [userId]);
|
||||
return (rows || []).map(function (row) { return mapAuthMethod(row.provider_id); }).filter(Boolean);
|
||||
}
|
||||
|
||||
async function getPasskeys(userId) {
|
||||
const [rows] = await pool.query('SELECT id, name, created_at FROM a_user_passkeys WHERE user_id = ? ORDER BY created_at ASC', [userId]);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
function isPasswordChangeRequired(req) {
|
||||
return Boolean(req && (req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword));
|
||||
}
|
||||
@@ -51,67 +68,112 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionMetadata(req) {
|
||||
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
return {
|
||||
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim(),
|
||||
userAgent: req && req.headers && req.headers['user-agent']
|
||||
};
|
||||
}
|
||||
async function clearPendingEmailWithoutActiveOtp(user) {
|
||||
const pendingEmail = String(user && user.pending_email || '').trim().toLowerCase();
|
||||
if (!pendingEmail) return;
|
||||
const email = String(user.email || '').trim().toLowerCase();
|
||||
const identifier = pendingEmail === email
|
||||
? 'email-verification-otp-' + email
|
||||
: 'change-email-otp-' + email + '-' + pendingEmail;
|
||||
|
||||
function getRequestOrigin(req) {
|
||||
const forwardedProto = String(req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
|
||||
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
|
||||
return forwardedHost ? forwardedProto + '://' + forwardedHost : '';
|
||||
await pool.query('DELETE FROM a_verifications WHERE identifier = ? AND expires_at <= NOW()', [identifier]);
|
||||
const [result] = await pool.query(
|
||||
`UPDATE a_users
|
||||
SET pending_email = NULL
|
||||
WHERE id = ?
|
||||
AND pending_email = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM a_verifications
|
||||
WHERE identifier = ? AND expires_at > NOW()
|
||||
)`,
|
||||
[user.id, user.pending_email, identifier]
|
||||
);
|
||||
if (result && result.affectedRows) user.pending_email = null;
|
||||
}
|
||||
|
||||
app.post('/account/email', async function (req, res, next) {
|
||||
try {
|
||||
const email = String(req.body.email || '').trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
|
||||
const [rows] = await pool.query('SELECT pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const email = String(rows && rows[0] && rows[0].pending_email || '').trim().toLowerCase();
|
||||
if (!email) return res.redirect('/account?message=' + encodeURIComponent('There is no pending email change.'));
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.');
|
||||
const token = createOneTimeToken();
|
||||
const expiryMinutes = Number(settings['email.verification_expiry_minutes']);
|
||||
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE) WHERE id = ?', [email, hashSessionToken(token), req.currentUser.id]);
|
||||
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
|
||||
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.email_change_requested', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: email });
|
||||
res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.'));
|
||||
if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
|
||||
await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
|
||||
res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your new email address.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/account', function (req, res) {
|
||||
fetchAppSettings(pool).then(function (settings) {
|
||||
app.post('/account/email/send-verification', async function (req, res, next) {
|
||||
try {
|
||||
const email = String(req.currentUser.email || '').trim().toLowerCase();
|
||||
if (!email) return res.redirect('/account?message=' + encodeURIComponent('Add an email address before requesting a verification code.'));
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
|
||||
await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, req.currentUser.id]);
|
||||
await betterAuth.api.sendVerificationOTP({ body: { email: email, type: 'email-verification' } });
|
||||
res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your email address.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/account/email/verify', async function (req, res, next) {
|
||||
try {
|
||||
const otp = String(req.body.otp || '').trim();
|
||||
const [rows] = await pool.query('SELECT email, email_verified, pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows && rows[0];
|
||||
if (!user) return res.status(404).send('User not found.');
|
||||
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
|
||||
if (pendingEmail) {
|
||||
if (pendingEmail === String(user.email || '').trim().toLowerCase()) {
|
||||
await betterAuth.api.verifyEmailOTP({ body: { email: pendingEmail, otp: otp } });
|
||||
} else {
|
||||
await betterAuth.api.changeEmailEmailOTP({ body: { newEmail: pendingEmail, otp: otp }, headers: fromNodeHeaders(req.headers) });
|
||||
}
|
||||
await pool.query('UPDATE a_users SET email = ?, email_verified = 1, pending_email = NULL WHERE id = ?', [pendingEmail, req.currentUser.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_completed', targetType: 'user', targetId: req.currentUser.id, targetLabel: pendingEmail });
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Email address verified.'));
|
||||
}
|
||||
res.status(400).send('No email change is awaiting verification. Request a new code and try again.');
|
||||
} catch (error) {
|
||||
res.status(400).send('The email code is invalid or expired. Request a new code and try again.');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/account', async function (req, res) {
|
||||
try {
|
||||
await clearPendingEmailWithoutActiveOtp(req.currentUser);
|
||||
const settings = await fetchAppSettings(pool);
|
||||
const passwordRequirements = buildPasswordRequirements(settings);
|
||||
const authMethods = await getAuthMethods(req.currentUser.id);
|
||||
const passkeys = await getPasskeys(req.currentUser.id);
|
||||
if (!settings['security.allow_user_session_revocation']) {
|
||||
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements));
|
||||
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements, authMethods, passkeys));
|
||||
}
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const tokenHash = cookies[sessionCookieName] ? hashSessionToken(cookies[sessionCookieName]) : '';
|
||||
return pool.query(
|
||||
const sessionToken = String(req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName] || '');
|
||||
const result = await pool.query(
|
||||
'SELECT id, session_hash, ip_address, user_agent, created_at, last_used_at, expires_at FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at DESC',
|
||||
[req.currentUser.id]
|
||||
).then(function (result) {
|
||||
const rows = result[0] || [];
|
||||
const sessions = rows.map(function (session) {
|
||||
return {
|
||||
id: Number(session.id),
|
||||
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash),
|
||||
ipAddress: String(session.ip_address || 'Unknown'),
|
||||
userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
|
||||
createdAtLabel: formatDashboardDate(session.created_at),
|
||||
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
|
||||
expiresAtLabel: formatDashboardDate(session.expires_at)
|
||||
};
|
||||
});
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements));
|
||||
);
|
||||
const rows = result[0] || [];
|
||||
const sessions = rows.map(function (session) {
|
||||
return {
|
||||
id: Number(session.id),
|
||||
isCurrent: Boolean(sessionToken && session.session_hash === sessionToken),
|
||||
ipAddress: String(session.ip_address || 'Unknown'),
|
||||
userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
|
||||
createdAtLabel: formatDashboardDate(session.created_at),
|
||||
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
|
||||
expiresAtLabel: formatDashboardDate(session.expires_at)
|
||||
};
|
||||
});
|
||||
}).catch(function (error) {
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements, authMethods, passkeys));
|
||||
} catch (error) {
|
||||
res.status(500).send(error.message || 'Unable to load account settings.');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/account/password', async function (req, res, next) {
|
||||
@@ -137,37 +199,39 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
|
||||
if (!currentPassword) return res.status(400).send('Current password is required.');
|
||||
|
||||
const [rows] = await pool.query('SELECT id, username, name, email, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const [rows] = await pool.query('SELECT id, username, name, email, pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) return res.status(404).send('User not found.');
|
||||
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
|
||||
if (!(await verifyCurrentPassword(req, currentPassword))) return res.status(400).send('Current password is incorrect.');
|
||||
if (username !== user.username && await common.fetchDuplicateName(pool, 'a_users', username, user.id, 'username')) return res.status(400).send('That username already exists.');
|
||||
if (name !== user.name && await common.fetchDuplicateName(pool, 'a_users', name, user.id)) return res.status(400).send('That name already exists.');
|
||||
|
||||
const confirmedEmail = String(user.email || '').trim().toLowerCase();
|
||||
const confirmedEmail = toVisibleEmail(user.email).trim().toLowerCase();
|
||||
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
|
||||
const emailChanged = email !== confirmedEmail && email !== pendingEmail;
|
||||
const pendingEmailCleared = Boolean(pendingEmail && email === confirmedEmail);
|
||||
const settings = emailChanged ? await fetchAppSettings(pool) : null;
|
||||
if (emailChanged && email && (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function')) return res.status(400).send('Email delivery is not configured.');
|
||||
if (emailChanged && email && !settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('UPDATE a_users SET username = ?, name = ?, modified_by = ? WHERE id = ?', [username, name, actorId, user.id]);
|
||||
if (emailChanged || pendingEmailCleared) {
|
||||
if (!email) {
|
||||
await pool.query('UPDATE a_users SET email = NULL, email_verified_at = NULL, pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
|
||||
await pool.query('UPDATE a_users SET email = ?, email_verified = 0, pending_email = NULL WHERE id = ?', [createSyntheticAuthEmail(user.id), user.id]);
|
||||
} else if (pendingEmailCleared) {
|
||||
await pool.query('UPDATE a_users SET pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
|
||||
await pool.query('UPDATE a_users SET pending_email = NULL WHERE id = ?', [user.id]);
|
||||
} else {
|
||||
const token = createOneTimeToken();
|
||||
const expiryMinutes = Number(settings['email.verification_expiry_minutes']);
|
||||
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE) WHERE id = ?', [email, hashSessionToken(token), user.id]);
|
||||
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
|
||||
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
|
||||
await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, user.id]);
|
||||
try {
|
||||
await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
|
||||
} catch (error) {
|
||||
await pool.query('UPDATE a_users SET pending_email = NULL WHERE id = ?', [user.id]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (emailChanged && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: email ? 'user.email_change_requested' : 'user.email_cleared', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: email || user.email, details: { previousEmail: confirmedEmail || null, email: email || null, verificationRequired: Boolean(email) } });
|
||||
res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. Check your new email address for a verification link.' : 'Account details updated.'));
|
||||
res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. A verification code was sent to your new email address.' : 'Account details updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -184,13 +248,13 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
|
||||
}
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
|
||||
if (!token) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.'));
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
'DELETE FROM a_sessions WHERE id = ? AND user_id = ? AND session_hash <> ?',
|
||||
[sessionId, req.currentUser.id, hashSessionToken(token)]
|
||||
[sessionId, req.currentUser.id, token]
|
||||
);
|
||||
if (result && result.affectedRows && typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
@@ -216,9 +280,9 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
|
||||
}
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
|
||||
if (token) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, hashSessionToken(token)]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, token]);
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
@@ -266,10 +330,10 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
const currentPassword = String(req.body.current_password || '');
|
||||
if (!username) return res.status(400).send('Username is required.');
|
||||
if (!currentPassword) return res.status(400).send('Current password is required.');
|
||||
const [rows] = await pool.query('SELECT id, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const [rows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) return res.status(404).send('User not found.');
|
||||
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
|
||||
if (!(await verifyCurrentPassword(req, currentPassword))) return res.status(400).send('Current password is incorrect.');
|
||||
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, user.id]);
|
||||
if (existingRows.length) return res.status(400).send('That username already exists.');
|
||||
await pool.query('UPDATE a_users SET username = ?, modified_by = ? WHERE id = ?', [username, getAuditUserId(req), user.id]);
|
||||
@@ -285,13 +349,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
const currentPassword = String(req.body.current_password || '');
|
||||
const newPassword = String(req.body.new_password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (!verifyPassword(currentPassword, user)) {
|
||||
if (!currentPassword) {
|
||||
return sendPasswordError(req, res, 'Current password is incorrect.');
|
||||
}
|
||||
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
|
||||
@@ -302,28 +360,30 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return sendPasswordError(req, res, 'New passwords do not match.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(newPassword);
|
||||
await pool.query(
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
|
||||
);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
|
||||
try {
|
||||
await betterAuth.api.changePassword({
|
||||
body: { currentPassword: currentPassword, newPassword: newPassword, revokeOtherSessions: true },
|
||||
headers: fromNodeHeaders(req.headers)
|
||||
});
|
||||
} catch (_error) {
|
||||
return sendPasswordError(req, res, 'Current password is incorrect.');
|
||||
}
|
||||
await pool.query('UPDATE a_users SET must_change_password = 0, modified_by = ? WHERE id = ?', [getAuditUserId(req), req.currentUser.id]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [req.currentUser.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'password.changed',
|
||||
actorUserId: user.id,
|
||||
actorUserId: req.currentUser.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
targetLabel: user.username,
|
||||
targetId: req.currentUser.id,
|
||||
targetLabel: req.currentUser.username,
|
||||
details: { source: 'account' }
|
||||
});
|
||||
}
|
||||
|
||||
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
|
||||
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, getSessionMetadata(req));
|
||||
setSessionCookie(res, token, sessionMaxAgeMs);
|
||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||
if (typeof clearSessionCookie === 'function') clearSessionCookie(res);
|
||||
res.redirect('/login?message=' + encodeURIComponent('Password updated. Sign in again.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { buildAuditChanges, buildSlideAuditChanges } = require('#src/data/audit-log');
|
||||
const { replaceContentReferences } = require('#src/data/content-references');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
|
||||
@@ -644,7 +644,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const actorId = getAuditUserId(req);
|
||||
const changes = buildAuditChanges({
|
||||
const changes = buildSlideAuditChanges({
|
||||
title: slide.title,
|
||||
templateId: Number(slide.template_id),
|
||||
content: slide.content
|
||||
@@ -683,7 +683,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
screenSlideCounts: screenSlideCounts
|
||||
});
|
||||
await broadcastDashboardState();
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
|
||||
if (Object.keys(changes).length && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
|
||||
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||
});
|
||||
@@ -921,7 +921,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
details: { changes: buildAuditChanges(
|
||||
{ regions: normalizeTemplateRegionsForAudit(template.regions) },
|
||||
{ regions: normalizeTemplateRegionsForAudit(regions) }
|
||||
) }
|
||||
), ...(slideId ? { promotedSlideId: slideId } : {}) }
|
||||
});
|
||||
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
|
||||
} catch (error) {
|
||||
@@ -1005,7 +1005,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, {
|
||||
category: 'templates', eventType: 'template.updated', actorUserId: actorId,
|
||||
targetType: 'template', targetId: templateId, targetLabel: template.name,
|
||||
details: { changes: changes }
|
||||
details: { changes: changes, ...(slideId ? { promotedSlideId: slideId } : {}) }
|
||||
});
|
||||
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
|
||||
} catch (error) {
|
||||
@@ -1047,12 +1047,14 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
canvasSizeId: Number(template.canvas_size_id),
|
||||
backgroundImagePath: template.background_image_path,
|
||||
backgroundColor: template.background_color,
|
||||
backgroundGradient: template.background_gradient,
|
||||
regions: normalizeTemplateRegionsForAudit(template.regions)
|
||||
}, {
|
||||
name: payload.name,
|
||||
canvasSizeId: Number(payload.canvasSizeId),
|
||||
backgroundImagePath: payload.backgroundImagePath,
|
||||
backgroundColor: payload.backgroundColor,
|
||||
backgroundGradient: payload.backgroundGradient,
|
||||
regions: normalizeTemplateRegionsForAudit(payload.regions)
|
||||
});
|
||||
connection = await pool.getConnection();
|
||||
|
||||
@@ -19,6 +19,33 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function fetchPlaylistAuditItems(connection, playlistId) {
|
||||
const [items] = await connection.query(
|
||||
`SELECT id, slide_id, duration_seconds, use_video_duration, disable_audio,
|
||||
group_key, group_name, group_pick_count, group_shuffle, group_mode,
|
||||
group_repeat_count, group_schedule_rules_json
|
||||
FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC`,
|
||||
[playlistId]
|
||||
);
|
||||
const [rules] = await connection.query(
|
||||
`SELECT r.playlist_slide_id, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json
|
||||
FROM c_playlist_slide_schedule_rules r
|
||||
INNER JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
|
||||
WHERE ps.playlist_id = ? ORDER BY r.position ASC, r.id ASC`,
|
||||
[playlistId]
|
||||
);
|
||||
const rulesByItem = new Map();
|
||||
rules.forEach(function ({ playlist_slide_id: itemId, ...rule }) {
|
||||
if (!rulesByItem.has(Number(itemId))) rulesByItem.set(Number(itemId), []);
|
||||
if (typeof rule.schedule_days_json === 'string') rule.schedule_days_json = JSON.parse(rule.schedule_days_json);
|
||||
rulesByItem.get(Number(itemId)).push(rule);
|
||||
});
|
||||
return items.map(function ({ id, ...item }) {
|
||||
if (typeof item.group_schedule_rules_json === 'string') item.group_schedule_rules_json = JSON.parse(item.group_schedule_rules_json);
|
||||
return Object.assign(item, { scheduleRules: rulesByItem.get(Number(id)) || [] });
|
||||
});
|
||||
}
|
||||
|
||||
function readRawFieldArray(reqBody, keys) {
|
||||
const searchKeys = Array.isArray(keys) ? keys : [keys];
|
||||
for (let index = 0; index < searchKeys.length; index += 1) {
|
||||
@@ -456,7 +483,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
}
|
||||
}
|
||||
|
||||
const previousItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
|
||||
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
|
||||
const nextItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
||||
await broadcastDashboardState();
|
||||
@@ -465,12 +494,14 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
name: playlist.name,
|
||||
fadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id)
|
||||
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id),
|
||||
items: previousItems
|
||||
}, {
|
||||
name: name,
|
||||
fadeBetweenSlides: Boolean(fadeBetweenSlides),
|
||||
skipUnavailableRtmp: Boolean(skipUnavailableRtmp),
|
||||
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId)
|
||||
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId),
|
||||
items: nextItems
|
||||
});
|
||||
await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } });
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@
|
||||
return {
|
||||
formValues: {
|
||||
name: common.validateMaxLength(formValues && formValues.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'),
|
||||
description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description')
|
||||
description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description'),
|
||||
require_two_factor: Boolean(formValues && formValues.require_two_factor)
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(data.users, selectedUserIds),
|
||||
@@ -247,7 +248,8 @@
|
||||
const permissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||
const viewModel = await buildRoleCreateViewModel({
|
||||
name: buildDuplicateRoleName(role.name),
|
||||
description: buildDuplicateRole(role).description
|
||||
description: buildDuplicateRole(role).description,
|
||||
require_two_factor: buildDuplicateRole(role).require_two_factor
|
||||
}, permissionKeys, [], page, search, sort, direction, currentUserId);
|
||||
|
||||
res.send(pages.renderRbacAddPage('Review the copied values and save when ready.', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
|
||||
@@ -260,6 +262,7 @@
|
||||
try {
|
||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||
const requireTwoFactor = req.body.require_two_factor === '1';
|
||||
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
@@ -277,7 +280,7 @@
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description, require_two_factor: requireTwoFactor }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
|
||||
}
|
||||
@@ -308,8 +311,8 @@
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO a_roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, actorId, actorId]
|
||||
'INSERT INTO a_roles (role_key, name, description, require_two_factor, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, requireTwoFactor ? 1 : 0, actorId, actorId]
|
||||
);
|
||||
insertedRoleId = Number(result.insertId);
|
||||
if (selectedPermissionKeys.length) {
|
||||
@@ -331,7 +334,7 @@
|
||||
targetType: 'role',
|
||||
targetId: insertedRoleId,
|
||||
targetLabel: name,
|
||||
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds }
|
||||
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds, requireTwoFactor: requireTwoFactor }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/roles/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
|
||||
@@ -383,6 +386,7 @@
|
||||
|
||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||
const requireTwoFactor = req.body.require_two_factor === '1';
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
@@ -440,8 +444,8 @@
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE a_roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, getAuditUserId(req), roleId]
|
||||
'UPDATE a_roles SET name = ?, description = ?, require_two_factor = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, requireTwoFactor ? 1 : 0, getAuditUserId(req), roleId]
|
||||
);
|
||||
if (shouldSyncPermissions) {
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
@@ -460,11 +464,13 @@
|
||||
const changes = buildAuditChanges({
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
requireTwoFactor: Boolean(role.require_two_factor),
|
||||
permissionKeys: existingRolePermissionKeys,
|
||||
userIds: existingRoleUserIds
|
||||
}, {
|
||||
name: name,
|
||||
description: description || null,
|
||||
requireTwoFactor: requireTwoFactor,
|
||||
permissionKeys: normalizedPermissionKeys,
|
||||
userIds: normalizedUserIds
|
||||
});
|
||||
|
||||
+119
-171
@@ -1,8 +1,8 @@
|
||||
// Admin user route registration and user-role management.
|
||||
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
const { buildAuditChanges, formatUserAgentLabel } = require('#src/data/audit-log');
|
||||
const { createSyntheticAuthEmail, toVisibleEmail, hashCredentialPassword, setCredentialPassword } = require('#src/data/auth-accounts');
|
||||
|
||||
module.exports = function registerUsersRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -11,11 +11,6 @@
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
const sendAccountEmail = deps.sendAccountEmail;
|
||||
const createOneTimeToken = deps.createOneTimeToken;
|
||||
const hashSessionToken = deps.hashSessionToken;
|
||||
const getRequestOrigin = deps.getRequestOrigin;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
@@ -34,11 +29,6 @@
|
||||
return rbacData.fetchRoles(pool);
|
||||
}
|
||||
|
||||
async function shouldRequirePasswordChange(settingKey) {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return Boolean(settings[settingKey]);
|
||||
}
|
||||
|
||||
async function getPasswordRequirements() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return {
|
||||
@@ -85,23 +75,6 @@
|
||||
return { ok: true, roleIds: normalizedRoleIds };
|
||||
}
|
||||
|
||||
function mapInvitationForView(invitation, roleNamesById) {
|
||||
let roleIds = [];
|
||||
try {
|
||||
roleIds = JSON.parse(invitation.role_ids_json || '[]');
|
||||
} catch (error) {
|
||||
roleIds = [];
|
||||
}
|
||||
return Object.assign({}, invitation, {
|
||||
roleNames: roleIds.map(function (roleId) {
|
||||
return roleNamesById.get(Number(roleId));
|
||||
}).filter(Boolean).join(', ') || 'No roles assigned',
|
||||
createdAtLabel: formatDashboardDate(invitation.created_at),
|
||||
expiresAtLabel: formatDashboardDate(invitation.expires_at),
|
||||
createdByLabel: invitation.created_by_username || 'System'
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/settings/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
@@ -126,77 +99,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/settings/invitations', requirePermission('invitations.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const search = common.getSearchQuery(req);
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await rbacData.fetchInvitationsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const roles = await fetchRoleOptions();
|
||||
const roleNamesById = new Map((roles || []).map(function (role) {
|
||||
return [Number(role.id), role.name];
|
||||
}));
|
||||
const invitations = (data.invitations || []).map(function (invitation) {
|
||||
return mapInvitationForView(invitation, roleNamesById);
|
||||
});
|
||||
res.send(pages.renderInvitationsPage({
|
||||
invitations: invitations,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'invitations', 'Invitation pages'),
|
||||
message: req.query.message ? String(req.query.message) : ''
|
||||
}, req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/invitations/:id/delete', requirePermission('invitations.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const invitationId = Number(req.params.id);
|
||||
if (!Number.isInteger(invitationId) || invitationId <= 0) {
|
||||
return res.status(400).send('Invalid invitation.');
|
||||
}
|
||||
const [result] = await pool.query('DELETE FROM a_user_invitations WHERE id = ? AND used_at IS NULL', [invitationId]);
|
||||
if (!result || !result.affectedRows) {
|
||||
return res.status(404).send('Invitation not found.');
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_deleted', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId });
|
||||
res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/invitations/:id/resend', requirePermission('invitations.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const invitationId = Number(req.params.id);
|
||||
if (!Number.isInteger(invitationId) || invitationId <= 0) {
|
||||
return res.status(400).send('Invalid invitation.');
|
||||
}
|
||||
if (typeof sendAccountEmail !== 'function' || typeof createOneTimeToken !== 'function' || typeof hashSessionToken !== 'function') {
|
||||
return res.status(503).send('Email delivery is not available.');
|
||||
}
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (!settings['email.smtp_enabled']) {
|
||||
return res.status(503).send('Email delivery is not configured.');
|
||||
}
|
||||
const [rows] = await pool.query('SELECT email, name, role_ids_json FROM a_user_invitations WHERE id = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [invitationId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('Invitation not found.');
|
||||
}
|
||||
const invitation = rows[0];
|
||||
const token = createOneTimeToken();
|
||||
const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token);
|
||||
const expiryHours = Number(settings['email.invitation_expiry_hours']);
|
||||
await sendAccountEmail(settings, Object.assign({ to: invitation.email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: invitation.name || 'there', email: invitation.email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] })));
|
||||
await pool.query('UPDATE a_user_invitations SET token_hash = ?, expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), created_at = NOW(), created_by = ? WHERE id = ? AND used_at IS NULL', [hashSessionToken(token), getAuditUserId(req), invitationId]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_resent', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId, targetLabel: invitation.email });
|
||||
res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation resent.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/settings/users/new', requirePermission('users.create'), function (req, res) {
|
||||
fetchRoleOptions().then(function (roles) {
|
||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
||||
@@ -205,54 +107,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/settings/users/invite', requirePermission('invitations.create'), async function (req, res, next) {
|
||||
try {
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersInvitePage('', req.currentUser, mapRolesForForm(roles, []), {}, 'success'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/users/invite', requirePermission('invitations.create'), async function (req, res, next) {
|
||||
try {
|
||||
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
|
||||
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = { email: email, name: name };
|
||||
async function renderInviteError(message) {
|
||||
const roles = await fetchRoleOptions();
|
||||
return res.status(400).send(pages.renderUsersInvitePage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
|
||||
}
|
||||
if (!email || !EMAIL_PATTERN.test(email)) return renderInviteError('Email address is invalid.');
|
||||
if (!name) return renderInviteError('Display name is required.');
|
||||
if (!roleCheck.ok) return renderInviteError(roleCheck.message);
|
||||
if (typeof sendAccountEmail !== 'function' || typeof createOneTimeToken !== 'function' || typeof hashSessionToken !== 'function') return renderInviteError('Email delivery is not available.');
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (!settings['email.smtp_enabled']) return renderInviteError('Email delivery is not configured.');
|
||||
const [existingUsers] = await pool.query('SELECT id FROM a_users WHERE email = ? OR pending_email = ? LIMIT 1', [email, email]);
|
||||
if (existingUsers.length) return renderInviteError('That email address is already associated with an account.');
|
||||
const [recentInvites] = await pool.query('SELECT COUNT(*) AS invite_count FROM a_user_invitations WHERE created_by = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)', [getAuditUserId(req)]);
|
||||
if (Number(recentInvites[0] && recentInvites[0].invite_count) >= 25) return renderInviteError('Invitation sending is temporarily limited. Try again later.');
|
||||
const token = createOneTimeToken();
|
||||
await pool.query('UPDATE a_user_invitations SET used_at = NOW() WHERE email = ? AND used_at IS NULL', [email]);
|
||||
const expiryHours = Number(settings['email.invitation_expiry_hours']);
|
||||
await pool.query('INSERT INTO a_user_invitations (email, name, role_ids_json, token_hash, expires_at, created_by) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), ?)', [email, name || null, JSON.stringify(roleCheck.roleIds), hashSessionToken(token), getAuditUserId(req)]);
|
||||
const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token);
|
||||
try {
|
||||
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: name || 'there', email: email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] })));
|
||||
} catch (mailError) {
|
||||
await pool.query('DELETE FROM a_user_invitations WHERE token_hash = ?', [hashSessionToken(token)]);
|
||||
throw mailError;
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_sent', actorUserId: getAuditUserId(req), targetType: 'email', targetLabel: email, details: { roleIds: roleCheck.roleIds } });
|
||||
res.redirect('/settings/users?message=' + encodeURIComponent('Invitation sent.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/settings/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
@@ -360,6 +214,91 @@
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/users/:id/two-factor/unlink', requirePermission('users.update'), async function (req, res, next) {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return res.status(400).send('Invalid user.');
|
||||
if (Number(req.currentUser.id) === userId) return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own two-step verification.'));
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [userRows] = await connection.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
const [factorRows] = await connection.query('SELECT id FROM a_user_two_factors WHERE user_id = ? LIMIT 1', [userId]);
|
||||
if (!factorRows.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('No authenticator is linked to this user.'));
|
||||
}
|
||||
|
||||
await connection.query('DELETE FROM a_user_two_factors WHERE user_id = ?', [userId]);
|
||||
await connection.query('UPDATE a_users SET two_factor_enabled = 0, modified_by = ? WHERE id = ?', [getAuditUserId(req), userId]);
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
await connection.commit();
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'user.two_factor_unlinked',
|
||||
actorUserId: getAuditUserId(req),
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: String(userId)
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Two-step verification removed. The user must sign in again.'));
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) {}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/users/:id/passkeys/:passkeyId/unlink', requirePermission('users.update'), async function (req, res, next) {
|
||||
const userId = Number(req.params.id);
|
||||
const passkeyId = String(req.params.passkeyId || '').trim();
|
||||
if (!Number.isInteger(userId) || userId <= 0 || !passkeyId || passkeyId.length > 191) return res.status(400).send('Invalid passkey.');
|
||||
if (Number(req.currentUser.id) === userId) return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own passkeys.'));
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [userRows] = await connection.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
const [passkeyRows] = await connection.query('SELECT id FROM a_user_passkeys WHERE id = ? AND user_id = ? LIMIT 1', [passkeyId, userId]);
|
||||
if (!passkeyRows.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Passkey was not found for this user.'));
|
||||
}
|
||||
|
||||
await connection.query('DELETE FROM a_user_passkeys WHERE id = ? AND user_id = ?', [passkeyId, userId]);
|
||||
await connection.query('UPDATE a_users SET modified_by = ? WHERE id = ?', [getAuditUserId(req), userId]);
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
await connection.commit();
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'user.passkey_unlinked',
|
||||
actorUserId: getAuditUserId(req),
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: String(userId)
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Passkey removed. The user must sign in again.'));
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) {}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
@@ -368,13 +307,15 @@
|
||||
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const mustChangePassword = req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'));
|
||||
const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase();
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
username: username,
|
||||
name: name,
|
||||
email: email
|
||||
email: email,
|
||||
must_change_password: mustChangePassword
|
||||
};
|
||||
|
||||
async function renderValidationError(message) {
|
||||
@@ -410,14 +351,14 @@
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_for_new_users');
|
||||
const encodedPassword = await hashCredentialPassword(password);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO a_users (name, username, email, password_hash, password_salt, password_iterations, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, email || null, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, actorId, actorId]
|
||||
'INSERT INTO a_users (name, username, email, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[name, username, email || createSyntheticAuthEmail(), mustChangePassword ? 1 : 0, actorId, actorId]
|
||||
);
|
||||
await setCredentialPassword(connection, result.insertId, encodedPassword);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
@@ -515,6 +456,8 @@
|
||||
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
|
||||
const accountLocked = req.body.account_locked === '1' || (Array.isArray(req.body.account_locked) && req.body.account_locked.includes('1'));
|
||||
const emailVerified = req.body.email_verified === '1' || (Array.isArray(req.body.email_verified) && req.body.email_verified.includes('1'));
|
||||
const mustChangePassword = req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'));
|
||||
const storedEmail = email || createSyntheticAuthEmail(userId);
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
||||
@@ -525,6 +468,7 @@
|
||||
return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
name: name || user.name,
|
||||
username: username || user.username,
|
||||
must_change_password: mustChangePassword,
|
||||
roleIds: selectedRoleIds,
|
||||
inUse: !canDelete
|
||||
}), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds)));
|
||||
@@ -559,6 +503,10 @@
|
||||
if (existingRows.length) {
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
if (email) {
|
||||
const [existingEmailRows] = await pool.query('SELECT id FROM a_users WHERE email = ? AND id <> ? LIMIT 1', [email, userId]);
|
||||
if (existingEmailRows.length) return renderValidationError('That email address is already associated with another account.');
|
||||
}
|
||||
|
||||
const changes = buildAuditChanges({
|
||||
name: user.name,
|
||||
@@ -566,6 +514,7 @@
|
||||
email: user.email || '',
|
||||
roleIds: user.roleIds,
|
||||
accountLocked: Boolean(user.account_locked),
|
||||
mustChangePassword: Boolean(user.must_change_password),
|
||||
passwordReset: false
|
||||
}, {
|
||||
name: name,
|
||||
@@ -573,29 +522,28 @@
|
||||
email: email,
|
||||
roleIds: roleCheck.roleIds,
|
||||
accountLocked: accountLocked,
|
||||
mustChangePassword: mustChangePassword,
|
||||
passwordReset: shouldUpdatePassword
|
||||
});
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
|
||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, must_change_password = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
await connection.query('UPDATE a_users SET email = ?, email_verified_at = CASE WHEN email = ? THEN email_verified_at ELSE NULL END, modified_by = ? WHERE id = ?', [email || null, email || null, getAuditUserId(req), userId]);
|
||||
if (emailVerified && email && (await fetchAppSettings(pool))['security.allow_admin_email_verification_bypass']) {
|
||||
await connection.query('UPDATE a_users SET email_verified_at = NOW() WHERE id = ? AND email = ?', [userId, email]);
|
||||
}
|
||||
const emailIsUnchanged = Boolean(email && email === user.email);
|
||||
let verifiedEmail = Boolean(emailIsUnchanged && user.email_verified);
|
||||
if (emailVerified && email && (await fetchAppSettings(pool))['security.allow_admin_email_verification_bypass']) verifiedEmail = true;
|
||||
if (!email) verifiedEmail = false;
|
||||
await connection.query('UPDATE a_users SET email = ?, email_verified = ?, modified_by = ? WHERE id = ?', [storedEmail, verifiedEmail ? 1 : 0, getAuditUserId(req), userId]);
|
||||
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
|
||||
if (accountLocked) {
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
}
|
||||
if (shouldUpdatePassword) {
|
||||
const passwordRecord = hashPassword(password);
|
||||
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
|
||||
await connection.query(
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
|
||||
);
|
||||
const encodedPassword = await hashCredentialPassword(password);
|
||||
await setCredentialPassword(connection, userId, encodedPassword);
|
||||
await connection.query('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?', [mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
}
|
||||
await connection.commit();
|
||||
@@ -630,7 +578,7 @@
|
||||
details: { source: 'administrator' }
|
||||
});
|
||||
}
|
||||
if (emailVerified && !user.email_verified_at && email) {
|
||||
if (emailVerified && !user.email_verified && verifiedEmail && email) {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'email.verification_bypassed',
|
||||
@@ -679,17 +627,17 @@
|
||||
return res.redirect(editUrl + '?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
const [rows] = await pool.query('SELECT id, username, must_change_password FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
|
||||
await pool.query(
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
|
||||
);
|
||||
const encodedPassword = await hashCredentialPassword(password);
|
||||
const mustChangePassword = Object.prototype.hasOwnProperty.call(req.body || {}, 'must_change_password')
|
||||
? req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'))
|
||||
: Boolean(rows[0].must_change_password);
|
||||
await setCredentialPassword(pool, userId, encodedPassword);
|
||||
await pool.query('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?', [mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
|
||||
@@ -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.' });
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderEmailVerifiedPage() {
|
||||
return renderView('auth/email-verified', { title: 'Email verified', authShell: true, bodyClass: 'login-page-body' });
|
||||
};
|
||||
+21
-265
@@ -2,7 +2,6 @@
|
||||
|
||||
const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
const { hasPermission } = require('#src/rbac');
|
||||
|
||||
const DEFAULT_LANDING_ROUTES = [
|
||||
@@ -21,7 +20,6 @@ const DEFAULT_LANDING_ROUTES = [
|
||||
['weather.read', '/data-sources/weather'],
|
||||
['users.read', '/settings/users'],
|
||||
['rbac.read', '/settings/roles'],
|
||||
['invitations.read', '/settings/invitations'],
|
||||
['fonts.read', '/settings/fonts'],
|
||||
['weather-icons.read', '/settings/weather-icons'],
|
||||
['background-tasks.read', '/settings/tasks-background'],
|
||||
@@ -41,81 +39,18 @@ function getDefaultLandingPath(currentUser) {
|
||||
module.exports = function registerAuthRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
|
||||
const betterAuth = deps.betterAuth;
|
||||
const clearSessionCookie = deps.clearSessionCookie;
|
||||
const parseCookies = deps.parseCookies;
|
||||
const consumeAuthMessageCookie = deps.consumeAuthMessageCookie;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
const hashSessionToken = deps.hashSessionToken;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
const sendAccountEmail = deps.sendAccountEmail;
|
||||
const createOneTimeToken = deps.createOneTimeToken;
|
||||
const getConnection = deps.getConnection;
|
||||
const rbacData = deps.rbacData;
|
||||
|
||||
function getReturnTo(req) {
|
||||
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(req));
|
||||
}
|
||||
|
||||
function buildLoginRedirect(returnTo, username) {
|
||||
const query = [];
|
||||
if (returnTo) query.push('returnTo=' + encodeURIComponent(returnTo));
|
||||
if (username) query.push('username=' + encodeURIComponent(username));
|
||||
return query.length ? '/login?' + query.join('&') : '/login';
|
||||
}
|
||||
|
||||
function getClientAddress(req) {
|
||||
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
return forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim() || 'unknown';
|
||||
}
|
||||
|
||||
function getRateLimitKey(username, address, scope) {
|
||||
if (scope === 'username') return 'username:' + username;
|
||||
if (scope === 'ip') return 'ip:' + address;
|
||||
return 'both:' + JSON.stringify([username, address]);
|
||||
}
|
||||
|
||||
async function getLoginRateLimitSettings() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return {
|
||||
maxAttempts: Number(settings['security.login_max_attempts']) || 5,
|
||||
lockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
|
||||
scope: String(settings['security.login_rate_limit_scope'] || 'both')
|
||||
};
|
||||
}
|
||||
|
||||
async function getLoginRateLimitStatus(rateKey) {
|
||||
const [rows] = await pool.query('SELECT locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
|
||||
const lockedUntil = rows && rows[0] && rows[0].locked_until ? new Date(rows[0].locked_until).getTime() : 0;
|
||||
return {
|
||||
limited: lockedUntil > Date.now(),
|
||||
remainingMinutes: Math.max(1, Math.ceil((lockedUntil - Date.now()) / 60000))
|
||||
};
|
||||
}
|
||||
|
||||
async function recordFailedLogin(rateKey, settings) {
|
||||
const [rows] = await pool.query('SELECT failed_count, locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
|
||||
const existing = rows && rows[0] ? rows[0] : null;
|
||||
const existingLockedUntil = existing && existing.locked_until ? new Date(existing.locked_until).getTime() : 0;
|
||||
const currentCount = existingLockedUntil && existingLockedUntil <= Date.now() ? 0 : Number(existing && existing.failed_count) || 0;
|
||||
const failedCount = currentCount + 1;
|
||||
const lockedUntil = failedCount >= settings.maxAttempts ? new Date(Date.now() + settings.lockoutMinutes * 60 * 1000) : null;
|
||||
if (existing) {
|
||||
await pool.query('UPDATE a_login_attempts SET failed_count = ?, last_failed_at = ?, locked_until = ? WHERE rate_key = ?', [failedCount, new Date(), lockedUntil, rateKey]);
|
||||
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
|
||||
}
|
||||
await pool.query('INSERT INTO a_login_attempts (rate_key, failed_count, last_failed_at, locked_until) VALUES (?, ?, ?, ?)', [rateKey, failedCount, new Date(), lockedUntil]);
|
||||
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
|
||||
}
|
||||
|
||||
async function clearFailedLogins(rateKey) {
|
||||
await pool.query('DELETE FROM a_login_attempts WHERE rate_key = ?', [rateKey]);
|
||||
}
|
||||
|
||||
async function getPasswordRequirements() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return {
|
||||
@@ -128,55 +63,6 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/accept-invite', async function (req, res, next) {
|
||||
try {
|
||||
const token = String(req.query.token || '').trim();
|
||||
const [rows] = await pool.query('SELECT email, name FROM a_user_invitations WHERE token_hash = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [hashSessionToken(token)]);
|
||||
const invitation = rows[0] || null;
|
||||
if (!invitation) return res.status(400).send(pages.renderAcceptInvitePage('This invitation is invalid or has expired.', token, '', '', await getPasswordRequirements(), false));
|
||||
res.send(pages.renderAcceptInvitePage('', token, invitation.email, invitation.name, await getPasswordRequirements(), true));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/accept-invite', async function (req, res, next) {
|
||||
const connection = typeof getConnection === 'function' ? await getConnection() : pool;
|
||||
try {
|
||||
const token = String(req.body.token || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const name = String(req.body.name || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const [rows] = await connection.query('SELECT id, email, name, role_ids_json FROM a_user_invitations WHERE token_hash = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [hashSessionToken(token)]);
|
||||
const invitation = rows[0] || null;
|
||||
const requirements = await getPasswordRequirements();
|
||||
const renderError = function (message) { return res.status(400).send(pages.renderAcceptInvitePage(message, token, invitation ? invitation.email : '', name || (invitation && invitation.name) || '', requirements, Boolean(invitation))); };
|
||||
if (!invitation) return renderError('This invitation is invalid or has expired.');
|
||||
if (!username) return renderError('Username is required.');
|
||||
if (!name) return renderError('Name is required.');
|
||||
const [existingUsers] = await connection.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingUsers.length) return renderError('That username already exists.');
|
||||
const strengthMessage = deps.validatePasswordStrength(password, requirements);
|
||||
if (strengthMessage) return renderError(strengthMessage);
|
||||
if (password !== confirmPassword) return renderError('Passwords do not match.');
|
||||
const passwordRecord = deps.hashPassword(password);
|
||||
const roleIds = JSON.parse(invitation.role_ids_json || '[]');
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query('INSERT INTO a_users (name, username, email, email_verified_at, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, NOW(), ?, ?, ?, NULL, NULL)', [name, username, invitation.email, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations]);
|
||||
if (rbacData && typeof rbacData.syncUserRoles === 'function') await rbacData.syncUserRoles(connection, result.insertId, roleIds);
|
||||
await connection.query('UPDATE a_user_invitations SET used_at = NOW() WHERE id = ? AND used_at IS NULL', [invitation.id]);
|
||||
await connection.commit();
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_accepted', targetType: 'user', targetId: result.insertId, targetLabel: username, details: { invitationId: invitation.id } });
|
||||
res.redirect('/login?message=' + encodeURIComponent('Account created. You can now sign in.'));
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) {}
|
||||
next(error);
|
||||
} finally {
|
||||
if (connection !== pool && connection && typeof connection.release === 'function') connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/forgot-password', function (req, res) {
|
||||
res.send(pages.renderForgotPasswordPage(''));
|
||||
});
|
||||
@@ -184,24 +70,15 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
app.post('/forgot-password', async function (req, res, next) {
|
||||
try {
|
||||
const identity = String(req.body.identity || '').trim();
|
||||
const genericMessage = 'If that account has a verified email address, a reset link has been sent.';
|
||||
const [rows] = await pool.query('SELECT id, name, username, email FROM a_users WHERE username = ? OR (email = ? AND email_verified_at IS NOT NULL) LIMIT 1', [identity, identity.toLowerCase()]);
|
||||
const genericMessage = 'If that account has a verified email address, a reset code has been sent.';
|
||||
const [rows] = await pool.query('SELECT id, email, email_verified FROM a_users WHERE username = ? OR (email = ? AND email_verified = 1) LIMIT 1', [identity, identity.toLowerCase()]);
|
||||
const user = rows[0] || null;
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (user && user.email && user.email_verified_at && typeof sendAccountEmail === 'function' && typeof createOneTimeToken === 'function') {
|
||||
const token = createOneTimeToken();
|
||||
await pool.query('DELETE FROM a_account_tokens WHERE user_id = ? AND token_type = ?', [user.id, 'password-reset']);
|
||||
const expiryMinutes = Number(settings['email.reset_expiry_minutes']);
|
||||
await pool.query('INSERT INTO a_account_tokens (user_id, token_type, token_hash, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE))', [user.id, 'password-reset', hashSessionToken(token)]);
|
||||
const resetUrl = getRequestOrigin(req) + '/reset-password?token=' + encodeURIComponent(token);
|
||||
try {
|
||||
await sendAccountEmail(settings, Object.assign({ to: user.email }, renderAccountEmailTemplate(settings['email.reset_subject'], settings['email.reset_body'], { url: resetUrl, username: user.username, display_name: user.name, email: user.email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.reset_button_alignment'], action_label: settings['email.reset_button_text'] })));
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset_requested', targetType: 'user', targetId: user.id, targetLabel: user.username });
|
||||
} catch (_mailError) {
|
||||
await pool.query('DELETE FROM a_account_tokens WHERE token_hash = ?', [hashSessionToken(token)]);
|
||||
}
|
||||
if (user && user.email && user.email_verified && settings['email.smtp_enabled']) {
|
||||
await betterAuth.api.requestPasswordResetEmailOTP({ body: { email: user.email } });
|
||||
}
|
||||
res.send(pages.renderForgotPasswordPage(genericMessage));
|
||||
const resetEmail = identity.indexOf('@') !== -1 ? identity.toLowerCase() : '';
|
||||
res.send(pages.renderResetPasswordPage(genericMessage, resetEmail, await getPasswordRequirements()));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -209,9 +86,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
|
||||
app.get('/reset-password', async function (req, res, next) {
|
||||
try {
|
||||
const token = String(req.query.token || '').trim();
|
||||
if (!token) return res.redirect('/forgot-password');
|
||||
res.send(pages.renderResetPasswordPage('', token, await getPasswordRequirements()));
|
||||
res.send(pages.renderResetPasswordPage('', String(req.query.email || ''), await getPasswordRequirements()));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -219,34 +94,19 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
|
||||
app.post('/reset-password', async function (req, res, next) {
|
||||
try {
|
||||
const token = String(req.body.token || '');
|
||||
const email = String(req.body.email || '').trim().toLowerCase();
|
||||
const otp = String(req.body.otp || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const [rows] = await pool.query('SELECT t.id AS token_id, t.user_id, u.username, u.email FROM a_account_tokens t JOIN a_users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.token_type = ? AND t.used_at IS NULL AND t.expires_at > NOW() LIMIT 1', [hashSessionToken(token), 'password-reset']);
|
||||
const record = rows[0] || null;
|
||||
if (!record) return res.status(400).send(pages.renderResetPasswordPage('This reset link is invalid or has expired.', token, await getPasswordRequirements()));
|
||||
const strengthMessage = deps.validatePasswordStrength(password, await getPasswordRequirements());
|
||||
if (strengthMessage || password !== confirmPassword) return res.status(400).send(pages.renderResetPasswordPage(strengthMessage || 'Passwords do not match.', token, await getPasswordRequirements()));
|
||||
const passwordRecord = deps.hashPassword(password);
|
||||
await pool.query('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_at = CURRENT_TIMESTAMP WHERE id = ?', [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, record.user_id]);
|
||||
await pool.query('UPDATE a_account_tokens SET used_at = NOW() WHERE id = ?', [record.token_id]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [record.user_id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset', targetType: 'user', targetId: record.user_id, targetLabel: record.username, details: { source: 'email' } });
|
||||
res.redirect('/login?message=' + encodeURIComponent('Password updated. You can now sign in.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/verify-email', async function (req, res, next) {
|
||||
try {
|
||||
const tokenHash = hashSessionToken(String(req.query.token || ''));
|
||||
const [rows] = await pool.query('SELECT id FROM a_users WHERE pending_email_token_hash = ? AND pending_email_expires_at > NOW() LIMIT 1', [tokenHash]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) return res.status(400).send(pages.renderEmailVerificationErrorPage('This email verification link is invalid or has expired.'));
|
||||
await pool.query('UPDATE a_users SET email = pending_email, email_verified_at = NOW(), pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_completed', targetType: 'user', targetId: user.id });
|
||||
res.send(pages.renderEmailVerifiedPage());
|
||||
const requirements = await getPasswordRequirements();
|
||||
const strengthMessage = deps.validatePasswordStrength(password, requirements);
|
||||
if (strengthMessage || password !== confirmPassword) return res.status(400).send(pages.renderResetPasswordPage(strengthMessage || 'Passwords do not match.', email, requirements));
|
||||
try {
|
||||
await betterAuth.api.resetPasswordEmailOTP({ body: { email: email, otp: otp, password: password } });
|
||||
} catch (error) {
|
||||
return res.status(400).send(pages.renderResetPasswordPage('The email code is invalid or expired. Request a new code and try again.', email, requirements));
|
||||
}
|
||||
res.send(pages.renderForgotPasswordPage('Password updated. You can now sign in.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -279,116 +139,12 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/login', async function (req, res, next) {
|
||||
try {
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
if (!username || !password) {
|
||||
return res.status(400).send('Username and password are required.');
|
||||
}
|
||||
|
||||
const loginRateLimitSettings = await getLoginRateLimitSettings();
|
||||
const rateKey = getRateLimitKey(username.toLowerCase(), getClientAddress(req), loginRateLimitSettings.scope);
|
||||
const rateLimitStatus = await getLoginRateLimitStatus(rateKey);
|
||||
if (rateLimitStatus.limited) {
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'authentication',
|
||||
eventType: 'login.locked_out',
|
||||
targetType: 'user',
|
||||
targetLabel: username,
|
||||
details: { reason: 'rate_limit' }
|
||||
});
|
||||
}
|
||||
const message = 'Too many failed login attempts. Try again in ' + rateLimitStatus.remainingMinutes + ' minute' + (rateLimitStatus.remainingMinutes === 1 ? '' : 's') + '.';
|
||||
const returnTo = getReturnTo(req);
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, message);
|
||||
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||
}
|
||||
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||
}
|
||||
|
||||
const [usernameRows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations, account_locked FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
let user = usernameRows[0] || null;
|
||||
if (!user) {
|
||||
const [emailRows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations, account_locked FROM a_users WHERE email = ? AND email_verified_at IS NOT NULL LIMIT 1', [username.toLowerCase()]);
|
||||
user = emailRows[0] || null;
|
||||
}
|
||||
if (user && user.account_locked) {
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'login.blocked_account_locked',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
targetLabel: user.username
|
||||
});
|
||||
}
|
||||
const returnTo = getReturnTo(req);
|
||||
const message = 'This account is locked. Contact an administrator.';
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, message);
|
||||
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||
}
|
||||
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||
}
|
||||
if (!user || !verifyPassword(password, user)) {
|
||||
const lockout = await recordFailedLogin(rateKey, loginRateLimitSettings);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'authentication',
|
||||
eventType: lockout ? 'login.lockout' : 'login.failed',
|
||||
targetType: user ? 'user' : 'username',
|
||||
targetId: user ? user.id : null,
|
||||
targetLabel: user ? user.username : username,
|
||||
details: { reason: user ? 'invalid_password' : 'unknown_username' }
|
||||
});
|
||||
}
|
||||
const message = lockout
|
||||
? 'Too many failed login attempts. Try again in ' + lockout.remainingMinutes + ' minute' + (lockout.remainingMinutes === 1 ? '' : 's') + '.'
|
||||
: 'Invalid username or password.';
|
||||
const returnTo = getReturnTo(req);
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, message);
|
||||
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||
}
|
||||
|
||||
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||
}
|
||||
|
||||
await clearFailedLogins(rateKey);
|
||||
const returnTo = getReturnTo(req);
|
||||
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
|
||||
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, {
|
||||
ipAddress: getClientAddress(req),
|
||||
userAgent: req.headers && req.headers['user-agent']
|
||||
});
|
||||
const rememberMe = req.body.remember_me === '1' || req.body.remember_me === 'true' || req.body.remember_me === true;
|
||||
setSessionCookie(res, token, rememberMe ? sessionMaxAgeMs : null);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'authentication',
|
||||
eventType: 'login.success',
|
||||
actorUserId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
targetLabel: user.username,
|
||||
details: { rememberMe: rememberMe }
|
||||
});
|
||||
}
|
||||
res.redirect(returnTo || '/');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/logout', async function (req, res, next) {
|
||||
try {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
|
||||
if (token) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [token]);
|
||||
}
|
||||
const actor = req.authenticatedUser || req.currentUser;
|
||||
if (typeof recordRequestAuditEvent === 'function' && actor) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Sign-in page renderer for the authentication flow.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { resolvePasskeyRpID } = require('#src/web/lib/auth/better-auth');
|
||||
|
||||
module.exports = function renderLoginPage(message, returnTo, username, showForgotPassword) {
|
||||
return renderView('auth/login', {
|
||||
@@ -11,6 +12,8 @@ module.exports = function renderLoginPage(message, returnTo, username, showForgo
|
||||
returnTo: returnTo || '',
|
||||
username: username || '',
|
||||
showForgotPassword: Boolean(showForgotPassword),
|
||||
messageVariant: 'warning'
|
||||
passkeyRpID: resolvePasskeyRpID(process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL),
|
||||
messageVariant: 'warning',
|
||||
scripts: ['js/auth/passkey-client.js', 'js/auth/login.js']
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderResetPasswordPage(message, token, passwordRequirements) {
|
||||
module.exports = function renderResetPasswordPage(message, email, passwordRequirements) {
|
||||
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
|
||||
const requiredCategories = [];
|
||||
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||
@@ -12,5 +12,5 @@ module.exports = function renderResetPasswordPage(message, token, passwordRequir
|
||||
: requirements.minimumCategories === 4
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
|
||||
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
|
||||
return renderView('auth/reset-password', { title: 'Choose a new password', authShell: true, bodyClass: 'login-page-body', message: message || '', token: token || '', passwordMinimumLength: requirements.minimumLength, passwordRequirementsText: passwordRequirementsText });
|
||||
return renderView('auth/reset-password', { title: 'Choose a new password', authShell: true, bodyClass: 'login-page-body', message: message || '', email: email || '', passwordMinimumLength: requirements.minimumLength, passwordRequirementsText: passwordRequirementsText });
|
||||
};
|
||||
@@ -86,25 +86,16 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
// Auth, profile, and user management routes share the same session helpers.
|
||||
registerAuthRoutes(app, {
|
||||
pool: deps.pool,
|
||||
betterAuth: deps.betterAuth,
|
||||
fromNodeHeaders: deps.fromNodeHeaders,
|
||||
pages: deps.pages,
|
||||
createUserSession: deps.createUserSession,
|
||||
setSessionCookie: deps.setSessionCookie,
|
||||
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
|
||||
getConfiguredMaxActiveSessions: deps.getConfiguredMaxActiveSessions,
|
||||
clearSessionCookie: deps.clearSessionCookie,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: deps.consumeAuthMessageCookie,
|
||||
parseCookies: deps.parseCookies,
|
||||
hashSessionToken: deps.hashSessionToken,
|
||||
verifyPassword: deps.verifyPassword,
|
||||
hashPassword: deps.hashPassword,
|
||||
validatePasswordStrength: deps.validatePasswordStrength,
|
||||
sessionCookieName: deps.sessionCookieName,
|
||||
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||
,sendAccountEmail: deps.sendAccountEmail
|
||||
,createOneTimeToken: deps.createOneTimeToken
|
||||
,getConnection: deps.pool.getConnection ? deps.pool.getConnection.bind(deps.pool) : null
|
||||
,rbacData: deps.rbacData
|
||||
});
|
||||
|
||||
registerPagesRoutes(app, {
|
||||
@@ -120,22 +111,18 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
|
||||
registerAccountRoutes(app, {
|
||||
pool: deps.pool,
|
||||
betterAuth: deps.betterAuth,
|
||||
fromNodeHeaders: deps.fromNodeHeaders,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
verifyPassword: deps.verifyPassword,
|
||||
hashPassword: deps.hashPassword,
|
||||
validatePasswordStrength: deps.validatePasswordStrength,
|
||||
createUserSession: deps.createUserSession,
|
||||
setSessionCookie: deps.setSessionCookie,
|
||||
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
|
||||
parseCookies: deps.parseCookies,
|
||||
hashSessionToken: deps.hashSessionToken,
|
||||
sessionCookieName: deps.sessionCookieName,
|
||||
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||
,sendAccountEmail: deps.sendAccountEmail
|
||||
,createOneTimeToken: deps.createOneTimeToken
|
||||
});
|
||||
|
||||
registerUsersRoutes(app, {
|
||||
@@ -150,10 +137,6 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
rbacData: deps.rbacData,
|
||||
requirePermission: deps.requirePermission,
|
||||
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||
,sendAccountEmail: deps.sendAccountEmail
|
||||
,createOneTimeToken: deps.createOneTimeToken
|
||||
,hashSessionToken: deps.hashSessionToken
|
||||
,getRequestOrigin: deps.getRequestOrigin
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Pending invitations page renderer.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderInvitationsPage(data, currentUser) {
|
||||
return renderView('settings/invitations/list', {
|
||||
title: 'Pending invitations',
|
||||
active: 'users-invitations',
|
||||
userManagementMenuOpen: true,
|
||||
currentUser: currentUser || null,
|
||||
message: data.message || '',
|
||||
invitations: data.invitations || [],
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -10,7 +10,8 @@ function buildDuplicateRole(role, duplicateName) {
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateRoleName(source.name)).trim(),
|
||||
description: String(source.description || '').trim()
|
||||
description: String(source.description || '').trim(),
|
||||
require_two_factor: Boolean(source.require_two_factor)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const { ANNOUNCEMENT_ICON_CATALOG } = require('#src/data/announcement-icons');
|
||||
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, SCREEN_CONTROL_COMMAND_KEYS, SCREEN_CONTROL_COMMAND_LABELS } = require('#src/data/audit-log');
|
||||
const { fetchAppSettings, saveAppSettings } = require('#src/data/app-settings');
|
||||
const { normalizeOtpEmailBody } = require('#src/data/account-email-templates');
|
||||
const { appVersion, detectSchemaVersion } = require('#src/db/migrations');
|
||||
|
||||
const UPLOAD_MIME_TYPE_OPTIONS = [
|
||||
@@ -42,24 +43,17 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
'data-sources.api_default_interval_unit'
|
||||
],
|
||||
security: [
|
||||
'security.session_lifetime_days',
|
||||
'security.allow_user_session_revocation',
|
||||
'security.max_active_sessions',
|
||||
'security.login_max_attempts',
|
||||
'security.login_lockout_minutes',
|
||||
'security.login_rate_limit_scope',
|
||||
'security.password_min_length',
|
||||
'security.password_min_categories',
|
||||
'security.password_require_lowercase',
|
||||
'security.password_require_uppercase',
|
||||
'security.password_require_number',
|
||||
'security.password_require_symbol',
|
||||
'security.require_password_change_for_new_users',
|
||||
'security.require_password_change_after_admin_reset',
|
||||
'security.allow_admin_email_verification_bypass'
|
||||
],
|
||||
email: ['email.smtp_enabled', 'email.smtp_host', 'email.smtp_port', 'email.smtp_security', 'email.smtp_username', 'email.from_address', 'email.from_name'],
|
||||
'email-templates': ['email.verification_subject', 'email.verification_body', 'email.verification_button_alignment', 'email.verification_button_text', 'email.reset_subject', 'email.reset_body', 'email.reset_button_alignment', 'email.reset_button_text', 'email.invitation_subject', 'email.invitation_body', 'email.invitation_button_alignment', 'email.invitation_button_text'],
|
||||
email: ['email.smtp_enabled', 'email.smtp_host', 'email.smtp_port', 'email.smtp_security', 'email.smtp_username', 'email.from_address', 'email.from_name', 'email.otp_expiry_minutes'],
|
||||
'email-templates': ['email.verification_subject', 'email.verification_body', 'email.reset_subject', 'email.reset_body'],
|
||||
audit: ['audit.enabled', 'audit.categories', 'audit.screen_control_commands', 'audit.include_request_metadata', 'audit.retention_days'],
|
||||
media: ['uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types'],
|
||||
weather: ['weather.open_meteo_api_key', 'weather.pirate_weather_api_key'],
|
||||
@@ -152,20 +146,13 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
videoMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.filter(function (option) { return option.category === 'Video'; }).map(function (option) {
|
||||
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
|
||||
}),
|
||||
sessionLifetimeDays: Number(settings['security.session_lifetime_days']) || 14,
|
||||
allowUserSessionRevocation: Boolean(settings['security.allow_user_session_revocation']),
|
||||
maxActiveSessions: Number(settings['security.max_active_sessions']) || 0,
|
||||
loginMaxAttempts: Number(settings['security.login_max_attempts']) || 5,
|
||||
loginLockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
|
||||
loginRateLimitScope: String(settings['security.login_rate_limit_scope'] || 'both'),
|
||||
passwordMinLength: Number(settings['security.password_min_length']) || 10,
|
||||
passwordMinCategories: Number(settings['security.password_min_categories']) || 3,
|
||||
passwordRequireLowercase: Boolean(settings['security.password_require_lowercase']),
|
||||
passwordRequireUppercase: Boolean(settings['security.password_require_uppercase']),
|
||||
passwordRequireNumber: Boolean(settings['security.password_require_number']),
|
||||
passwordRequireSymbol: Boolean(settings['security.password_require_symbol']),
|
||||
requirePasswordChangeForNewUsers: Boolean(settings['security.require_password_change_for_new_users']),
|
||||
requirePasswordChangeAfterAdminReset: Boolean(settings['security.require_password_change_after_admin_reset']),
|
||||
allowAdminEmailVerificationBypass: Boolean(settings['security.allow_admin_email_verification_bypass']),
|
||||
smtpEnabled: Boolean(settings['email.smtp_enabled']),
|
||||
smtpHost: String(settings['email.smtp_host'] || ''),
|
||||
@@ -175,21 +162,11 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
smtpConfigured: Boolean(settings['email.smtp_password']),
|
||||
emailFromAddress: String(settings['email.from_address'] || ''),
|
||||
emailFromName: String(settings['email.from_name'] || ''),
|
||||
verificationExpiryMinutes: Number(settings['email.verification_expiry_minutes']) || 30,
|
||||
resetExpiryMinutes: Number(settings['email.reset_expiry_minutes']) || 30,
|
||||
invitationExpiryHours: Number(settings['email.invitation_expiry_hours']) || 24,
|
||||
otpExpiryMinutes: Number(settings['email.otp_expiry_minutes']) || 30,
|
||||
verificationEmailSubject: String(settings['email.verification_subject'] || ''),
|
||||
verificationEmailBody: String(settings['email.verification_body'] || ''),
|
||||
verificationButtonAlignment: String(settings['email.verification_button_alignment'] || 'center'),
|
||||
verificationButtonText: String(settings['email.verification_button_text'] || 'Verify email address'),
|
||||
verificationEmailBody: normalizeOtpEmailBody(settings['email.verification_body']),
|
||||
resetEmailSubject: String(settings['email.reset_subject'] || ''),
|
||||
resetEmailBody: String(settings['email.reset_body'] || ''),
|
||||
resetButtonAlignment: String(settings['email.reset_button_alignment'] || 'center'),
|
||||
resetButtonText: String(settings['email.reset_button_text'] || 'Reset password'),
|
||||
invitationEmailSubject: String(settings['email.invitation_subject'] || ''),
|
||||
invitationEmailBody: String(settings['email.invitation_body'] || ''),
|
||||
invitationButtonAlignment: String(settings['email.invitation_button_alignment'] || 'center'),
|
||||
invitationButtonText: String(settings['email.invitation_button_text'] || 'Accept invitation'),
|
||||
resetEmailBody: normalizeOtpEmailBody(settings['email.reset_body']),
|
||||
defaultSlideDurationSeconds: Number(settings['player.default_slide_duration_seconds']) || 10,
|
||||
defaultFadeBetweenSlides: Boolean(settings['player.default_fade_between_slides']),
|
||||
defaultSkipUnavailableRtmp: Boolean(settings['player.skip_unavailable_rtmp']),
|
||||
@@ -279,6 +256,25 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/system/login-lockouts/reset', deps.requirePermission('login-lockouts.allow'), async function (req, res, next) {
|
||||
try {
|
||||
await pool.query('DELETE FROM a_login_attempts WHERE rate_key LIKE ? OR rate_key LIKE ?', ['%|/sign-in/%', '%|/two-factor/verify-%']);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'system-settings',
|
||||
eventType: 'system-settings.login_lockouts_reset',
|
||||
actorUserId: req.currentUser && req.currentUser.id,
|
||||
targetType: 'settings',
|
||||
targetId: 'security',
|
||||
targetLabel: 'All login lockouts'
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/system?message=' + encodeURIComponent('Sign-in and two-factor request rate limits reset for all clients.') + '#security-sessions');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/system', deps.requirePermission('system-settings.update'), async function (req, res, next) {
|
||||
try {
|
||||
const section = String(req.body && req.body.settings_section || '').trim().toLowerCase();
|
||||
@@ -314,16 +310,11 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
|
||||
if (section === 'email-templates') {
|
||||
const verificationSubject = String(req.body && req.body.verification_subject || '').trim();
|
||||
const verificationBody = String(req.body && req.body.verification_body || '').trim();
|
||||
const verificationBody = normalizeOtpEmailBody(req.body && req.body.verification_body).trim();
|
||||
const resetSubject = String(req.body && req.body.reset_subject || '').trim();
|
||||
const resetBody = String(req.body && req.body.reset_body || '').trim();
|
||||
const verificationButtonText = String(req.body && req.body.verification_button_text || '').trim();
|
||||
const resetButtonText = String(req.body && req.body.reset_button_text || '').trim();
|
||||
const invitationSubject = String(req.body && req.body.invitation_subject || '').trim();
|
||||
const invitationBody = String(req.body && req.body.invitation_body || '').trim();
|
||||
const invitationButtonText = String(req.body && req.body.invitation_button_text || '').trim();
|
||||
if (!verificationSubject || !verificationBody.includes('[[url]]') || !resetSubject || !resetBody.includes('[[url]]') || !invitationSubject || !invitationBody.includes('[[url]]') || !verificationButtonText || !resetButtonText || !invitationButtonText) {
|
||||
const error = new Error('Email templates require a subject, message, and the correct link placeholder.');
|
||||
const resetBody = normalizeOtpEmailBody(req.body && req.body.reset_body).trim();
|
||||
if (!verificationSubject || !verificationBody.includes('[[otp]]') || !resetSubject || !resetBody.includes('[[otp]]')) {
|
||||
const error = new Error('Email templates require a subject, message, and the correct code or link placeholder.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
throw error;
|
||||
@@ -331,16 +322,8 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
const savedSettings = await saveAppSettings(pool, {
|
||||
'email.verification_subject': verificationSubject,
|
||||
'email.verification_body': verificationBody,
|
||||
'email.verification_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.verification_button_alignment || '').trim()) ? String(req.body.verification_button_alignment).trim() : 'center',
|
||||
'email.verification_button_text': verificationButtonText,
|
||||
'email.reset_subject': resetSubject,
|
||||
'email.reset_body': resetBody,
|
||||
'email.reset_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.reset_button_alignment || '').trim()) ? String(req.body.reset_button_alignment).trim() : 'center',
|
||||
'email.reset_button_text': resetButtonText
|
||||
,'email.invitation_subject': invitationSubject
|
||||
,'email.invitation_body': invitationBody
|
||||
,'email.invitation_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.invitation_button_alignment || '').trim()) ? String(req.body.invitation_button_alignment).trim() : 'center'
|
||||
,'email.invitation_button_text': invitationButtonText
|
||||
}, req.currentUser && req.currentUser.id);
|
||||
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
|
||||
return res.redirect('/settings/system?message=' + encodeURIComponent('Account email templates saved.') + '#email-templates');
|
||||
@@ -380,43 +363,29 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (section === 'security') {
|
||||
const sessionLifetimeDays = Number(req.body && req.body.session_lifetime_days);
|
||||
const allowUserSessionRevocation = req.body && (req.body.allow_user_session_revocation === '1' || req.body.allow_user_session_revocation === 'true');
|
||||
const maxActiveSessions = Number(req.body && req.body.max_active_sessions);
|
||||
const loginMaxAttempts = Number(req.body && req.body.login_max_attempts);
|
||||
const loginLockoutMinutes = Number(req.body && req.body.login_lockout_minutes);
|
||||
const loginRateLimitScope = String(req.body && req.body.login_rate_limit_scope || 'both').trim().toLowerCase();
|
||||
const passwordMinLength = Number(req.body && req.body.password_min_length);
|
||||
const passwordMinCategories = Number(req.body && req.body.password_min_categories);
|
||||
const passwordRequireLowercase = req.body && (req.body.password_require_lowercase === '1' || req.body.password_require_lowercase === 'true');
|
||||
const passwordRequireUppercase = req.body && (req.body.password_require_uppercase === '1' || req.body.password_require_uppercase === 'true');
|
||||
const passwordRequireNumber = req.body && (req.body.password_require_number === '1' || req.body.password_require_number === 'true');
|
||||
const passwordRequireSymbol = req.body && (req.body.password_require_symbol === '1' || req.body.password_require_symbol === 'true');
|
||||
const requirePasswordChangeForNewUsers = req.body && (req.body.require_password_change_for_new_users === '1' || req.body.require_password_change_for_new_users === 'true');
|
||||
const requirePasswordChangeAfterAdminReset = req.body && (req.body.require_password_change_after_admin_reset === '1' || req.body.require_password_change_after_admin_reset === 'true');
|
||||
const requiredCategoryCount = [passwordRequireLowercase, passwordRequireUppercase, passwordRequireNumber, passwordRequireSymbol].filter(Boolean).length;
|
||||
if (!Number.isInteger(sessionLifetimeDays) || sessionLifetimeDays < 1 || sessionLifetimeDays > 365 || !Number.isInteger(maxActiveSessions) || maxActiveSessions < 0 || maxActiveSessions > 100 || !Number.isInteger(loginMaxAttempts) || loginMaxAttempts < 1 || loginMaxAttempts > 100 || !Number.isInteger(loginLockoutMinutes) || loginLockoutMinutes < 1 || loginLockoutMinutes > 1440 || !['both', 'username', 'ip'].includes(loginRateLimitScope) || !Number.isInteger(passwordMinLength) || passwordMinLength < 8 || passwordMinLength > 128 || !Number.isInteger(passwordMinCategories) || passwordMinCategories < 1 || passwordMinCategories > 4 || requiredCategoryCount > passwordMinCategories) {
|
||||
const error = new Error('Session and password settings must use valid values; required password categories cannot exceed the minimum category count.');
|
||||
if (!Number.isInteger(passwordMinLength) || passwordMinLength < 8 || passwordMinLength > 128 || !Number.isInteger(passwordMinCategories) || passwordMinCategories < 1 || passwordMinCategories > 4 || requiredCategoryCount > passwordMinCategories) {
|
||||
const error = new Error('Password settings must use valid values; required password categories cannot exceed the minimum category count.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
throw error;
|
||||
}
|
||||
const savedSettings = await saveAppSettings(pool, {
|
||||
'security.session_lifetime_days': sessionLifetimeDays,
|
||||
'security.allow_user_session_revocation': allowUserSessionRevocation,
|
||||
'security.max_active_sessions': maxActiveSessions,
|
||||
'security.login_max_attempts': loginMaxAttempts,
|
||||
'security.login_lockout_minutes': loginLockoutMinutes,
|
||||
'security.login_rate_limit_scope': loginRateLimitScope,
|
||||
'security.password_min_length': passwordMinLength,
|
||||
'security.password_min_categories': passwordMinCategories,
|
||||
'security.password_require_lowercase': passwordRequireLowercase,
|
||||
'security.password_require_uppercase': passwordRequireUppercase,
|
||||
'security.password_require_number': passwordRequireNumber,
|
||||
'security.password_require_symbol': passwordRequireSymbol,
|
||||
'security.require_password_change_for_new_users': requirePasswordChangeForNewUsers,
|
||||
'security.require_password_change_after_admin_reset': requirePasswordChangeAfterAdminReset
|
||||
,'security.allow_admin_email_verification_bypass': req.body && (req.body.allow_admin_email_verification_bypass === '1' || req.body.allow_admin_email_verification_bypass === 'true')
|
||||
'security.allow_admin_email_verification_bypass': req.body && (req.body.allow_admin_email_verification_bypass === '1' || req.body.allow_admin_email_verification_bypass === 'true')
|
||||
}, req.currentUser && req.currentUser.id);
|
||||
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
|
||||
return res.redirect('/settings/system?message=' + encodeURIComponent('Security settings saved.') + '#security-sessions');
|
||||
@@ -431,10 +400,8 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
const smtpPassword = String(req.body && req.body.smtp_password || '');
|
||||
const fromAddress = String(req.body && req.body.from_address || '').trim();
|
||||
const fromName = String(req.body && req.body.from_name || '').trim();
|
||||
const verificationExpiryMinutes = Number(req.body && req.body.verification_expiry_minutes);
|
||||
const resetExpiryMinutes = Number(req.body && req.body.reset_expiry_minutes);
|
||||
const invitationExpiryHours = Number(req.body && req.body.invitation_expiry_hours);
|
||||
if (!Number.isInteger(verificationExpiryMinutes) || verificationExpiryMinutes < 1 || verificationExpiryMinutes > 10080 || !Number.isInteger(resetExpiryMinutes) || resetExpiryMinutes < 1 || resetExpiryMinutes > 10080 || !Number.isInteger(invitationExpiryHours) || invitationExpiryHours < 1 || invitationExpiryHours > 720 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
|
||||
const otpExpiryMinutes = Number(req.body && req.body.otp_expiry_minutes);
|
||||
if (!Number.isInteger(otpExpiryMinutes) || otpExpiryMinutes < 1 || otpExpiryMinutes > 10080 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
|
||||
const error = new Error('Enabled email delivery requires an SMTP host, valid port, and from address.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
@@ -449,9 +416,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'],
|
||||
'email.from_address': fromAddress,
|
||||
'email.from_name': fromName,
|
||||
'email.verification_expiry_minutes': verificationExpiryMinutes,
|
||||
'email.reset_expiry_minutes': resetExpiryMinutes,
|
||||
'email.invitation_expiry_hours': invitationExpiryHours
|
||||
'email.otp_expiry_minutes': otpExpiryMinutes
|
||||
}, req.currentUser && req.currentUser.id);
|
||||
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
|
||||
return res.redirect('/settings/system?message=' + encodeURIComponent('Email settings saved.') + '#email-delivery');
|
||||
|
||||
@@ -36,12 +36,19 @@ function buildUsersAddViewModel(message, currentUser, roles, formValues, message
|
||||
messageVariant: messageVariant || 'success',
|
||||
currentUser: currentUser || null,
|
||||
roles: mapRolesForForm(roles, []),
|
||||
authMethods: [],
|
||||
formValues: formValues || {},
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/settings/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/settings/users/new"'
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsersEditViewModel(user, message, currentUser, roles, sessions) {
|
||||
const authMethods = Array.isArray(user && user.authMethods) ? user.authMethods : [];
|
||||
const passkeys = Array.isArray(user && user.passkeys) ? user.passkeys : [];
|
||||
const mappedUser = Object.assign({}, user, {
|
||||
passkeys: passkeys,
|
||||
canUnlinkPasskeys: Boolean(currentUser && Number(currentUser.id) !== Number(user && user.id) && passkeys.length > 0)
|
||||
});
|
||||
return {
|
||||
title: 'Edit user',
|
||||
active: 'users',
|
||||
@@ -60,7 +67,9 @@ function buildUsersEditViewModel(user, message, currentUser, roles, sessions) {
|
||||
showSaveSecondaryActions: true,
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
user: user,
|
||||
user: mappedUser,
|
||||
authMethods: authMethods,
|
||||
passkeys: passkeys,
|
||||
sessions: Array.isArray(sessions) ? sessions : [],
|
||||
inUse: Boolean(user && user.inUse),
|
||||
roles: mapRolesForForm(roles, user && user.roleIds),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderUsersInvitePage(message, currentUser, roles, formValues, messageVariant) {
|
||||
return renderView('settings/users/invite', {
|
||||
title: 'Invite user',
|
||||
active: 'users',
|
||||
userManagementMenuOpen: true,
|
||||
currentUser: currentUser || null,
|
||||
message: message || '',
|
||||
messageVariant: messageVariant || 'success',
|
||||
roles: Array.isArray(roles) ? roles : [],
|
||||
formValues: formValues || {}
|
||||
});
|
||||
};
|
||||
+264
-48
@@ -1,7 +1,9 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>My account</h2>
|
||||
<p>Update your username, email, display name, and password.</p>
|
||||
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3">
|
||||
<div>
|
||||
<h2>My account</h2>
|
||||
<p>Manage your sign-in identity, password, and active sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +11,7 @@
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Account details</h3>
|
||||
<h3 class="card-title">Profile and email</h3>
|
||||
</div>
|
||||
<form id="account-profile-form" method="post" action="/account/profile" autocomplete="off" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
@@ -17,23 +19,276 @@
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="account-username" class="form-label">Username</label>
|
||||
<input id="account-username" type="text" name="username" class="form-control" value="{{username}}" autocomplete="username" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" maxlength="64" required />
|
||||
<div class="form-text">Used to sign in with your password.</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="account-name" class="form-label">Display name</label>
|
||||
<input id="account-name" type="text" name="name" class="form-control" value="{{name}}" autocomplete="name" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" required />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="account-email" class="form-label">Email address</label>
|
||||
<input id="account-email" type="email" name="email" class="form-control" value="{{email}}" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" />
|
||||
<div class="form-text">{{#if emailPending}}Pending verification for {{pendingEmail}}. Enter a different address here to replace it, or resend the verification email below.{{else}}{{#if emailVerified}}Verified{{else}}Not verified{{/if}}. Email is optional; changing the address requires confirmation by email.{{/if}}</div>
|
||||
<label for="account-email" class="form-label d-flex align-items-center justify-content-between gap-2">
|
||||
<span>Email address</span>
|
||||
<span class="small {{#if emailPending}}text-warning{{else}}{{#if emailVerified}}text-success{{else}}text-body-secondary{{/if}}{{/if}}">{{#if emailPending}}{{#if pendingEmailChange}}Change pending{{else}}Verification pending{{/if}}{{else}}{{#if emailVerified}}Verified{{else}}Not verified{{/if}}{{/if}}</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input id="account-email" type="email" name="email" class="form-control" value="{{email}}" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" />
|
||||
{{#if emailPending}}
|
||||
<button type="submit" class="btn btn-outline-primary" form="account-email-verification-form">Resend code</button>
|
||||
{{else}}
|
||||
{{#unless emailVerified}}
|
||||
{{#if email}}<button type="submit" class="btn btn-outline-primary" form="account-email-verification-form">Send code</button>{{/if}}
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text">{{#if emailPending}}A code was sent to {{pendingEmail}}. Enter it below to verify the address.{{else}}Used for email verification and password recovery. Changing the address requires a code.{{/if}}</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="account-profile-current-password" class="form-label">Current password</label>
|
||||
<input id="account-profile-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
|
||||
<div class="form-text">Confirm your password to update your profile.</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="current_password" data-account-profile-current-password />
|
||||
</div>
|
||||
</form>
|
||||
{{#if emailPending}}<form method="post" action="/account/email" class="px-3 pb-3"><input type="hidden" name="email" value="{{pendingEmail}}"><button type="submit" class="btn btn-sm btn-outline-primary">Send verification email</button></form>{{/if}}
|
||||
{{#if emailPending}}
|
||||
<form id="account-email-verification-form" method="post" action="/account/email" hidden></form>
|
||||
{{else}}
|
||||
{{#if email}}
|
||||
{{#unless emailVerified}}<form id="account-email-verification-form" method="post" action="/account/email/send-verification" hidden></form>{{/unless}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-success" form="account-profile-form" name="save_action" value="save">Save account details</button>
|
||||
</div>
|
||||
{{#if emailPending}}
|
||||
<div class="card-body border-top pt-3">
|
||||
<form method="post" action="/account/email/verify" class="mb-0">
|
||||
<label for="account-email-otp" class="form-label">Email code</label>
|
||||
<div class="input-group">
|
||||
<input id="account-email-otp" name="otp" type="text" class="form-control" inputmode="numeric" autocomplete="one-time-code" required />
|
||||
<button type="submit" class="btn btn-primary">Verify email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if canManageTwoFactor}}
|
||||
<div class="card card-outline card-info admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Sign-in methods</h3>
|
||||
</div>
|
||||
{{#if requiresTwoFactor}}
|
||||
{{#unless twoFactorEnabled}}
|
||||
<div class="alert alert-warning m-3 mb-0" role="alert">Two-factor authentication is required by one of your roles. Set up an authenticator to continue.</div>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
<div class="card-body py-0" data-passkey-rp-id="{{passkeyRpID}}">
|
||||
<ul class="list-group list-group-flush">
|
||||
{{#if authMethods.length}}
|
||||
{{#each authMethods}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>{{label}}</span>
|
||||
<span class="small text-success">Linked</span>
|
||||
</li>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>No linked sign-in methods</span>
|
||||
<span class="small text-body-secondary">None</span>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if twoFactorEnabled}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>Authenticator app (TOTP)</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="small text-success">Enabled</span>
|
||||
<button type="button" class="btn btn-sm {{#if requiresTwoFactor}}btn-outline-secondary{{else}}btn-outline-danger{{/if}}" data-bs-toggle="modal" data-bs-target="#two-factor-manage-modal">{{#if requiresTwoFactor}}Manage{{else}}Unlink{{/if}}</button>
|
||||
</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>Two-step verification</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{{#if requiresTwoFactor}}<span class="small text-warning">Required by role</span>{{/if}}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#two-factor-setup-modal">Set up</button>
|
||||
</span>
|
||||
</li>
|
||||
{{/if}}
|
||||
<li class="list-group-item px-0" data-passkey-group>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<span>Passkeys</span>
|
||||
{{#if requiresTwoFactor}}
|
||||
<span class="small text-body-secondary">Unavailable while an authenticator is required</span>
|
||||
{{else if twoFactorEnabled}}
|
||||
<span class="small text-body-secondary">Unavailable while authenticator verification is enabled</span>
|
||||
{{else}}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-passkey-add-trigger data-bs-toggle="modal" data-bs-target="#passkey-setup-modal">Set up</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if passkeys.length}}
|
||||
<ul class="list-group list-group-flush ms-2 mt-1 ps-2" data-passkey-list>
|
||||
{{#each passkeys}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">
|
||||
<span><i class="bi bi-arrow-return-right me-2 text-body-secondary" aria-hidden="true"></i>{{#if name}}{{name}}{{else}}Passkey{{/if}}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-passkey-delete="{{id}}" aria-label="Remove passkey">Remove</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="form-text mx-3 mb-3" data-passkey-unavailable hidden>Passkeys require HTTPS and the configured Pulse hostname.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#unless twoFactorEnabled}}
|
||||
{{#unless requiresTwoFactor}}
|
||||
<div class="modal fade" id="passkey-setup-modal" tabindex="-1" aria-labelledby="passkey-setup-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title fs-5" id="passkey-setup-title">Set up a passkey</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Use your device biometrics, device PIN, or a security key. Your browser will guide you through setup.</p>
|
||||
<form data-passkey-add>
|
||||
<label for="passkey-name" class="form-label">Passkey name</label>
|
||||
<div class="input-group">
|
||||
<input id="passkey-name" name="name" type="text" class="form-control" maxlength="255" placeholder="For example, Work laptop" autocomplete="off" />
|
||||
<button type="submit" class="btn btn-primary" data-passkey-submit><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Continue</button>
|
||||
</div>
|
||||
<div class="form-text" data-passkey-progress hidden>Follow the prompt from your browser or device to finish adding the passkey.</div>
|
||||
<div class="alert alert-warning mt-2 mb-0" data-passkey-feedback role="alert" hidden></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/unless}}
|
||||
|
||||
{{#unless twoFactorEnabled}}
|
||||
<div class="modal fade" id="two-factor-setup-modal" tabindex="-1" aria-labelledby="two-factor-setup-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title fs-5" id="two-factor-setup-title">Set up two-step verification</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form data-two-factor-enable>
|
||||
<label for="two-factor-enable-password" class="form-label">Current password</label>
|
||||
<div class="input-group">
|
||||
<input id="two-factor-enable-password" name="password" type="password" class="form-control" autocomplete="current-password" required />
|
||||
<button type="submit" class="btn btn-primary">Continue</button>
|
||||
</div>
|
||||
<div class="form-text">You’ll scan a QR code with your authenticator app, save recovery codes, then verify a code to finish.</div>
|
||||
<div class="alert alert-warning mt-2" data-two-factor-enable-feedback hidden role="alert"></div>
|
||||
</form>
|
||||
<div data-two-factor-setup hidden>
|
||||
<p class="fw-semibold">Scan this QR code with your authenticator app</p>
|
||||
<div class="d-flex flex-column flex-sm-row align-items-start gap-3">
|
||||
<div class="p-1 bg-white border rounded" data-two-factor-qr></div>
|
||||
<div>
|
||||
<p class="small mb-1">Or enter this setup key manually:</p>
|
||||
<code class="user-select-all" data-two-factor-secret></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-3" data-two-factor-codes hidden>
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-2">
|
||||
<p class="fw-semibold mb-0">Save these recovery codes before verifying. Each code can only be used once.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" data-two-factor-download aria-label="Download recovery codes" title="Download recovery codes"><i class="bi bi-download" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<ul class="mb-2 font-monospace" data-two-factor-codes-list></ul>
|
||||
</div>
|
||||
<form data-two-factor-verify class="mt-3">
|
||||
<label for="two-factor-setup-code" class="form-label">Authenticator code</label>
|
||||
<div class="input-group">
|
||||
<input id="two-factor-setup-code" name="code" type="text" class="form-control" inputmode="numeric" autocomplete="one-time-code" required />
|
||||
<button type="submit" class="btn btn-primary">Verify and enable</button>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-2" data-two-factor-verify-feedback hidden role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
{{#if twoFactorEnabled}}
|
||||
<div class="modal fade" id="two-factor-manage-modal" tabindex="-1" aria-labelledby="two-factor-manage-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title fs-5" id="two-factor-manage-title">Manage authenticator</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{#unless requiresTwoFactor}}
|
||||
<form data-two-factor-disable>
|
||||
<p>Unlinking removes the authenticator and recovery codes. You will need to sign in again.</p>
|
||||
<label for="two-factor-disable-password" class="form-label">Current password</label>
|
||||
<div class="input-group">
|
||||
<input id="two-factor-disable-password" name="password" type="password" class="form-control" autocomplete="current-password" required />
|
||||
<button type="submit" class="btn btn-outline-danger">Unlink</button>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-2" data-two-factor-disable-feedback hidden role="alert"></div>
|
||||
</form>
|
||||
{{/unless}}
|
||||
<hr>
|
||||
<form data-two-factor-regenerate-codes>
|
||||
<label for="two-factor-codes-password" class="form-label">Generate new recovery codes</label>
|
||||
<div class="input-group">
|
||||
<input id="two-factor-codes-password" name="password" type="password" class="form-control" autocomplete="current-password" placeholder="Current password" required />
|
||||
<button type="submit" class="btn btn-outline-primary">Generate</button>
|
||||
</div>
|
||||
<div class="form-text">Generating new codes invalidates all previously issued recovery codes.</div>
|
||||
</form>
|
||||
<div class="alert alert-success mt-3" data-two-factor-codes hidden>
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-2">
|
||||
<p class="fw-semibold mb-0">Store these recovery codes somewhere safe. Each code can only be used once.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-success flex-shrink-0" data-two-factor-download aria-label="Download recovery codes" title="Download recovery codes"><i class="bi bi-download" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<ul class="mb-2 font-monospace" data-two-factor-codes-list></ul>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-2" data-two-factor-codes-feedback hidden role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-warning admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Change password</h3>
|
||||
</div>
|
||||
<form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="account-password-current-password" class="form-label">Current password</label>
|
||||
<input id="account-password-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="account-new-password" class="form-label">New password</label>
|
||||
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="account-confirm-password" class="form-label">Confirm new password</label>
|
||||
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-warning" form="account-password-form" name="save_action" value="save">Change password</button>
|
||||
</div>
|
||||
</div>
|
||||
{{#if allowUserSessionRevocation}}
|
||||
<div class="card card-outline card-secondary admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
@@ -71,43 +326,4 @@
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-warning admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Password</h3>
|
||||
</div>
|
||||
<form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
<input type="hidden" name="current_password" data-account-password-current-password />
|
||||
<div class="mb-3">
|
||||
<label for="account-new-password" class="form-label">New password</label>
|
||||
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="account-confirm-password" class="form-label">Confirm new password</label>
|
||||
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card card-outline card-secondary admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Confirmation</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label for="account-current-password" class="form-label">Current password</label>
|
||||
<input id="account-current-password" type="password" class="form-control" autocomplete="current-password" data-account-current-password required />
|
||||
<div class="form-text">Required to save account details or change your password.</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Account change actions">
|
||||
<button type="submit" class="btn btn-success" form="account-profile-form" name="save_action" value="save">Save account details</button>
|
||||
<button type="submit" class="btn btn-warning" form="account-password-form" name="save_action" value="save">Change password</button>
|
||||
<a class="btn btn-secondary" href="{{returnUrl}}" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -5,7 +5,7 @@
|
||||
<form method="post" action="/forgot-password">
|
||||
<label for="reset-identity" class="form-label">Username or email</label>
|
||||
<input id="reset-identity" name="identity" type="text" class="form-control mb-3" autocomplete="username" required>
|
||||
<button type="submit" class="btn btn-primary w-100">Email reset link</button>
|
||||
<button type="submit" class="btn btn-primary w-100">Send reset code</button>
|
||||
</form>
|
||||
<a class="d-block text-center mt-3" href="/login">Back to sign in</a>
|
||||
</div></div>
|
||||
@@ -1,44 +1,63 @@
|
||||
<div class="login-logo text-center mb-3">
|
||||
<strong>Pulse</strong> Signage
|
||||
<a class="text-reset text-decoration-none" href="/login"><strong>Pulse</strong> Signage</a>
|
||||
</div>
|
||||
<div class="card login-card mx-auto">
|
||||
<div class="card-body login-card-body">
|
||||
<p class="login-box-msg">Sign in to start your session</p>
|
||||
{{#if message}}
|
||||
<div class="alert alert-warning py-2">{{message}}</div>
|
||||
<div class="alert alert-warning py-2" data-login-initial-message>{{message}}</div>
|
||||
{{/if}}
|
||||
<form method="post" action="/login">
|
||||
<form method="post" action="/api/auth/sign-in/username" data-better-auth-login>
|
||||
{{#if returnTo}}
|
||||
<input type="hidden" name="returnTo" value="{{returnTo}}" />
|
||||
{{/if}}
|
||||
<div class="mb-3">
|
||||
<label for="username" class="visually-hidden">Username or email</label>
|
||||
<div class="input-group">
|
||||
<input id="username" name="username" type="text" class="form-control" placeholder="Username or email" autocomplete="username" value="{{username}}" required />
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-person"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="visually-hidden">Password</label>
|
||||
<div class="input-group">
|
||||
<input id="password" name="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required />
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-lock"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row align-items-center g-3 mb-3">
|
||||
<div class="col-7">
|
||||
<div class="form-check">
|
||||
<input id="remember-me" name="remember_me" type="checkbox" class="form-check-input" value="1" />
|
||||
<label for="remember-me" class="form-check-label">Remember me</label>
|
||||
<div data-login-credentials>
|
||||
<div class="mb-3">
|
||||
<label for="username" class="visually-hidden">Username or email</label>
|
||||
<div class="input-group">
|
||||
<input id="username" name="username" type="text" class="form-control" placeholder="Username or email" autocomplete="username" value="{{username}}" required />
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-person"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<button type="submit" class="btn btn-primary w-100">Sign in</button>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="visually-hidden">Password</label>
|
||||
<div class="input-group">
|
||||
<input id="password" name="password" type="password" class="form-control" placeholder="Enter your password" autocomplete="current-password" required />
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-lock"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" data-login-two-factor hidden>
|
||||
<p class="mb-2 fw-semibold">Two-factor authentication</p>
|
||||
<p class="small text-body-secondary">Enter a code from your authenticator app or use a recovery code.</p>
|
||||
<label for="two-factor-code" class="visually-hidden" data-two-factor-code-label>Authenticator code</label>
|
||||
<input id="two-factor-code" name="two_factor_code" type="text" class="form-control mb-2" inputmode="numeric" autocomplete="one-time-code" disabled />
|
||||
<div class="form-check mb-2">
|
||||
<input id="trust-device" name="trust_device" type="checkbox" class="form-check-input" value="1" disabled />
|
||||
<label for="trust-device" class="form-check-label">Trust this device for 30 days</label>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<button type="button" class="btn btn-sm btn-link p-0" data-two-factor-toggle-method>Use recovery code</button>
|
||||
<button type="button" class="btn btn-sm btn-link p-0" data-two-factor-back>Back to sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning py-2" data-login-feedback role="alert" hidden></div>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-3" data-login-actions>
|
||||
<div data-login-remember>
|
||||
<div class="form-check">
|
||||
<input id="remember-me" name="remember_me" type="checkbox" class="form-check-input" value="1" />
|
||||
<label for="remember-me" class="form-check-label">Remember Me</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary px-4" data-login-submit>Sign In</button>
|
||||
</div>
|
||||
<div data-passkey-login-panel data-passkey-rp-id="{{passkeyRpID}}">
|
||||
<div class="login-or-divider" aria-hidden="true"><span>OR</span></div>
|
||||
<button type="button" class="btn btn-secondary w-100" data-passkey-login><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Sign in with a passkey</button>
|
||||
<p class="form-text text-center mb-0 mt-2" data-passkey-unavailable hidden>Passkeys require HTTPS and the configured Pulse hostname.</p>
|
||||
</div>
|
||||
</form>
|
||||
{{#if showForgotPassword}}
|
||||
<p class="mb-0 text-center"><a href="/forgot-password">I forgot my password</a></p>
|
||||
<p class="mb-0 mt-3"><a href="/forgot-password">I forgot my password</a></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,7 +3,10 @@
|
||||
<h1 class="h4 mb-3">Choose a new password</h1>
|
||||
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
|
||||
<form method="post" action="/reset-password">
|
||||
<input type="hidden" name="token" value="{{token}}">
|
||||
<label for="reset-email" class="form-label">Email address</label>
|
||||
<input id="reset-email" name="email" type="email" class="form-control mb-3" autocomplete="email" value="{{email}}" required>
|
||||
<label for="reset-otp" class="form-label">Email code</label>
|
||||
<input id="reset-otp" name="otp" type="text" class="form-control mb-3" autocomplete="one-time-code" inputmode="numeric" required>
|
||||
<label for="reset-password" class="form-label">New password</label>
|
||||
<input id="reset-password" name="password" type="password" class="form-control mb-3" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required>
|
||||
<label for="reset-confirm-password" class="form-label">Confirm password</label>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Pending invitations</h2>
|
||||
<p>Invitations that have not yet been accepted.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">Active invitations</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search invitations" aria-label="Search invitations" data-table-search />
|
||||
</div>
|
||||
{{#if (hasPermission currentUser 'invitations.create')}}
|
||||
<a href="/settings/users/invite" class="btn btn-info btn-sm">Invite user</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-table-sort-key="email">Email</th>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th>Roles</th>
|
||||
<th data-table-sort-key="created">Sent</th>
|
||||
<th data-table-sort-key="expires">Expires</th>
|
||||
<th data-table-sort-key="createdBy">Sent by</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if invitations.length}}
|
||||
{{#each invitations}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Email">{{email}}</td>
|
||||
<td data-label="Name">{{#if name}}{{name}}{{else}}-{{/if}}</td>
|
||||
<td data-label="Roles">{{roleNames}}</td>
|
||||
<td data-label="Sent">{{createdAtLabel}}</td>
|
||||
<td data-label="Expires">{{expiresAtLabel}}</td>
|
||||
<td data-label="Sent by">{{createdByLabel}}</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions users-row-actions">
|
||||
{{#if (hasPermission ../currentUser 'invitations.allow')}}
|
||||
<form method="post" action="/settings/invitations/{{id}}/resend" class="inline-form" data-confirm-message="Resend invitation to {{email}}?">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Resend</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'invitations.delete')}}
|
||||
<form method="post" action="/settings/invitations/{{id}}/delete" class="inline-form" data-confirm-message="Delete invitation for {{email}}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="7" class="empty">No pending invitations.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/settings/invitations" alwaysShow=true}}
|
||||
</div>
|
||||
@@ -24,6 +24,10 @@
|
||||
<label for="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" class="form-label">Description</label>
|
||||
<textarea id="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" name="description" class="form-control" rows="5" maxlength="512" data-limit-text-length>{{#if isEdit}}{{role.description}}{{else}}{{formValues.description}}{{/if}}</textarea>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-3">
|
||||
<input id="role-require-two-factor" name="require_two_factor" type="checkbox" class="form-check-input" value="1" {{#if isEdit}}{{#if role.require_two_factor}}checked{{/if}}{{else}}{{#if formValues.require_two_factor}}checked{{/if}}{{/if}} />
|
||||
<label class="form-check-label" for="role-require-two-factor">Require two-factor authentication</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end flex-wrap">
|
||||
<div class="btn-group" role="group" aria-label="Role actions">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>System Settings</h2>
|
||||
<h2>System settings</h2>
|
||||
<p>Configure the defaults and content policies used across Pulse Signage.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,9 +282,7 @@
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-password">SMTP password</label><input id="settings-smtp-password" name="smtp_password" type="password" class="form-control" placeholder="{{#if mediaSettings.smtpConfigured}}Leave unchanged{{/if}}"></div>
|
||||
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-address">From address</label><input id="settings-from-address" name="from_address" type="email" class="form-control" value="{{mediaSettings.emailFromAddress}}"></div>
|
||||
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-name">From name</label><input id="settings-from-name" name="from_name" type="text" class="form-control" value="{{mediaSettings.emailFromName}}"></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-expiry">Verification expiry</label><div class="input-group"><input id="settings-verification-expiry" name="verification_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.verificationExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-expiry">Password reset expiry</label><div class="input-group"><input id="settings-reset-expiry" name="reset_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.resetExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-expiry">Invitation expiry</label><div class="input-group"><input id="settings-invitation-expiry" name="invitation_expiry_hours" type="number" class="form-control" min="1" max="720" value="{{mediaSettings.invitationExpiryHours}}"><span class="input-group-text">hours</span></div></div>
|
||||
<div class="col-12 col-md-6"><label class="form-label" for="settings-otp-expiry">Email code expiry</label><div class="input-group"><input id="settings-otp-expiry" name="otp_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.otpExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
|
||||
</div></div>
|
||||
<div class="card-footer d-flex align-items-center">
|
||||
{{#if (hasPermission currentUser 'system-settings.update')}}
|
||||
@@ -303,7 +301,7 @@
|
||||
<div id="email-templates" class="card settings-section-card" data-settings-section hidden>
|
||||
<div class="card-header"><h3 class="h5 mb-1 fw-semibold"><i class="bi bi-envelope-paper me-2 text-primary" aria-hidden="true"></i>Email templates</h3><p class="text-muted small mb-0">Customize the messages sent for email verification and password resets.</p></div>
|
||||
<div class="card-body">
|
||||
<div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" data-verification-expiry="{{mediaSettings.verificationExpiryMinutes}}" data-reset-expiry="{{mediaSettings.resetExpiryMinutes}}" data-invitation-expiry="{{mediaSettings.invitationExpiryHours}}" hidden></div>
|
||||
<div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" data-otp-expiry="{{mediaSettings.otpExpiryMinutes}}" hidden></div>
|
||||
<div class="email-template-editor" data-email-template-editor>
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-12 col-lg-6">
|
||||
@@ -313,11 +311,7 @@
|
||||
<label class="form-label" for="settings-verification-body">Message</label>
|
||||
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Verification message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
|
||||
<textarea id="settings-verification-body" name="verification_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.verificationEmailBody}}</textarea>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12 col-md-8"><label class="form-label" for="settings-verification-button-text">Button text</label><input id="settings-verification-button-text" name="verification_button_text" type="text" class="form-control" value="{{mediaSettings.verificationButtonText}}" maxlength="120" data-email-template-button-text></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-alignment">Alignment</label><select id="settings-verification-alignment" name="verification_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.verificationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.verificationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.verificationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
|
||||
</div>
|
||||
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the verification link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
<div class="form-text">Use <code>[[otp]]</code> for the one-time code. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="email-template-preview" data-email-template-preview aria-label="Email verification preview"></div>
|
||||
@@ -334,36 +328,13 @@
|
||||
<label class="form-label" for="settings-reset-body">Message</label>
|
||||
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Reset message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
|
||||
<textarea id="settings-reset-body" name="reset_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.resetEmailBody}}</textarea>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12 col-md-8"><label class="form-label" for="settings-reset-button-text">Button text</label><input id="settings-reset-button-text" name="reset_button_text" type="text" class="form-control" value="{{mediaSettings.resetButtonText}}" maxlength="120" data-email-template-button-text></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-alignment">Alignment</label><select id="settings-reset-alignment" name="reset_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.resetButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.resetButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.resetButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
|
||||
</div>
|
||||
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the reset link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
<div class="form-text">Use <code>[[otp]]</code> for the one-time code. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="email-template-preview" data-email-template-preview aria-label="Password reset preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-4">
|
||||
<div class="email-template-editor" data-email-template-editor>
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-12 col-lg-6">
|
||||
<h4 class="h6">User invitation</h4>
|
||||
<label class="form-label" for="settings-invitation-subject">Subject</label>
|
||||
<input id="settings-invitation-subject" name="invitation_subject" type="text" class="form-control mb-2" value="{{mediaSettings.invitationEmailSubject}}" maxlength="255" data-email-template-subject>
|
||||
<label class="form-label" for="settings-invitation-body">Message</label>
|
||||
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Invitation message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
|
||||
<textarea id="settings-invitation-body" name="invitation_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.invitationEmailBody}}</textarea>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12 col-md-8"><label class="form-label" for="settings-invitation-button-text">Button text</label><input id="settings-invitation-button-text" name="invitation_button_text" type="text" class="form-control" value="{{mediaSettings.invitationButtonText}}" maxlength="120" data-email-template-button-text></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-alignment">Alignment</label><select id="settings-invitation-alignment" name="invitation_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.invitationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.invitationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.invitationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
|
||||
</div>
|
||||
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the invitation link. Available values: <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6"><div class="email-template-preview" data-email-template-preview aria-label="User invitation preview"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{#if (hasPermission currentUser 'system-settings.update')}}<button type="submit" class="btn btn-success">Save</button>{{else}}<button type="button" class="btn btn-outline-success" disabled>Save</button>{{/if}}
|
||||
@@ -371,6 +342,9 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{#if (hasPermission currentUser 'login-lockouts.allow')}}
|
||||
<form id="reset-login-lockouts-form" method="post" action="/settings/system/login-lockouts/reset" data-confirm-message="Clear sign-in and two-factor request rate limits for all clients? This does not clear per-account authenticator lockouts."></form>
|
||||
{{/if}}
|
||||
<form method="post" action="/settings/system" data-async-save data-settings-form="security">
|
||||
<input type="hidden" name="settings_section" value="security">
|
||||
<div id="security-sessions" class="card settings-section-card" data-settings-section hidden>
|
||||
@@ -383,22 +357,6 @@
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12"><h4 class="h6 text-uppercase text-muted mb-0">Sessions</h4></div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="settings-session-lifetime">Session lifetime</label>
|
||||
<div class="input-group">
|
||||
<input id="settings-session-lifetime" name="session_lifetime_days" type="number" class="form-control" min="1" max="365" required value="{{mediaSettings.sessionLifetimeDays}}">
|
||||
<span class="input-group-text">days</span>
|
||||
</div>
|
||||
<div class="form-text">Applies to new logins. Existing sessions keep their current expiry.</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="settings-max-active-sessions">Maximum active sessions</label>
|
||||
<div class="input-group">
|
||||
<input id="settings-max-active-sessions" name="max_active_sessions" type="number" class="form-control" min="0" max="100" required value="{{mediaSettings.maxActiveSessions}}">
|
||||
<span class="input-group-text">sessions</span>
|
||||
</div>
|
||||
<div class="form-text">Use 0 for unlimited. Oldest sessions are removed first.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input id="settings-allow-session-revocation" name="allow_user_session_revocation" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.allowUserSessionRevocation}}checked{{/if}}>
|
||||
@@ -406,26 +364,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Login protection</h4></div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="settings-login-attempts">Failed attempts before lockout</label>
|
||||
<input id="settings-login-attempts" name="login_max_attempts" type="number" class="form-control" min="1" max="100" required value="{{mediaSettings.loginMaxAttempts}}">
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="settings-login-lockout">Lockout duration</label>
|
||||
<div class="input-group">
|
||||
<input id="settings-login-lockout" name="login_lockout_minutes" type="number" class="form-control" min="1" max="1440" required value="{{mediaSettings.loginLockoutMinutes}}">
|
||||
<span class="input-group-text">minutes</span>
|
||||
<div class="col-12"><p class="form-text mb-0">Built-in request protection allows up to 30 requests per 15 minutes, with username sign-in limited to 5 per 15 minutes.</p></div>
|
||||
{{#if (hasPermission currentUser 'login-lockouts.allow')}}
|
||||
<div class="col-12">
|
||||
<button type="submit" form="reset-login-lockouts-form" class="btn btn-sm btn-outline-warning"><i class="bi bi-unlock me-1" aria-hidden="true"></i>Reset sign-in and two-factor request limits</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="settings-login-scope">Track failed logins by</label>
|
||||
<select id="settings-login-scope" name="login_rate_limit_scope" class="form-select" required>
|
||||
<option value="both" {{#if (eq mediaSettings.loginRateLimitScope 'both')}}selected{{/if}}>Username and IP address</option>
|
||||
<option value="username" {{#if (eq mediaSettings.loginRateLimitScope 'username')}}selected{{/if}}>Username</option>
|
||||
<option value="ip" {{#if (eq mediaSettings.loginRateLimitScope 'ip')}}selected{{/if}}>IP address</option>
|
||||
</select>
|
||||
<div class="form-text">Failed login responses remain generic.</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Password and account protection</h4></div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<label class="form-label" for="settings-password-min-length">Minimum length</label>
|
||||
@@ -450,13 +394,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input id="settings-require-password-new" name="require_password_change_for_new_users" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.requirePasswordChangeForNewUsers}}checked{{/if}}>
|
||||
<label class="form-check-label" for="settings-require-password-new">Require password change for newly created users</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input id="settings-require-password-reset" name="require_password_change_after_admin_reset" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.requirePasswordChangeAfterAdminReset}}checked{{/if}}>
|
||||
<label class="form-check-label" for="settings-require-password-reset">Require password change after an administrator reset</label>
|
||||
<input id="settings-admin-email-verification-bypass" name="allow_admin_email_verification_bypass" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.allowAdminEmailVerificationBypass}}checked{{/if}}>
|
||||
<label class="form-check-label" for="settings-admin-email-verification-bypass">Allow administrators to mark user email addresses as verified</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">{{#if isEdit}}Profile{{else}}User details{{/if}}</h3>
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="col-12">
|
||||
<label for="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" class="form-label">Email address</label>
|
||||
<input id="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" type="email" name="email" class="form-control" autocomplete="email" value="{{#if isEdit}}{{user.email}}{{else}}{{formValues.email}}{{/if}}" maxlength="320" />
|
||||
{{#if isEdit}}<div class="form-text">{{#if user.email_verified_at}}Verified{{else}}Not verified{{/if}}. Email is used for password recovery.</div>{{/if}}
|
||||
{{#if isEdit}}<div class="form-text">{{#if user.email_verified}}Verified{{else}}Not verified{{/if}}. Email is used for password recovery.</div>{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{#if isEdit}}
|
||||
@@ -39,19 +39,25 @@
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input id="edit-user-email-verified" name="email_verified" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if user.email_verified_at}}checked{{/if}}>
|
||||
<input id="edit-user-email-verified" name="email_verified" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if user.email_verified}}checked{{/if}}>
|
||||
<label class="form-check-label" for="edit-user-email-verified">Email verified</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="border-top mt-3 pt-3">
|
||||
<div class="form-check form-switch">
|
||||
<input id="user-must-change-password" name="must_change_password" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if isEdit}}{{#if user.must_change_password}}checked{{/if}}{{else}}checked{{/if}}>
|
||||
<label class="form-check-label" for="user-must-change-password">Require password change at next sign-in</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap">
|
||||
{{#if isEdit}}
|
||||
{{#if (hasPermission currentUser 'impersonate.allow')}}
|
||||
{{#unless currentUser.isViewingAs}}
|
||||
<button type="submit" form="view-as-user-form" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person-badge me-1" aria-hidden="true"></i>View as</button>
|
||||
<button type="submit" form="view-as-user-form" class="btn btn btn-outline-secondary"><i class="bi bi-person-badge me-1" aria-hidden="true"></i>View as</button>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -76,40 +82,80 @@
|
||||
<input id="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="10" {{#unless isEdit}}required{{/unless}} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-2">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
|
||||
<div class="form-text">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
|
||||
{{#if isEdit}}
|
||||
<div class="form-text mt-2">Leave both fields blank to keep the current password.</div>
|
||||
<div class="form-text">Leave both fields blank to keep the current password.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if isEdit}}
|
||||
<div class="card card-outline card-secondary admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Sign-in methods</h3>
|
||||
</div>
|
||||
<div class="card-body py-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
{{#each authMethods}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>{{label}}</span>
|
||||
<span class="small text-success">Linked</span>
|
||||
</li>
|
||||
{{/each}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>{{#if user.twoFactorEnabled}}Authenticator app (TOTP){{else}}{{#if user.twoFactorSetupPending}}Authenticator setup{{else}}Two-step verification{{/if}}{{/if}}</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{{#if user.twoFactorEnabled}}<span class="small text-success">Enabled</span>{{else}}{{#if user.twoFactorSetupPending}}<span class="small text-warning">Setup pending</span>{{else}}<span class="small text-body-secondary">Not enabled</span>{{/if}}{{/if}}
|
||||
{{#if user.canUnlinkTwoFactor}}<button type="submit" form="unlink-two-factor-form" class="btn btn-sm btn-outline-danger">Unlink</button>{{/if}}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item px-0" data-admin-passkey-group>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<span>Passkeys</span>
|
||||
<span class="small text-body-secondary">{{#if user.passkeys.length}}{{user.passkeys.length}} registered{{else}}Not registered{{/if}}</span>
|
||||
</div>
|
||||
{{#if user.passkeys.length}}
|
||||
<ul class="list-group list-group-flush ms-2 mt-1 ps-2" data-admin-passkey-list>
|
||||
{{#each user.passkeys}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">
|
||||
<span><i class="bi bi-arrow-return-right me-2 text-body-secondary" aria-hidden="true"></i>{{#if name}}{{name}}{{else}}Passkey{{/if}}</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="small text-success">Linked</span>
|
||||
{{#if ../user.canUnlinkPasskeys}}<button type="submit" form="unlink-passkey-form-{{id}}" class="btn btn-sm btn-outline-danger">Unlink</button>{{/if}}
|
||||
</span>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Roles</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{{#if roles.length}}
|
||||
<div class="card-body py-0">
|
||||
{{#if roles.length}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{#each roles}}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
||||
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="list-group-item d-flex align-items-start gap-3 px-0">
|
||||
<input class="form-check-input mt-1 flex-shrink-0" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="flex-grow-1">
|
||||
<strong class="d-block">{{name}}</strong>
|
||||
<span class="text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mb-0">{{#if isEdit}}Create at least one role before assigning this role.{{else}}Create at least one role before adding users.{{/if}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text mt-2">{{#if isEdit}}Select one or more roles. Leave them blank to remove all roles.{{else}}Select one or more roles, or leave them blank to create a user without roles.{{/if}}</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="py-3"><div class="alert alert-warning mb-0">{{#if isEdit}}Create at least one role before assigning this role.{{else}}Create at least one role before adding users.{{/if}}</div></div>
|
||||
{{/if}}
|
||||
<div class="form-text border-top py-2">{{#if isEdit}}Select one or more roles. Leave them blank to remove all roles.{{else}}Select one or more roles, or leave them blank to create a user without roles.{{/if}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,6 +163,14 @@
|
||||
</form>
|
||||
|
||||
{{#if isEdit}}
|
||||
{{#if user.canUnlinkTwoFactor}}
|
||||
<form id="unlink-two-factor-form" method="post" action="/settings/users/{{user.id}}/two-factor/unlink" data-confirm-message="Unlink two-step verification? The user will need to sign in again."></form>
|
||||
{{/if}}
|
||||
{{#if user.canUnlinkPasskeys}}
|
||||
{{#each user.passkeys}}
|
||||
<form id="unlink-passkey-form-{{id}}" method="post" action="/settings/users/{{../user.id}}/passkeys/{{id}}/unlink" data-confirm-message="Unlink this passkey? The user will need to sign in again."></form>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'impersonate.allow')}}
|
||||
{{#unless currentUser.isViewingAs}}
|
||||
<form id="view-as-user-form" method="post" action="/settings/users/{{user.id}}/view-as" data-confirm-message="View as {{user.username}}? This preview is read-only and ends after 15 minutes."></form>
|
||||
|
||||
@@ -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>
|
||||
@@ -13,11 +13,7 @@
|
||||
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{{#if (hasPermission currentUser 'invitations.create')}}
|
||||
<a href="/settings/users/invite" class="btn btn-info btn-sm">Invite user</a><a class="btn btn-primary btn-sm" href="/settings/users/new">Add<span class="table-action-context"> user</span></a>
|
||||
{{/if}}
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="/settings/users/new">Add<span class="table-action-context"> user</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
|
||||
@@ -28,36 +28,19 @@
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/shared/system-status.js"></script>
|
||||
{{else if authShell}}
|
||||
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
|
||||
{{#if message}}
|
||||
<div class="toast shadow-lg border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}warning{{/if}}" data-toast-created-at="">
|
||||
<div class="toast-header border-0 text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}warning{{/if}}">
|
||||
{{#if (eq messageVariant 'success')}}
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'warning')}}
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'danger')}}
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'primary')}}
|
||||
<i class="bi bi-bell-fill me-2"></i>
|
||||
{{else}}
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
{{/if}}
|
||||
<strong class="me-auto">Pulse</strong>
|
||||
<small class="toast-time text-nowrap">just now</small>
|
||||
<button type="button" class="btn-close btn-close-white ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
|
||||
</div>
|
||||
<div class="toast-body">{{message}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
|
||||
<div class="login-box">
|
||||
{{{body}}}
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/js/shared/ui/toast.js"></script>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
{{#each scripts}}
|
||||
{{#if (isExternalUrl this)}}
|
||||
<script src="{{this}}"></script>
|
||||
{{else}}
|
||||
<script src="/assets/{{this}}"></script>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
<script src="/assets/js/shared/system-status.js"></script>
|
||||
{{else}}
|
||||
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
|
||||
@@ -285,10 +268,10 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'invitations.read' 'fonts.read' 'weather-icons.read')}}
|
||||
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read' 'weather-icons.read')}}
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'users.read' 'rbac.read' 'invitations.read')}}
|
||||
{{#if (anyPermission currentUser 'users.read' 'rbac.read')}}
|
||||
<li class="nav-item has-treeview{{#if userManagementMenuOpen}} menu-open{{/if}}">
|
||||
<a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#">
|
||||
<i class="nav-icon bi bi-people"></i>
|
||||
@@ -314,14 +297,6 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'invitations.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'users-invitations')}}active{{/if}}" href="/settings/invitations">
|
||||
<i class="nav-icon bi bi-envelope-open"></i>
|
||||
<p>Pending invitations</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</li>
|
||||
{{/if}}
|
||||
@@ -384,7 +359,7 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'settings')}}active{{/if}}" href="/settings/system">
|
||||
<i class="nav-icon bi bi-sliders"></i>
|
||||
<p>System Settings</p>
|
||||
<p>System settings</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const test = require('node:test');
|
||||
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', () => {
|
||||
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.');
|
||||
});
|
||||
|
||||
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, {
|
||||
url: 'https://example.test/verify',
|
||||
display_name: 'Alex',
|
||||
otp: '123456',
|
||||
expiry_time: '30 minutes',
|
||||
action_label: 'Verify email address'
|
||||
});
|
||||
|
||||
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, /Confirm this email address: <a href="https:\/\/example\.test\/verify">https:\/\/example\.test\/verify<\/a>/);
|
||||
assert.doesNotMatch(rendered.html, /\[\[(?:action_button|url)\]\]/);
|
||||
assert.match(rendered.html, /verification code[\s\S]*<strong>123456<\/strong>/);
|
||||
assert.match(rendered.html, /This code expires in <em>30 minutes<\/em>\./);
|
||||
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)\]\]/);
|
||||
});
|
||||
@@ -27,6 +27,197 @@ test('required-password page shows only password controls and uses an inline war
|
||||
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 () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
@@ -67,8 +258,9 @@ test('required-password route renders the password-only page and redirects after
|
||||
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 = {};
|
||||
let changePasswordRequest = null;
|
||||
const app = {
|
||||
post(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
@@ -79,10 +271,7 @@ test('account password update redirects back to the account page', async () => {
|
||||
const deps = {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1')) {
|
||||
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 = ?')) {
|
||||
if (sql.includes('UPDATE a_users SET must_change_password = 0')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
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: {},
|
||||
pages: {},
|
||||
betterAuth: {
|
||||
api: {
|
||||
async changePassword(request) { changePasswordRequest = request; }
|
||||
}
|
||||
},
|
||||
fromNodeHeaders(headers) { return headers; },
|
||||
formatDashboardDate: () => '',
|
||||
getAuditUserId: () => 9,
|
||||
verifyPassword: () => true,
|
||||
validatePasswordStrength: validatePasswordStrength,
|
||||
hashPassword: () => ({ hash: 'new-hash', salt: 'new-salt', iterations: 2 }),
|
||||
createUserSession: async () => 'session-token',
|
||||
setSessionCookie: () => {}
|
||||
clearSessionCookie() {}
|
||||
};
|
||||
|
||||
registerAccountRoutes(app, deps);
|
||||
@@ -113,6 +305,7 @@ test('account password update redirects back to the account page', async () => {
|
||||
new_password: 'NewPassword!1',
|
||||
confirm_password: 'NewPassword!1'
|
||||
},
|
||||
headers: { cookie: 'digital_signage_session=signed-token' },
|
||||
currentUser: { id: 5 }
|
||||
};
|
||||
const res = {
|
||||
@@ -129,5 +322,60 @@ test('account password update redirects back to the account page', async () => {
|
||||
|
||||
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.');
|
||||
});
|
||||
@@ -15,6 +15,7 @@ test('user create route allows users without roles', async () => {
|
||||
};
|
||||
|
||||
let insertedArgs = null;
|
||||
let credentialPassword = null;
|
||||
let syncedRoles = null;
|
||||
|
||||
const deps = {
|
||||
@@ -33,6 +34,13 @@ test('user create route allows users without roles', async () => {
|
||||
insertedArgs = args;
|
||||
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 [[]];
|
||||
},
|
||||
async commit() {},
|
||||
@@ -81,7 +89,8 @@ test('user create route allows users without roles', async () => {
|
||||
name: 'No Roles User',
|
||||
username: 'noroles',
|
||||
password: 'ValidPass!1',
|
||||
confirm_password: 'ValidPass!1'
|
||||
confirm_password: 'ValidPass!1',
|
||||
must_change_password: '1'
|
||||
},
|
||||
currentUser: { id: 1 }
|
||||
};
|
||||
@@ -100,6 +109,8 @@ test('user create route allows users without roles', async () => {
|
||||
await routeHandlers[1](req, res, () => {});
|
||||
|
||||
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.equal(res.redirectedTo, '/settings/users/11/edit?message=User%20created.');
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
const handlers = {};
|
||||
let mustChangePasswordValue = null;
|
||||
const app = {
|
||||
get() {},
|
||||
post(path, ...routeHandlers) {
|
||||
@@ -28,8 +29,9 @@ test('user edit save and new goes to the blank create page', async () => {
|
||||
async getConnection() {
|
||||
return {
|
||||
async beginTransaction() {},
|
||||
async query(sql) {
|
||||
if (sql.includes('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?')) {
|
||||
async query(sql, params) {
|
||||
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 [[]];
|
||||
@@ -75,6 +77,7 @@ test('user edit save and new goes to the blank create page', async () => {
|
||||
body: {
|
||||
name: 'User',
|
||||
username: 'user',
|
||||
must_change_password: '1',
|
||||
save_action: 'new'
|
||||
},
|
||||
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, () => {});
|
||||
|
||||
assert.equal(res.redirectedTo, '/settings/users/new?message=User%20updated.');
|
||||
assert.equal(mustChangePasswordValue, 1);
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -8,6 +8,8 @@ const { validatePasswordStrength } = require('../src/auth');
|
||||
|
||||
test('user password reset redirects back to the selected user edit page', async () => {
|
||||
const handlers = {};
|
||||
let savedMustChangePassword = null;
|
||||
let credentialPassword = null;
|
||||
const app = {
|
||||
post(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
@@ -17,11 +19,19 @@ test('user password reset redirects back to the selected user edit page', async
|
||||
|
||||
const deps = {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT id FROM a_users WHERE id = ? LIMIT 1')) {
|
||||
return [[{ id: 7 }]];
|
||||
async query(sql, params) {
|
||||
if (sql.includes('SELECT id, username, must_change_password FROM a_users WHERE id = ? LIMIT 1')) {
|
||||
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 }];
|
||||
}
|
||||
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 = {
|
||||
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 }
|
||||
};
|
||||
const res = {
|
||||
@@ -77,4 +87,117 @@ test('user password reset redirects back to the selected user edit page', async
|
||||
await routeHandlers[1](req, res, () => {});
|
||||
|
||||
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.');
|
||||
});
|
||||
@@ -16,16 +16,12 @@ test('application settings expose typed defaults', () => {
|
||||
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.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.max_active_sessions'], 0);
|
||||
assert.equal(settings['security.password_min_length'], 10);
|
||||
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_after_admin_reset'], true);
|
||||
assert.equal(settings['security.login_max_attempts'], 5);
|
||||
assert.equal(settings['security.login_lockout_minutes'], 15);
|
||||
assert.equal(settings['security.login_rate_limit_scope'], 'both');
|
||||
assert.equal(settings['security.require_password_change_for_new_users'], undefined);
|
||||
assert.equal(settings['security.require_password_change_after_admin_reset'], undefined);
|
||||
assert.equal(settings['security.allow_admin_email_verification_bypass'], true);
|
||||
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.api_default_interval_value'], 60);
|
||||
|
||||
@@ -51,6 +51,22 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
||||
logoPath: {
|
||||
from: '',
|
||||
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, /backgroundColor 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.match(response.body, />Export</);
|
||||
assert.ok(handlers['/settings/audit-log/export']);
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
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');
|
||||
|
||||
function createPool(settings) {
|
||||
@@ -68,6 +68,20 @@ test('audit writer omits request metadata when disabled', async () => {
|
||||
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', () => {
|
||||
assert.deepEqual(buildAuditChanges({
|
||||
name: 'Old name',
|
||||
@@ -81,4 +95,126 @@ test('audit changes include only fields with different from and to values', () =
|
||||
name: { from: 'Old name', to: 'New name' },
|
||||
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 }
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -8,13 +8,91 @@ const pages = require('../src/web/pages');
|
||||
const renderLoginPage = require('../src/web/routes/auth/login');
|
||||
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', () => {
|
||||
const html = renderLoginPage('', '/slides/6/edit', 'demo');
|
||||
|
||||
assert.match(html, /name="returnTo"/);
|
||||
assert.match(html, /value="\/slides\/6\/edit"/);
|
||||
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, /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', () => {
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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({
|
||||
sessionCookieName: 'pulse_session',
|
||||
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')) {
|
||||
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')) {
|
||||
return [[
|
||||
@@ -91,6 +169,8 @@ test('session loader keeps the administrator identity while loading preview-user
|
||||
assert.deepEqual(currentUser.permissionKeys, ['screens.read']);
|
||||
assert.equal(currentUser.isViewingAs, true);
|
||||
assert.equal(currentUser.authenticatedUser.id, 7);
|
||||
assert.equal(currentUser.requiresTwoFactor, false);
|
||||
assert.equal(currentUser.authenticatedUser.requiresTwoFactor, true);
|
||||
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' } };
|
||||
req.betterAuthSession = { session: { token: 'token' } };
|
||||
|
||||
assert.equal(await service.startViewAsUser(pool, req, 7, 19), true);
|
||||
assert.equal(await service.stopViewAsUser(pool, req, 7), true);
|
||||
assert.deepEqual(queries[0][1], [19, 7, 'hash:token', 7]);
|
||||
assert.deepEqual(queries[1][1], [7, 'hash:token', 7]);
|
||||
assert.deepEqual(queries[0][1], [19, 7, 'token', 7]);
|
||||
assert.deepEqual(queries[1][1], [7, 'token', 7]);
|
||||
});
|
||||
|
||||
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"/);
|
||||
});
|
||||
|
||||
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 app = {
|
||||
get(path, ...routeHandlers) {
|
||||
@@ -243,218 +324,27 @@ test('successful login returns to the requested link instead of the dashboard',
|
||||
};
|
||||
|
||||
registerAuthRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
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;
|
||||
},
|
||||
pool: { async query() { return [[]]; } },
|
||||
pages: {},
|
||||
clearSessionCookie() {},
|
||||
parseCookies() {
|
||||
return {};
|
||||
},
|
||||
consumeAuthMessageCookie() {
|
||||
return '';
|
||||
},
|
||||
parseCookies() { return {}; },
|
||||
consumeAuthMessageCookie() { return ''; },
|
||||
setAuthMessageCookie() {},
|
||||
hashSessionToken(value) {
|
||||
return value;
|
||||
},
|
||||
verifyPassword() {
|
||||
return true;
|
||||
},
|
||||
hashSessionToken(value) { return value; },
|
||||
sessionCookieName: 'pulse_session'
|
||||
});
|
||||
|
||||
const req = {
|
||||
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, '/');
|
||||
assert.equal(handlers['POST /login'], undefined);
|
||||
});
|
||||
|
||||
test('accept invite page renders a valid invitation with password requirements', async () => {
|
||||
const handlers = {};
|
||||
let rendered = null;
|
||||
test('invitation acceptance endpoints are not registered', () => {
|
||||
const routes = [];
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
get(path) { routes.push('GET ' + path); },
|
||||
post(path) { routes.push('POST ' + path); }
|
||||
};
|
||||
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
|
||||
});
|
||||
registerAuthRoutes(app, {});
|
||||
|
||||
const res = { send(value) { this.body = value; }, status() { return this; } };
|
||||
await handlers['GET /accept-invite'][0]({ query: { token: 'invite-token' } }, res, (error) => { throw error; });
|
||||
|
||||
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);
|
||||
assert.equal(routes.includes('GET /accept-invite'), false);
|
||||
assert.equal(routes.includes('POST /accept-invite'), false);
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -12,8 +12,7 @@ test('player control sync sends minimized authorization data to remote players o
|
||||
async query() {
|
||||
return [[{
|
||||
username: 'operator',
|
||||
password_hash: 'hash',
|
||||
password_salt: 'salt'
|
||||
password_hash: 'pbkdf2-sha256$310000$salt$hash'
|
||||
}]];
|
||||
}
|
||||
},
|
||||
@@ -44,8 +43,7 @@ test('player control sync sends minimized authorization data to remote players o
|
||||
command: 'sync-local-control',
|
||||
users: [{
|
||||
username_hash: crypto.createHash('sha256').update('operator').digest('hex'),
|
||||
password_hash: 'hash',
|
||||
password_salt: 'salt'
|
||||
password_hash: 'pbkdf2-sha256$310000$salt$hash'
|
||||
}]
|
||||
}
|
||||
}]);
|
||||
|
||||
@@ -9,7 +9,7 @@ const { WebSocket } = require('ws');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { hashPassword } = require('../src/auth');
|
||||
const { hashScryptPassword } = require('../src/auth');
|
||||
const { createLocalControlService } = require('../src/player/local-control');
|
||||
|
||||
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 () => {
|
||||
const { app, service, temporaryDirectory } = await createTestService();
|
||||
const password = hashPassword('CorrectHorseBatteryStaple!');
|
||||
const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
|
||||
await service.saveUsers([{
|
||||
id: 7,
|
||||
name: 'Operator',
|
||||
username: 'operator',
|
||||
email: 'operator@example.com',
|
||||
password_hash: password.hash,
|
||||
password_salt: password.salt,
|
||||
password_iterations: password.iterations
|
||||
password_hash: password
|
||||
}]);
|
||||
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].email, 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);
|
||||
try {
|
||||
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 () => {
|
||||
const { app, service, temporaryDirectory } = await createTestService({ cacheMaxAgeMs: 1 });
|
||||
const password = hashPassword('CorrectHorseBatteryStaple!');
|
||||
const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
|
||||
await service.saveUsers([{
|
||||
id: 7,
|
||||
name: 'Operator',
|
||||
username: 'operator',
|
||||
password_hash: password.hash,
|
||||
password_salt: password.salt,
|
||||
password_iterations: password.iterations
|
||||
password_hash: password
|
||||
}]);
|
||||
await service.loadCache();
|
||||
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 () => {
|
||||
const { app, service, temporaryDirectory } = await createTestService({ loginRateLimitMaxAttempts: 2, loginRateLimitWindowMs: 60 * 1000 });
|
||||
const password = hashPassword('CorrectHorseBatteryStaple!');
|
||||
const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
|
||||
await service.saveUsers([{
|
||||
username: 'operator',
|
||||
password_hash: password.hash,
|
||||
password_salt: password.salt
|
||||
password_hash: password
|
||||
}]);
|
||||
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 () => {
|
||||
const { server, service, temporaryDirectory } = await createTestService();
|
||||
const password = hashPassword('CorrectHorseBatteryStaple!');
|
||||
const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
|
||||
await service.saveUsers([{
|
||||
username: 'operator',
|
||||
password_hash: password.hash,
|
||||
password_salt: password.salt
|
||||
password_hash: password
|
||||
}]);
|
||||
await service.loadCache();
|
||||
|
||||
|
||||
@@ -197,4 +197,66 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
||||
}, invalidRes, () => {});
|
||||
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 });
|
||||
});
|
||||
@@ -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 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');
|
||||
|
||||
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', () => {
|
||||
assert.match(rbacFormTemplate, /name="permissions_present" value="1"/);
|
||||
assert.match(rbacFormTemplate, /name="user_ids_present\[\]" value="\{\{id\}\}"/);
|
||||
|
||||
@@ -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] === 'notify'));
|
||||
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]));
|
||||
assert.equal(writes.length, 2);
|
||||
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]);
|
||||
assert.deepEqual(persisted.hero, { ...savedContent.hero, layout: { animation_json: { loop: { preset: 'pulse' } } } });
|
||||
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.ok(calls.findIndex(call => call[0] === 'commit') > calls.findIndex(call => call === writes[0]));
|
||||
});
|
||||
|
||||
@@ -5,6 +5,13 @@ require('../src/common');
|
||||
|
||||
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) {
|
||||
const queries = [];
|
||||
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 () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const queries = [];
|
||||
const pool = {
|
||||
|
||||
+110
-7
@@ -6,6 +6,70 @@ require('../src/common');
|
||||
const registerSettingsRoutes = require('../src/web/routes/settings/routes');
|
||||
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 () => {
|
||||
const handlers = {};
|
||||
const queries = [];
|
||||
@@ -31,7 +95,7 @@ test('settings route renders the overview', async () => {
|
||||
}
|
||||
},
|
||||
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) {
|
||||
next();
|
||||
};
|
||||
@@ -48,7 +112,7 @@ test('settings route renders the overview', async () => {
|
||||
});
|
||||
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['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);
|
||||
}).forEach(function (entry) {
|
||||
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=/);
|
||||
|
||||
@@ -103,18 +167,21 @@ test('settings route renders the overview', async () => {
|
||||
}
|
||||
};
|
||||
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 }
|
||||
}, securityResponse, function (error) {
|
||||
throw error;
|
||||
});
|
||||
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) {
|
||||
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) {
|
||||
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.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$/);
|
||||
});
|
||||
@@ -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' });
|
||||
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);
|
||||
|
||||
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]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql)));
|
||||
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 () => {
|
||||
|
||||
@@ -2,7 +2,10 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
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');
|
||||
|
||||
test('user edit view model includes delete confirmation text', () => {
|
||||
@@ -35,4 +38,78 @@ test('user form keeps username editable in the admin section', () => {
|
||||
assert.ok(!usersFormTemplate.includes('{{#if isEdit}}disabled{{else}}required{{/if}}'));
|
||||
assert.ok(usersFormTemplate.includes('required />'));
|
||||
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);
|
||||
});
|
||||
@@ -4,6 +4,45 @@ const path = require('path');
|
||||
|
||||
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', () => {
|
||||
const calls = [];
|
||||
const app = {
|
||||
|
||||
Reference in new issue
Block a user