From fba4f99ff71d75beb57d26e521efb58806d0132c Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 25 Oct 2026 18:19:55 +0000 Subject: [PATCH] Release v2.20.1: migrate authentication to Better Auth --- CHANGELOG.md | 18 + build/package.player.json | 2 +- build/package.web.json | 4 +- docker-compose/.env.example | 2 + docker-compose/.env.remote.example | 2 - docker-compose/README.md | 5 + docker-compose/docker-compose.yml | 2 + docs/technical/schema.md | 59 +- docs/user/guide-admin.md | 29 +- package-lock.json | 1152 ++++++++++++++++- package.json | 6 +- src/auth.js | 56 + src/data/account-email-templates.js | 18 +- src/data/app-settings.js | 23 +- src/data/audit-log.js | 71 +- src/data/auth-accounts.js | 58 + src/db/bootstrap.js | 14 +- src/db/index.js | 99 +- src/db/migrations.js | 154 ++- src/player/local-control.js | 9 +- src/rbac.js | 19 +- src/web.js | 65 +- src/web/lib/auth/better-auth.js | 320 +++++ src/web/lib/auth/rbac-data.js | 53 +- src/web/lib/auth/session.js | 106 +- src/web/lib/local-control-users.js | 11 +- src/web/lib/web-startup.js | 6 +- src/web/middleware.js | 10 +- src/web/pages/index.js | 5 - src/web/public/css/theme-custom.css | 43 + src/web/public/js/account/account-page.js | 56 - src/web/public/js/account/passkeys.js | 70 + src/web/public/js/account/two-factor.js | 146 +++ src/web/public/js/auth/login.js | 137 ++ .../public/js/auth/passkey-client-entry.js | 6 + src/web/public/js/auth/passkey-client.js | 4 + src/web/public/js/settings/settings-page.js | 48 +- src/web/routes/account/password.js | 14 +- src/web/routes/admin/account.js | 238 ++-- src/web/routes/admin/content.js | 12 +- src/web/routes/admin/playlists.js | 35 +- src/web/routes/admin/rbac.js | 22 +- src/web/routes/admin/users.js | 290 ++--- src/web/routes/auth/accept-invite.js | 27 - .../routes/auth/email-verification-error.js | 5 - src/web/routes/auth/email-verified.js | 5 - src/web/routes/auth/index.js | 286 +--- src/web/routes/auth/login.js | 5 +- src/web/routes/auth/reset-password.js | 4 +- src/web/routes/register.js | 25 +- src/web/routes/settings/invitations/list.js | 15 - src/web/routes/settings/rbac/duplicate.js | 3 +- src/web/routes/settings/routes.js | 105 +- .../routes/settings/users/form-view-model.js | 11 +- src/web/routes/settings/users/invite.js | 14 - src/web/views/account/index.hbs | 312 ++++- src/web/views/auth/accept-invite.hbs | 27 - .../views/auth/email-verification-error.hbs | 11 - src/web/views/auth/email-verified.hbs | 11 - src/web/views/auth/forgot-password.hbs | 2 +- src/web/views/auth/login.hbs | 71 +- src/web/views/auth/reset-password.hbs | 5 +- src/web/views/settings/invitations/list.hbs | 67 - src/web/views/settings/rbac/form.hbs | 4 + src/web/views/settings/system/index.hbs | 90 +- src/web/views/settings/users/form.hbs | 108 +- src/web/views/settings/users/invite.hbs | 67 - src/web/views/settings/users/list.hbs | 6 +- src/web/views/shared/layout.hbs | 45 +- test/account-email-templates.test.js | 22 +- test/admin-account-password-route.test.js | 268 +++- .../admin-users-create-no-roles-route.test.js | 13 +- test/admin-users-edit-save-new-route.test.js | 8 +- test/admin-users-invitations-route.test.js | 192 --- test/admin-users-password-route.test.js | 133 +- test/app-settings.test.js | 10 +- test/audit-log-route.test.js | 21 + test/audit-log-settings.test.js | 138 +- test/auth-account-sync.test.js | 27 + test/auth-routes.test.js | 308 ++--- test/better-auth-password.test.js | 142 ++ test/email-otp-routes.test.js | 137 ++ test/player-control-sync.test.js | 6 +- test/player-local-control.test.js | 27 +- test/playlists-routes.test.js | 62 + test/rbac-permissions.test.js | 71 + test/region-layout.test.js | 4 + test/schema-update-log.test.js | 372 ++++++ test/settings-route.test.js | 117 +- test/timetable-migrations.test.js | 4 +- test/users-form-view-model.test.js | 77 ++ test/web-middleware.test.js | 39 + 92 files changed, 5085 insertions(+), 1943 deletions(-) create mode 100644 src/data/auth-accounts.js create mode 100644 src/web/lib/auth/better-auth.js delete mode 100644 src/web/public/js/account/account-page.js create mode 100644 src/web/public/js/account/passkeys.js create mode 100644 src/web/public/js/account/two-factor.js create mode 100644 src/web/public/js/auth/login.js create mode 100644 src/web/public/js/auth/passkey-client-entry.js create mode 100644 src/web/public/js/auth/passkey-client.js delete mode 100644 src/web/routes/auth/accept-invite.js delete mode 100644 src/web/routes/auth/email-verification-error.js delete mode 100644 src/web/routes/auth/email-verified.js delete mode 100644 src/web/routes/settings/invitations/list.js delete mode 100644 src/web/routes/settings/users/invite.js delete mode 100644 src/web/views/auth/accept-invite.hbs delete mode 100644 src/web/views/auth/email-verification-error.hbs delete mode 100644 src/web/views/auth/email-verified.hbs delete mode 100644 src/web/views/settings/invitations/list.hbs delete mode 100644 src/web/views/settings/users/invite.hbs delete mode 100644 test/admin-users-invitations-route.test.js create mode 100644 test/auth-account-sync.test.js create mode 100644 test/better-auth-password.test.js create mode 100644 test/email-otp-routes.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a50da..821ea32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build/package.player.json b/build/package.player.json index e043a25..2c90e64 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -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": { diff --git a/build/package.web.json b/build/package.web.json index c252c47..d1249f9 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -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", diff --git a/docker-compose/.env.example b/docker-compose/.env.example index 1c95522..30bf7bd 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -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" diff --git a/docker-compose/.env.remote.example b/docker-compose/.env.remote.example index 9d5e653..9b849d8 100644 --- a/docker-compose/.env.remote.example +++ b/docker-compose/.env.remote.example @@ -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" \ No newline at end of file diff --git a/docker-compose/README.md b/docker-compose/README.md index a143ef6..5aef8da 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -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` | diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index fe062c9..b1c13fa 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -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} diff --git a/docs/technical/schema.md b/docs/technical/schema.md index c65f9f1..e24ebdb 100644 --- a/docs/technical/schema.md +++ b/docs/technical/schema.md @@ -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 diff --git a/docs/user/guide-admin.md b/docs/user/guide-admin.md index 0962daf..9ac9b3c 100644 --- a/docs/user/guide-admin.md +++ b/docs/user/guide-admin.md @@ -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? diff --git a/package-lock.json b/package-lock.json index 09efe9b..cb4321c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "pulse-signage", - "version": "2.19.3", + "version": "2.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.19.3", + "version": "2.20.1", "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", @@ -25,12 +27,160 @@ "ws": "^8.21.3" }, "devDependencies": { + "esbuild": "^0.28.2", "nodemon": "^3.1.10" }, "engines": { "node": ">=26.12.1" } }, + "node_modules/@better-auth/core": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.13.tgz", + "integrity": "sha512-h7zk+hwNxNxva2I49ELg1b3yNVG5CneluIp860DYcikWWZlCd3Kh6zYiDcY0Bvt/meRl0XBoQhjp7WlNk4a9fQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "@better-fetch/fetch": "1.3.3", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.4.4", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.13.tgz", + "integrity": "sha512-BCA2uJQJ9/GrIt/RvnDIVBlo7r1ZgkAICLG20wxjxLIvSLVVlpuZhjNUFf8FtaHYmRXOCYL566hZ114MAvl4lg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.13", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.13.tgz", + "integrity": "sha512-Pw6roVatSW+B3McdTJv+sS0V5EnwSPS3v/IcYCLcoWTy9LaMSUa/jmGZQarQJ0jAawtv+PFTcYQKXS1Glvjpbg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.13", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.13.tgz", + "integrity": "sha512-9rnOCaXgeiXNTr4EZOAI75xyt+pg7+ILhuY/AP4fpBVnrA9B5RZNg6wrPVUwo9YU5HsK/Ho1Ov4ikAOUicJixQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.13", + "@better-auth/utils": "^0.4.2 || ^0.5.0" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.13.tgz", + "integrity": "sha512-U/wnes25UBiKA7Kfz8tiimSs8BF1+h05ro99arzFis186XwRpFdD/YvtErmcXH5jMVAvIoCUZNmso+SNQQtsgA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.13", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/passkey": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/@better-auth/passkey/-/passkey-1.7.13.tgz", + "integrity": "sha512-LITtv4nLcZP2Q4fWrhEGhjVIoN8Di5H8WwDCR1n7DbTVuEq15660lsO/zv2t4iEJx+78BxE4m2fxgkwIQRHaXg==", + "license": "MIT", + "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@better-auth/core": "^1.7.13", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "@better-fetch/fetch": "1.3.3", + "better-auth": "^1.7.13", + "better-call": "1.4.4", + "nanostores": "^1.0.1" + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.13.tgz", + "integrity": "sha512-401uXNuw8TR/u/dRasmLPRhDW+eqr6NUX6o0D7bq9gCTf4vP4DK1vYSOmG0SJJt5KC1mM1g4n4HtQI2Tq2eLZw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.13", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.13.tgz", + "integrity": "sha512-+v9bIV837pBPLWAB1MLpqY+4MDgetiqv1A2tw3gFYseiz/+2FxfAt0FXbpYHGf9fEaGtPaPWMvBE9dNyIE74MQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.13", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "@better-fetch/fetch": "1.3.3" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.1.tgz", + "integrity": "sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.3.tgz", + "integrity": "sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg==", + "license": "MIT" + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -41,6 +191,454 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -595,6 +1193,237 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.11.1.tgz", + "integrity": "sha512-AuUBRDNdWrgtnuJGXARa0Xdtq6OAD8GqezDQz6Qka7kyynMo9fzkcrtpBEHuX984uEB570BrhMZfJfkUDfpQfw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.11.1.tgz", + "integrity": "sha512-Bk+oatE62dW0Tzzpfk6vdQxfGGEfvuGdWgmijnKjcv7HqZCJISs85z4zw2uDArrk+R1Oel1wts/BBl7HHrtAcA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.1", + "@peculiar/asn1-x509": "^2.11.1", + "@peculiar/asn1-x509-attr": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.11.1.tgz", + "integrity": "sha512-KVfbXaKng8886/tOPG55t5K1XkTkGbR4amjIJzytXWobFU5nZgw/sKnSpa1hxP17nUSsrOF/7Mt9WUYeyLsi6Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.1", + "@peculiar/asn1-x509": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.11.1.tgz", + "integrity": "sha512-7MSNji77fQa/oN3WAfwagMy8EoJFg50sW4hZ1dtr0zU1BSugit0I2DGpIdPhvkNplopQhz5bogxpn1077eaamA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.1", + "@peculiar/asn1-x509": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.11.1.tgz", + "integrity": "sha512-2SN2z8qkc2hk/GgTYA8RN54wtsCDZShg9gI2Mq1vLhdj1adcv5fhTbiEmExZUaAMqlizxiEpULQDfHM5iYtn1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.11.1", + "@peculiar/asn1-pkcs8": "^2.11.1", + "@peculiar/asn1-rsa": "^2.11.1", + "@peculiar/asn1-schema": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.11.1.tgz", + "integrity": "sha512-HqsSrnpQghRp9SM9QSmR6fa4z/BNktnKq7928+oq4qF0VAnuHZ4cyePKvcxh9fP4taBCP/WHNItlVNsYaQm2qw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.1", + "@peculiar/asn1-x509": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.11.1.tgz", + "integrity": "sha512-OHFkTg8D2zYafILqFE4KPbqX8srpvd3diGDc2h2cduumawc765mOHGcuFMGvLWlL2bbCMWMmMSCtlXptsOuiPA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.11.1", + "@peculiar/asn1-pfx": "^2.11.1", + "@peculiar/asn1-pkcs8": "^2.11.1", + "@peculiar/asn1-schema": "^2.11.1", + "@peculiar/asn1-x509": "^2.11.1", + "@peculiar/asn1-x509-attr": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.11.1.tgz", + "integrity": "sha512-6KwKGjulF489CpidZqkogNgLj6oGzgC4BL2FClM8PuJQ4t5NqT4q4a6A4vrSxBIBhMnL8hGYkLOpbVw0Vq38Tw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.1", + "@peculiar/asn1-x509": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.1.tgz", + "integrity": "sha512-jaU9OaCcbZUdyXx9hMETZB9xoNmxTGjOGFqd3cOwKFkpKnyKd9WxF3uqj4PvX4cyOMP9bSdHg5YyML2qtx/d/g==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.11.1.tgz", + "integrity": "sha512-SXU4/X6B3FquqQAN6ARI0bFDUV7+L1WlvPEPMKzEMkstd2D7UyM0r93fv241+OjdhqAKYGx5MvV3gzZMAp0APA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.1", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.11.1.tgz", + "integrity": "sha512-n7RKFrbTzt8pGBlZ90kmKEn1qsLTTyw7lrvHMK0xsA42kQugPk/GXYpNbEcl7DkkdVsU/one5UX7KQPfxLj8vQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.11.1", + "@peculiar/asn1-x509": "^2.11.1", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@puppeteer/browsers": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.0.tgz", @@ -623,6 +1452,37 @@ } } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz", + "integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -721,6 +1581,20 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", + "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -740,6 +1614,127 @@ "node": "18 || 20 || >=22" } }, + "node_modules/better-auth": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.13.tgz", + "integrity": "sha512-ncevUrVAWmYbC6Jjxadeh2QLBGMbsfenu8pwgAHJRexXJ4ifPCMbT4e4u79QKPuGpR5Wjw9yPReedVoh6m/qBg==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.13", + "@better-auth/drizzle-adapter": "1.7.13", + "@better-auth/kysely-adapter": "1.7.13", + "@better-auth/memory-adapter": "1.7.13", + "@better-auth/mongo-adapter": "1.7.13", + "@better-auth/prisma-adapter": "1.7.13", + "@better-auth/telemetry": "1.7.13", + "@better-auth/utils": "0.5.1", + "@better-fetch/fetch": "1.3.3", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "better-call": "1.4.4", + "defu": "^6.1.4", + "jose": "^6.2.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.3.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0 || ^3.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.4.tgz", + "integrity": "sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -996,6 +1991,15 @@ "devtools-protocol": "*" } }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -1105,6 +2109,12 @@ } } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1206,6 +2216,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1687,12 +2739,30 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.13", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz", + "integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jsqr": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", "license": "Apache-2.0" }, + "node_modules/kysely": { + "version": "0.29.7", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.7.tgz", + "integrity": "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -1863,6 +2933,21 @@ "node": ">=8.0.0" } }, + "node_modules/nanostores": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", + "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2028,6 +3113,24 @@ "node": ">=22.12.0" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -2099,6 +3202,18 @@ "node": ">=8.10.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2223,6 +3338,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.2.1.tgz", + "integrity": "sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2493,8 +3614,25 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" }, "node_modules/type-is": { "version": "1.6.18", @@ -2681,9 +3819,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 5df16d2..7460496 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/auth.js b/src/auth.js index 0bfe7a3..bc4ce2f 100644 --- a/src/auth.js +++ b/src/auth.js @@ -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, diff --git a/src/data/account-email-templates.js b/src/data/account-email-templates.js index 1d28ac4..93565b9 100644 --- a/src/data/account-email-templates.js +++ b/src/data/account-email-templates.js @@ -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 }; diff --git a/src/data/app-settings.js b/src/data/app-settings.js index f0d1620..7a8d192 100644 --- a/src/data/app-settings.js +++ b/src/data/app-settings.js @@ -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: [] }, diff --git a/src/data/audit-log.js b/src/data/audit-log.js index 199fc56..2334a03 100644 --- a/src/data/audit-log.js +++ b/src/data/audit-log.js @@ -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 }; \ No newline at end of file diff --git a/src/data/auth-accounts.js b/src/data/auth-accounts.js new file mode 100644 index 0000000..01b4fcf --- /dev/null +++ b/src/data/auth-accounts.js @@ -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 }; \ No newline at end of file diff --git a/src/db/bootstrap.js b/src/db/bootstrap.js index c5c752a..ce112e2 100644 --- a/src/db/bootstrap.js +++ b/src/db/bootstrap.js @@ -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']); diff --git a/src/db/index.js b/src/db/index.js index ff56dde..a20ed33 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -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 `); diff --git a/src/db/migrations.js b/src/db/migrations.js index bbb6e93..96141dd 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -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' + ] + ); + } } ]; diff --git a/src/player/local-control.js b/src/player/local-control.js index 9ee5ed0..1b8b6d9 100644 --- a/src/player/local-control.js +++ b/src/player/local-control.js @@ -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.' }); } diff --git a/src/rbac.js b/src/rbac.js index 3963d40..482e2a1 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -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, diff --git a/src/web.js b/src/web.js index b47590a..95e4ae5 100644 --- a/src/web.js +++ b/src/web.js @@ -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, diff --git a/src/web/lib/auth/better-auth.js b/src/web/lib/auth/better-auth.js new file mode 100644 index 0000000..0b282d2 --- /dev/null +++ b/src/web/lib/auth/better-auth.js @@ -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 +}; \ No newline at end of file diff --git a/src/web/lib/auth/rbac-data.js b/src/web/lib/auth/rbac-data.js index 6c78deb..aeb26da 100644 --- a/src/web/lib/auth/rbac-data.js +++ b/src/web/lib/auth/rbac-data.js @@ -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, diff --git a/src/web/lib/auth/session.js b/src/web/lib/auth/session.js index fa1db51..21d9b4d 100644 --- a/src/web/lib/auth/session.js +++ b/src/web/lib/auth/session.js @@ -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 }; diff --git a/src/web/lib/local-control-users.js b/src/web/lib/local-control-users.js index 8ed5437..76bef18 100644 --- a/src/web/lib/local-control-users.js +++ b/src/web/lib/local-control-users.js @@ -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); }); } diff --git a/src/web/lib/web-startup.js b/src/web/lib/web-startup.js index d0ace2a..1bd5cd2 100644 --- a/src/web/lib/web-startup.js +++ b/src/web/lib/web-startup.js @@ -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({ diff --git a/src/web/middleware.js b/src/web/middleware.js index d9dd95f..9d43eca 100644 --- a/src/web/middleware.js +++ b/src/web/middleware.js @@ -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(); }); }); diff --git a/src/web/pages/index.js b/src/web/pages/index.js index 1259f62..c2aa73f 100644 --- a/src/web/pages/index.js +++ b/src/web/pages/index.js @@ -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')), diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index afadc69..d2ac213 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -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; } diff --git a/src/web/public/js/account/account-page.js b/src/web/public/js/account/account-page.js deleted file mode 100644 index a80e0cf..0000000 --- a/src/web/public/js/account/account-page.js +++ /dev/null @@ -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(); - }); -})(); diff --git a/src/web/public/js/account/passkeys.js b/src/web/public/js/account/passkeys.js new file mode 100644 index 0000000..f7ee457 --- /dev/null +++ b/src/web/public/js/account/passkeys.js @@ -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; + } + }); + }); +})(); \ No newline at end of file diff --git a/src/web/public/js/account/two-factor.js b/src/web/public/js/account/two-factor.js new file mode 100644 index 0000000..dad6fe7 --- /dev/null +++ b/src/web/public/js/account/two-factor.js @@ -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; + } + }); + } +})(); \ No newline at end of file diff --git a/src/web/public/js/auth/login.js b/src/web/public/js/auth/login.js new file mode 100644 index 0000000..ab4687f --- /dev/null +++ b/src/web/public/js/auth/login.js @@ -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; + } + }); +})(); \ No newline at end of file diff --git a/src/web/public/js/auth/passkey-client-entry.js b/src/web/public/js/auth/passkey-client-entry.js new file mode 100644 index 0000000..f47f96b --- /dev/null +++ b/src/web/public/js/auth/passkey-client-entry.js @@ -0,0 +1,6 @@ +import { createAuthClient } from 'better-auth/client'; +import { passkeyClient } from '@better-auth/passkey/client'; + +window.pulsePasskeyAuth = createAuthClient({ + plugins: [passkeyClient()] +}); \ No newline at end of file diff --git a/src/web/public/js/auth/passkey-client.js b/src/web/public/js/auth/passkey-client.js new file mode 100644 index 0000000..c9e8fc4 --- /dev/null +++ b/src/web/public/js/auth/passkey-client.js @@ -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{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(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{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);if(!t)return null;let[,r,n,o,s,l,a,i,c,u,d]=t,p=new Date(Date.UTC(parseInt(r,10),parseInt(n,10)-1,parseInt(o,10),parseInt(s,10),parseInt(l,10),parseInt(a,10),i?parseInt(i.padEnd(3,"0"),10):0));if(c){let m=(parseInt(u,10)*60+parseInt(d,10))*(c==="+"?-1:1);p.setUTCMinutes(p.getUTCMinutes()+m)}return Tt(p)?p:null}function At(e,t={}){let{strict:r=!1,warnings:n=!1,reviver:o,parseDates:s=!0}=t;if(typeof e!="string")return e;let l=e.trim(),a=l.toLowerCase();if(a.length<=9&&a in Ue)return Ue[a];if(!bt.test(l)){if(r)throw new SyntaxError("[better-json] Invalid JSON");return e}if(Object.entries(Rt).some(([i,c])=>{let u=c.test(l);return u&&n&&console.warn(`[better-json] Detected potential prototype pollution attempt using ${i} pattern`),u})&&r)throw new Error("[better-json] Potential prototype pollution attempt detected");try{return JSON.parse(l,(c,u)=>{if(c==="__proto__"||c==="constructor"&&u&&typeof u=="object"&&"prototype"in u){n&&console.warn(`[better-json] Dropping "${c}" key to prevent prototype pollution`);return}if(s&&typeof u=="string"){let d=St(u);if(d)return d}return o?o(c,u):u})}catch(i){if(r)throw i;return e}}function Ie(e,t={strict:!0}){return At(e,t)}var wt=Symbol.for("better-auth:auth-query");function ee(e){let t=D(e);return Object.defineProperty(t,wt,{value:!0}),t}var Ot=()=>typeof window>"u";function _t(e,t){return L(e.data,t.data)&&e.error===t.error&&e.isPending===t.isPending&&e.isRefetching===t.isRefetching&&e.refetch===t.refetch}var me=(e,t,r,n)=>{let o=ee({data:null,error:null,isPending:!0,isRefetching:!1,refetch:d=>l(d)});j(o,()=>X(o,_t));let s=0,l=async d=>{let p=++s;return new Promise(m=>{let E=typeof n=="function"?n({data:o.get().data,error:o.get().error,isPending:o.get().isPending}):n;r(t,{...E,query:{...E?.query,...d?.query},async onSuccess(f){if(p===s){let g=o.get(),h=g.data!=null&&f.data!=null&&L(g.data,f.data)?g.data:f.data;o.set({data:h,error:null,isPending:!1,isRefetching:!1,refetch:o.value.refetch})}await E?.onSuccess?.(f)},async onError(f){let{request:g}=f,h=typeof g.retry=="number"?g.retry:g.retry?.attempts,T=g.retryAttempt||0;if(!(h&&T{p===s&&o.set({error:f,data:o.get().data,isPending:!1,isRefetching:!1,refetch:o.value.refetch})}).finally(()=>{m(void 0)})})};e=Array.isArray(e)?e:[e];let a=!1,i=!1,c=!1,u=()=>{if(a){c=!0;return}a=!0,l().finally(()=>{a=!1;let d=c&&i;c=!1,d&&u()})};return j(o,()=>{if(Ot())return;i=!0;let d=!1,p,m=e.map(E=>E.listen(()=>{d?l():(d=!0,clearTimeout(p),u())}));return p=setTimeout(()=>{d=!0,u()},0),()=>{i=!1;for(let E of m)E();clearTimeout(p)}}),o};var Ee=()=>Math.floor(Date.now()/1e3),Pt=5;function Le(e){let{fetchSession:t,shouldPollSession:r=()=>!0,sessionSignal:n,options:o={}}=e,s=o.sessionOptions?.refetchInterval??0,l=o.sessionOptions?.refetchOnWindowFocus??!0,a=o.sessionOptions?.refetchWhenOffline??!1,i={isInitialized:!1,lastSessionRequest:0},c=()=>a||Z().isOnline,u=R=>{if(c()){if(R?.event==="storage"){t();return}if(R?.event==="poll"){i.lastSessionRequest=Ee(),t();return}if(R?.event==="visibilitychange"){if(Ee()-i.lastSessionRequest{J().post({event:"session",data:{trigger:R},clientId:Math.random().toString(36).substring(7)})},p=()=>{s&&s>0&&(i.pollInterval=setInterval(()=>{r()&&u({event:"poll"})},s*1e3))},m=()=>{i.unsubscribeBroadcast=J().subscribe(()=>{u({event:"storage"})})},E=()=>{l&&(i.unsubscribeFocus=he().subscribe(()=>{u({event:"visibilitychange"})}))},f=()=>{i.unsubscribeOnline=Z().subscribe(R=>{R&&u({event:"visibilitychange"})})},g=()=>{i.unsubscribeSignal=n.listen(()=>{t()})};return{init:()=>{i.isInitialized||(i.isInitialized=!0,p(),m(),E(),f(),g(),i.cleanupBroadcastSetup=J().setup(),i.cleanupFocusSetup=he().setup(),i.cleanupOnlineSetup=Z().setup())},cleanup:()=>{i.isInitialized&&(i.pollInterval&&(clearInterval(i.pollInterval),i.pollInterval=void 0),i.unsubscribeBroadcast&&(i.unsubscribeBroadcast(),i.unsubscribeBroadcast=void 0),i.unsubscribeFocus&&(i.unsubscribeFocus(),i.unsubscribeFocus=void 0),i.unsubscribeOnline&&(i.unsubscribeOnline(),i.unsubscribeOnline=void 0),i.unsubscribeSignal&&(i.unsubscribeSignal(),i.unsubscribeSignal=void 0),i.cleanupBroadcastSetup&&(i.cleanupBroadcastSetup(),i.cleanupBroadcastSetup=void 0),i.cleanupFocusSetup&&(i.cleanupFocusSetup(),i.cleanupFocusSetup=void 0),i.cleanupOnlineSetup&&(i.cleanupOnlineSetup(),i.cleanupOnlineSetup=void 0),i.isInitialized=!1,i.lastSessionRequest=0)},triggerRefetch:u,broadcastSessionUpdate:d}}var te=Object.create(null),V=e=>globalThis.process?.env||globalThis.Deno?.env.toObject()||globalThis.__env__||(e?te:globalThis),I=new Proxy(te,{get(e,t){return V()[t]??te[t]},has(e,t){return t in V()||t in te},set(e,t,r){let n=V(!0);return n[t]=r,!0},deleteProperty(e,t){if(!t)return!1;let r=V(!0);return delete r[t],!0},ownKeys(){let e=V(!0);return Object.keys(e)}});var Ut=I.NODE_ENV??"";function N(e,t){return typeof process<"u"&&process.env?process.env[e]??t:typeof Deno<"u"?Deno.env.get(e)??t:typeof Bun<"u"?Bun.env[e]??t:t}var It=Object.freeze({get BETTER_AUTH_SECRET(){return N("BETTER_AUTH_SECRET")},get AUTH_SECRET(){return N("AUTH_SECRET")},get BETTER_AUTH_TELEMETRY(){return N("BETTER_AUTH_TELEMETRY")},get BETTER_AUTH_TELEMETRY_ID(){return N("BETTER_AUTH_TELEMETRY_ID")},get NODE_ENV(){return N("NODE_ENV","development")},get PACKAGE_VERSION(){return N("PACKAGE_VERSION","0.0.0")},get BETTER_AUTH_TELEMETRY_ENDPOINT(){return N("BETTER_AUTH_TELEMETRY_ENDPOINT","")}});function Ne(e){return Object.fromEntries(Object.entries(e).map(([t,r])=>[t,{code:t,message:r,toString:()=>t}]))}function Lt(){let e=Object.getOwnPropertyDescriptor(Error,"stackTraceLimit");return e===void 0?Object.isExtensible(Error):Object.prototype.hasOwnProperty.call(e,"writable")?e.writable:e.set!==void 0}function Nt(e){let t=e.split(` + 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";let{data:h,error:T}=Be(g),R="fresh";if(h?.needsRefresh)try{let y=await e("/get-session",{method:"POST",signal:m});if(m.aborted)return"aborted";({data:h,error:T}=Be(y))}catch{if(m.aborted)return"aborted";R="stale"}if(T){let y=a.value,B=T?.status===401;return a.set({data:B?null:y.data,error:T,isPending:!1,isRefetching:!1,refetch:l}),"failed"}let _=Wt(h),A=a.value,U=A.data!=null&&_!=null&&L(A.data,_)?A.data:_;return a.set({data:U,error:null,isPending:!1,isRefetching:!1,refetch:l}),R}catch(g){if(m.aborted)return"aborted";let h=a.value;return a.set({data:h.data,error:g,isPending:!1,isRefetching:!1,refetch:l}),"failed"}},c=()=>{let m=a.value.data?.session?.expiresAt,E=m instanceof Date?m.getTime():Number.POSITIVE_INFINITY;return Math.min(Date.now()+Vt,E)},u=m=>{o=0,n?.cancel();let E=new AbortController,f={cancel:()=>E.abort(),promise:Promise.resolve().then(()=>E.signal.aborted?"aborted":i(E.signal,m)),revision:s};n=f;let g=h=>{n===f&&(n=void 0,h==="fresh"&&f.revision===s&&(o=c()))};return f.promise.then(g,()=>g("failed")),f.promise.then(()=>{})},d=()=>n?.revision===s?n.promise.then(()=>{}):Date.now(){};return j(a,()=>{let m;Ht()||(m=setTimeout(()=>{d()},0));let E=Le({fetchSession:u,shouldPollSession:()=>a.value.data!=null,sessionSignal:r,options:t});return E.init(),p=E.broadcastSessionUpdate,()=>{m&&clearTimeout(m),E.cleanup()}}),{session:a,$sessionSignal:r,broadcastSessionUpdate:m=>p(m)}}function ye(e){if(e===null||typeof e!="object")return!1;let t=Object.getPrototypeOf(e);return t!==null&&t!==Object.prototype&&Object.getPrototypeOf(t)!==null||Symbol.iterator in e?!1:Symbol.toStringTag in e?Object.prototype.toString.call(e)==="[object Module]":!0}function ge(e,t,r=".",n){if(!ye(t))return ge(e,{},r,n);let o={...t};for(let s of Object.keys(e)){if(s==="__proto__"||s==="constructor")continue;let l=e[s];l!=null&&(n&&n(o,s,l,r)||(Array.isArray(l)&&Array.isArray(o[s])?o[s]=[...l,...o[s]]:ye(l)&&ye(o[s])?o[s]=ge(l,o[s],(r?`${r}.`:"")+s.toString(),n):o[s]=l))}return o}function Re(e){return(...t)=>t.reduce((r,n)=>ge(r,n,"",e),{})}var Fe=Re(),Nn=Re((e,t,r)=>{if(e[t]!==void 0&&typeof r=="function")return e[t]=r(e[t]),!0}),Cn=Re((e,t,r)=>{if(Array.isArray(e[t])&&typeof r=="function")return e[t]=r(e[t]),!0});var Kt=class extends Error{constructor(e,t,r){super(t||e.toString(),{cause:r}),this.status=e,this.statusText=t,this.error=r,Error.captureStackTrace(this,this.constructor)}},Yt=async(e,t)=>{let r=t||{},n={onRequest:[t?.onRequest],onResponse:[t?.onResponse],onSuccess:[t?.onSuccess],onError:[t?.onError],onRetry:[t?.onRetry]};if(!t||!t?.plugins)return{url:e,options:r,hooks:n};for(let u of t?.plugins||[]){var o,s,l,a,i;if(u.init){var c;let d=await((c=u.init)===null||c===void 0?void 0:c.call(u,e.toString(),r));r=d.options||r,e=d.url}n.onRequest.push((o=u.hooks)===null||o===void 0?void 0:o.onRequest),n.onResponse.push((s=u.hooks)===null||s===void 0?void 0:s.onResponse),n.onSuccess.push((l=u.hooks)===null||l===void 0?void 0:l.onSuccess),n.onError.push((a=u.hooks)===null||a===void 0?void 0:a.onError),n.onRetry.push((i=u.hooks)===null||i===void 0?void 0:i.onRetry)}return{url:e,options:r,hooks:n}},He=class{constructor(e){this.options=e}shouldAttemptRetry(e,t){return this.options.shouldRetry?Promise.resolve(e0&&e[t-1]==="/";)t--;return e.slice(0,t)}function be(e,t){let r=e.indexOf(t);return r===-1?[e,void 0]:[e.slice(0,r),e.slice(r+1)]}var Xt=e=>e==="."||e==="..",Zt=e=>e.split(":").map(t=>encodeURIComponent(t)).join(":");function er(e,t){let r=t.get(e);if(r===void 0)return Zt(e);if(Xt(r))throw new TypeError("Path parameters cannot be reserved path segments");return encodeURIComponent(r)}function tr(e,t){let r=new Map;if(t)if(Array.isArray(t)){let n=e.split("/").filter(o=>o.startsWith(":"));for(let[o,s]of n.entries()){let l=t[o];r.set(s,String(l))}}else for(let[n,o]of Object.entries(t))r.set(`:${n}`,String(o));return r}function rr(e,t){let r=tr(e,t);return e.split("/").map(n=>er(n,r)).join("/")}function nr(e,t){let r=new URLSearchParams(e);for(let[o,s]of Object.entries(t||{})){if(s==null)continue;let l;if(typeof s=="string")l=s;else if(Array.isArray(s)){for(let a of s)r.append(o,a);continue}else l=JSON.stringify(s);r.set(o,l)}let n=r.toString();return n.length>0?`?${n}`.replace(/\+/g,"%20"):""}function or(e,t){if(!t)return e;let r=`${Ye(t)}/`;return e.startsWith(r)?e.slice(r.length):e}function sr(e,t,r,n){let[o]=be(e,"#"),[s,l=""]=be(o,"?"),a=t.replace(Qt,""),i=nr(`${l}&${r??""}`,n),c=`${Ye(s)}/${a}${i}`;return Ke.test(c)?new URL(c):c}function ir(e,t){var r,n;let{baseURL:o,params:s,query:l}=t||{query:{},params:{},baseURL:""},{path:a}=Te(e),i=a===e?e:`/${a}`,c=(r=i.match(Ke))===null||r===void 0?void 0:r[0],[u,d]=be(c?i.slice(c.length):or(i,o),"?"),p=rr(u,s);return sr((n=c??o)!==null&&n!==void 0?n:"",p,d,l)}var ar=async e=>{let t={},r=async n=>typeof n=="function"?await n():n;if(e?.auth){if(e.auth.type==="Bearer"){let n=await r(e.auth.token);if(!n)return t;t.authorization=`Bearer ${n}`}else if(e.auth.type==="Basic"){let[n,o]=await Promise.all([r(e.auth.username),r(e.auth.password)]);if(!n||!o)return t;t.authorization=`Basic ${btoa(`${n}:${o}`)}`}else if(e.auth.type==="Custom"){let[n,o]=await Promise.all([r(e.auth.prefix),r(e.auth.value)]);if(!o)return t;t.authorization=`${n??""} ${o}`}}return t},ur=/^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i;function lr(e){let t=e.headers.get("content-type"),r=new Set(["image/svg","application/xml","application/xhtml","application/html"]);if(!t)return"json";let n=t.split(";").shift()||"";return ur.test(n)?"json":r.has(n)||n.startsWith("text/")?"text":"blob"}function cr(e){try{return JSON.parse(e),!0}catch{return!1}}function Je(e){if(e===void 0)return!1;let t=typeof e;return t==="string"||t==="number"||t==="boolean"||t===null?!0:t!=="object"?!1:Array.isArray(e)?!0:e.buffer?!1:e.constructor&&e.constructor.name==="Object"||typeof e.toJSON=="function"}function Ve(e){try{return JSON.parse(e)}catch{return e}}function We(e){return typeof e=="function"}function fr(e){if(e?.customFetchImpl)return e.customFetchImpl;if(typeof globalThis<"u"&&We(globalThis.fetch))return globalThis.fetch;if(typeof window<"u"&&We(window.fetch))return window.fetch;throw new Error("No fetch implementation found")}function $e(...e){let t={};for(let r of e)if(r)if(r instanceof Headers)r.forEach((n,o)=>{t[o]=n});else{let n=Array.isArray(r)?r:Object.entries(r);for(let[o,s]of n)s!=null&&(t[o]=s)}return t}async function dr(e){let t=new Headers($e(e?.headers,await ar(e)));if(!t.has("content-type")){let r=hr(e?.body);r&&t.set("content-type",r)}return t}function hr(e){return Je(e)?"application/json":null}function pr(e){let t=e.get("content-type");return t?t.split(";")[0].trim().toLowerCase():null}function mr(e,t){let{body:r}=e;return r?!Je(r)||typeof r=="string"?r:pr(t)==="application/x-www-form-urlencoded"?new URLSearchParams(r).toString():JSON.stringify(r):null}function Er(e,t){if(t?.method)return t.method.toUpperCase();let{method:r}=Te(e);return r?r.toUpperCase():t?.body?"POST":"GET"}function yr(e,t){let r;return!e?.signal&&e?.timeout&&(r=setTimeout(()=>t?.abort(),e?.timeout)),{abortTimeout:r,clearTimeout:()=>{r&&clearTimeout(r)}}}var gr=class ze extends Error{constructor(t,r){super(r||JSON.stringify(t,null,2)),this.issues=t,Object.setPrototypeOf(this,ze.prototype)}};async function G(e,t){let r=await e["~standard"].validate(t);if(r.issues)throw new gr(r.issues);return r.value}function K(e){"@babel/helpers - typeof";return K=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(t){return typeof t}:function(t){return t&&typeof Symbol=="function"&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},K(e)}function Rr(e,t){if(K(e)!="object"||!e)return e;var r=e[Symbol.toPrimitive];if(r!==void 0){var n=r.call(e,t||"default");if(K(n)!="object")return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return(t==="string"?String:Number)(e)}function br(e){var t=Rr(e,"string");return K(t)=="symbol"?t:t+""}function vr(e,t,r){return(t=br(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function Ge(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter(function(o){return Object.getOwnPropertyDescriptor(e,o).enumerable})),r.push.apply(r,n)}return r}function b(e){for(var t=1;t{var r,n,o;let{hooks:s,url:l,options:a}=await Yt(e,t),i=fr(a),c=new AbortController,u=(r=a.signal)!==null&&r!==void 0?r:c.signal,d=ir(l,a),p=await dr(a),m=mr(a,p),E=Er(l,a),f=b(b({},a),{},{url:d,headers:p,body:m,method:E,signal:u});for(let w of s.onRequest)if(w){let S=await w(f);typeof S=="object"&&S!==null&&Object.assign(f,S)}("pipeTo"in f&&typeof f.pipeTo=="function"||typeof(t==null||(n=t.body)===null||n===void 0?void 0:n.pipe)=="function")&&("duplex"in f||(f.duplex="half"));let{clearTimeout:g}=yr(a,c),h=await i(f.url,f);g();let T={response:h,request:f};for(let w of s.onResponse)if(w){var R;let S=await w(b(b({},T),{},{response:!(t==null||(R=t.hookOptions)===null||R===void 0)&&R.cloneResponse?h.clone():h}));S instanceof Response?h=S:typeof S=="object"&&S!==null&&(h=S.response)}if(h.ok){if(f.method==="HEAD")return{data:"",error:null};let w=lr(h),S={data:null,response:h,request:f};if(w==="json"||w==="text"){var _;let q=await h.text();S.data=await((_=f.jsonParser)!==null&&_!==void 0?_:Ve)(q)}else S.data=await h[w]();f?.output&&f.output&&!f.disableValidation&&(S.data=await G(f.output,S.data));for(let q of s.onSuccess)if(q){var A;await q(b(b({},S),{},{response:!(t==null||(A=t.hookOptions)===null||A===void 0)&&A.cloneResponse?h.clone():h}))}return t?.throw?S.data:{data:S.data,error:null}}let U=(o=t?.jsonParser)!==null&&o!==void 0?o:Ve,y=await h.text(),B=cr(y),ie=B?await U(y):null,ct={response:h,responseText:y,request:f,error:b(b({},ie),{},{status:h.status,statusText:h.statusText})};for(let w of s.onError)if(w){var ae;await w(b(b({},ct),{},{response:!(t==null||(ae=t.hookOptions)===null||ae===void 0)&&ae.cloneResponse?h.clone():h}))}if(t?.retry){var ue;let w=$t(t.retry),S=(ue=t.retryAttempt)!==null&&ue!==void 0?ue:0;if(await w.shouldAttemptRetry(S,h)){for(let Y of s.onRetry)Y&&await Y(T);let q=w.getDelay(S);return await new Promise(Y=>setTimeout(Y,q)),await ve(e,b(b({},t),{},{retryAttempt:S+1}))}}if(t?.throw)throw new Kt(h.status,h.statusText,B?ie:y);return{data:null,error:b(b({},ie),{},{status:h.status,statusText:h.statusText})}},Tr=e=>({id:"apply-schema",name:"Apply Schema",version:"1.0.0",async init(t,r){var n;let o=b(b({},r),e.query!==void 0&&{query:b(b({},e.query),r?.query)}),s=((n=e.plugins)===null||n===void 0||(n=n.find(c=>{var u;return!((u=c.schema)===null||u===void 0)&&u.config?t.startsWith(c.schema.config.baseURL||"")||t.startsWith(c.schema.config.prefix||""):!1}))===null||n===void 0?void 0:n.schema)||e.schema;if(s){var l,a;let c=t;!((l=s.config)===null||l===void 0)&&l.prefix&&c.startsWith(s.config.prefix)&&(c=c.replace(s.config.prefix,""),s.config.baseURL&&(t=t.replace(s.config.prefix,s.config.baseURL))),!((a=s.config)===null||a===void 0)&&a.baseURL&&c.startsWith(s.config.baseURL)&&(c=c.replace(s.config.baseURL,"")),c.startsWith("/")&&c.charAt(1)==="@"&&(c=c.substring(1));let u=s.schema[c];if(u){var i;let{method:d,path:p}=Te(c);d&&(t=t.slice(0,t.length-c.length)+p);let m=r?.headers;if(u.headers&&!r?.disableValidation){let f={};if(r?.headers){if(r.headers instanceof Headers)r.headers.forEach((T,R)=>{f[R.toLowerCase()]=T});else if(typeof r.headers=="object")for(let[T,R]of Object.entries(r.headers))R!=null&&(f[T.toLowerCase()]=R)}let g=await G(u.headers,f),h={};for(let[T,R]of Object.entries(g))h[T.toLowerCase()]=R;m=h}let E=(i=u.method)!==null&&i!==void 0?i:d;if(o=b(b(b(b({},o),E!==void 0&&{method:E}),u.output!==void 0&&{output:u.output}),m!==void 0&&{headers:m}),!r?.disableValidation){if(u.query&&(o.query=await G(u.query,r?.query),o.query!==null&&typeof o.query=="object")){let f=b({},e.query);for(let g of Object.keys(f))r?.query&&Object.prototype.hasOwnProperty.call(r.query,g)&&(f[g]=r.query[g]);o.query=b(b({},f),o.query)}o=b(b({},o),{},{body:u.input?await G(u.input,r?.body):r?.body,params:u.params?await G(u.params,r?.params):r?.params})}return{url:t,options:o}}}return b({url:t},(r!==void 0||e.query!==void 0)&&{options:o})}}),Qe=e=>{async function t(r,n){let o=b(b(b({},e),n),{},{headers:$e(e?.headers,n?.headers),plugins:[...e?.plugins||[],Tr(e||{}),...n?.plugins||[]]});if(e?.catchAllError)try{return await ve(r,o)}catch(s){return{data:null,error:{status:500,statusText:"Fetch Error",message:"Fetch related error. Captured by catchAllError option. See error property for more details.",error:s}}}return await ve(r,o)}return t};var Sr=e=>{if(typeof process>"u")return;let t=e??"/api/auth";if(process.env.NEXT_PUBLIC_AUTH_URL)return process.env.NEXT_PUBLIC_AUTH_URL;if(typeof window>"u"){if(process.env.NEXTAUTH_URL)try{return process.env.NEXTAUTH_URL}catch{}if(process.env.VERCEL_URL)try{let r=process.env.VERCEL_URL.startsWith("http")?"":"https://";return`${new URL(`${r}${process.env.VERCEL_URL}`).origin}${t}`}catch{}}},Ar=e=>e==="/sign-out"||e==="/update-user"||e==="/update-session"||e==="/sign-up/email"||e==="/sign-in/email"||e==="/delete-user"||e==="/verify-email"||e==="/revoke-sessions"||e==="/revoke-session"||e==="/revoke-other-sessions"||e==="/change-email"||e==="/change-password",Xe=(e,t)=>{let r="credentials"in Request.prototype,n=xe(e?.baseURL,e?.basePath,void 0,t)??Sr(e?.basePath)??"/api/auth",o=e?.plugins?.flatMap(y=>y.fetchPlugins).filter(y=>y!==void 0)||[],s={id:"lifecycle-hooks",name:"lifecycle-hooks",hooks:{onSuccess:e?.fetchOptions?.onSuccess,onError:e?.fetchOptions?.onError,onRequest:e?.fetchOptions?.onRequest,onResponse:e?.fetchOptions?.onResponse}},{onSuccess:l,onError:a,onRequest:i,onResponse:c,...u}=e?.fetchOptions||{},d=Qe({baseURL:n,...r?{credentials:"include"}:{},method:"GET",jsonParser(y){return y?Ie(y,{strict:!1}):null},customFetchImpl:fetch,...u,plugins:[s,...u.plugins||[],...e?.disableDefaultFetchPlugins?[]:[Me],...o]}),{$sessionSignal:p,session:m,broadcastSessionUpdate:E}=je(d,e),f=!1,g=y=>{f||y===null||(f=!0,qe(m,y))},h=e?.plugins||[],T={},R={$sessionSignal:p,session:m},_={"/sign-out":"POST","/revoke-sessions":"POST","/revoke-other-sessions":"POST","/delete-user":"POST"},A=[{signal:"$sessionSignal",matcher:Ar,callback(y){y==="/sign-out"?E("signout"):(y==="/update-user"||y==="/update-session")&&E("updateUser")}}];for(let y of h)y.getAtoms&&Object.assign(R,y.getAtoms?.(d)),y.pathMethods&&Object.assign(_,y.pathMethods),y.atomListeners&&A.push(...y.atomListeners);let U={notify:y=>{R[y].set(!R[y].get())},listen:(y,B)=>{R[y].subscribe(B)},atoms:R};for(let y of h)y.getActions&&(T=Fe(y.getActions?.(d,U,e)??{},T));return{get baseURL(){return n},pluginsActions:T,pluginsAtoms:R,pluginPathMethods:_,atomListeners:A,hydrateSession:g,$sessionSignal:p,$fetch:d,$store:U}};function Ze(e){return typeof e=="object"&&e!==null&&"get"in e&&typeof e.get=="function"&&"lc"in e&&typeof e.lc=="number"}function et(e){return e.charAt(0).toUpperCase()+e.slice(1)}var wr=/[\p{Ll}\d]+|\p{Lu}+(?!\p{Ll})|\p{Lu}[\p{Ll}\d]+|\p{Lo}+/gu,Or=/['\u2019]/g;function _r(e){return e.replace(Or,"").match(wr)??[]}function tt(e){return _r(e).map(t=>t.toLowerCase()).join("-")}function Pr(e,t,r){let n=t[e],{fetchOptions:o,query:s,...l}=r||{};return n||(o?.method?o.method:l&&Object.keys(l).length>0?"POST":"GET")}function rt(e,t,r,n,o){function s(l=[]){return new Proxy(function(){},{get(a,i){if(typeof i!="string"||i==="then"||i==="catch"||i==="finally")return;let c=[...l,i],u=e;for(let d of c)if(u&&typeof u=="object"&&d in u)u=u[d];else{u=void 0;break}return typeof u=="function"||Ze(u)?u:s(c)},apply:async(a,i,c)=>{let u="/"+l.map(tt).join("/"),d=c[0]||{},p=c[1]||{},{query:m,fetchOptions:E,...f}=d,g={...p,...E},h=Pr(u,r,d);return await t(u,{...g,body:h==="GET"?void 0:{...f,...g?.body||{}},query:m||g?.query,method:h,async onSuccess(T){if(await g?.onSuccess?.(T),!o||g.disableSignal)return;let R=o.filter(A=>A.matcher(u));if(!R.length)return;let _=new Set;for(let A of R){let U=n[A.signal];if(!U)return;if(_.has(A.signal))continue;_.add(A.signal);let y=U.get();setTimeout(()=>{U.set(!y)},10),A.callback?.(u)}}})}})}return s()}function Se(e){let{pluginPathMethods:t,pluginsActions:r,pluginsAtoms:n,hydrateSession:o,$fetch:s,atomListeners:l,$store:a}=Xe(e),i={};for(let[u,d]of Object.entries(n))i[`use${et(u)}`]=d;let c={...r,...i,hydrateSession:o,$fetch:s,$store:a};return rt(c,s,t,n,l)}var x=Ne({CHALLENGE_NOT_FOUND:"Challenge not found",YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY:"You are not allowed to register this passkey",FAILED_TO_VERIFY_REGISTRATION:"Failed to verify registration",PASSKEY_NOT_FOUND:"Passkey not found",AUTHENTICATION_FAILED:"Authentication failed",UNABLE_TO_CREATE_SESSION:"Unable to create session",USER_NOT_FOUND:"User not found",FAILED_TO_UPDATE_PASSKEY:"Failed to update passkey",PREVIOUSLY_REGISTERED:"Previously registered",REGISTRATION_CANCELLED:"Registration cancelled",AUTH_CANCELLED:"Auth cancelled",UNKNOWN_ERROR:"Unknown error",SESSION_REQUIRED:"Passkey registration requires an authenticated session",RESOLVE_USER_REQUIRED:"Passkey registration requires either an authenticated session or a resolveUser callback when requireSession is false",RESOLVED_USER_INVALID:"Resolved user is invalid"}),nt="1.7.13";function P(e){let t=new Uint8Array(e),r="";for(let o of t)r+=String.fromCharCode(o);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function k(e){let t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),o=atob(n),s=new ArrayBuffer(o.length),l=new Uint8Array(s);for(let a=0;ae};function re(e){let{id:t}=e;return{...e,id:k(t),transports:e.transports}}function ne(e){return e==="localhost"||/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(e)}var v=class extends Error{constructor({message:t,code:r,cause:n,name:o}){super(t,{cause:n}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=o??n.name,this.code=r}};function ot({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:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if(e.name==="ConstraintError"){if(r.authenticatorSelection?.requireResidentKey===!0)return new v({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if(t.mediation==="conditional"&&r.authenticatorSelection?.userVerification==="required")return new v({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if(r.authenticatorSelection?.userVerification==="required")return new v({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if(e.name==="InvalidStateError")return new v({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if(e.name==="NotAllowedError")return new v({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="NotSupportedError")return r.pubKeyCredParams.filter(o=>o.type==="public-key").length===0?new v({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new v({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if(e.name==="SecurityError"){let n=globalThis.location.hostname;if(ne(n)){if(r.rp.id!==n)return new v({message:`The RP ID "${r.rp.id}" 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==="TypeError"){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new v({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",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 credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}var Ae=class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){let r=new Error("Cancelling existing WebAuthn API call for new one");r.name="AbortError",this.controller.abort(r)}let t=new AbortController;return this.controller=t,t.signal}cancelCeremony(){if(this.controller){let t=new Error("Manually cancelling existing WebAuthn API call");t.name="AbortError",this.controller.abort(t),this.controller=void 0}}},oe=new Ae;var Ir=["cross-platform","platform"];function se(e){if(e&&!(Ir.indexOf(e)<0))return e}async function st(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() 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,useAutoRegister:r=!1}=e;if(!M())throw new Error("WebAuthn is not supported in this browser");let n={...t,challenge:k(t.challenge),user:{...t.user,id:k(t.user.id)},excludeCredentials:t.excludeCredentials?.map(re)},o={};r&&(o.mediation="conditional"),o.publicKey=n,o.signal=oe.createNewAbortSignal();let s;try{s=await navigator.credentials.create(o)}catch(E){throw ot({error:E,options:o})}if(!s)throw new Error("Registration was not completed");let{id:l,rawId:a,response:i,type:c}=s,u;typeof i.getTransports=="function"&&(u=i.getTransports());let d;if(typeof i.getPublicKeyAlgorithm=="function")try{d=i.getPublicKeyAlgorithm()}catch(E){we("getPublicKeyAlgorithm()",E)}let p;if(typeof i.getPublicKey=="function")try{let E=i.getPublicKey();E!==null&&(p=P(E))}catch(E){we("getPublicKey()",E)}let m;if(typeof i.getAuthenticatorData=="function")try{m=P(i.getAuthenticatorData())}catch(E){we("getAuthenticatorData()",E)}return{id:l,rawId:P(a),response:{attestationObject:P(i.attestationObject),clientDataJSON:P(i.clientDataJSON),transports:u,publicKeyAlgorithm:d,publicKey:p,authenticatorData:m},type:c,clientExtensionResults:s.getClientExtensionResults(),authenticatorAttachment:se(s.authenticatorAttachment)}}function we(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them. +`,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 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:"UNKNOWN_ERROR",message:i instanceof Error?i.message:x.UNKNOWN_ERROR.message,status:500,statusText:"INTERNAL_SERVER_ERROR"}}}}},$Infer:{}}),lt=()=>{let e=D();return{id:"passkey",version:nt,$InferServerPlugin:{},getActions:(t,r)=>Lr(t,{$listPasskeys:e,$store:r}),getAtoms(t){return{listPasskeys:me(e,"/passkey/list-user-passkeys",t,{method:"GET"}),$listPasskeys:e}},pathMethods:{"/passkey/register":"POST","/passkey/authenticate":"POST"},atomListeners:[{matcher(t){return t==="/passkey/verify-registration"||t==="/passkey/delete-passkey"||t==="/passkey/update-passkey"||t==="/sign-out"},signal:"$listPasskeys"},{matcher:t=>t==="/passkey/verify-authentication",signal:"$sessionSignal"}],$ERROR_CODES:x}};window.pulsePasskeyAuth=Se({plugins:[lt()]});})(); diff --git a/src/web/public/js/settings/settings-page.js b/src/web/public/js/settings/settings-page.js index ea6072a..9367688 100644 --- a/src/web/public/js/settings/settings-page.js +++ b/src/web/public/js/settings/settings-page.js @@ -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(/\r?\n/g, '
').replace(/(https?:\/\/[^\s<]+)/g, '$1').replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '$1').replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '$1').replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '$1'); + paragraph.innerHTML = (line || '\u00a0').replace(/&/g, '&').replace(//g, '>').replace(/\r?\n/g, '
').replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '$1').replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '$1').replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '$1'); 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'); diff --git a/src/web/routes/account/password.js b/src/web/routes/account/password.js index 41ea422..326e1df 100644 --- a/src/web/routes/account/password.js +++ b/src/web/routes/account/password.js @@ -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'] }); }; diff --git a/src/web/routes/admin/account.js b/src/web/routes/admin/account.js index 3145a81..b7b0802 100644 --- a/src/web/routes/admin/account.js +++ b/src/web/routes/admin/account.js @@ -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); } diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index 56709b2..58f7bdf 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -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(); diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js index 35e3a7c..16b423b 100644 --- a/src/web/routes/admin/playlists.js +++ b/src/web/routes/admin/playlists.js @@ -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 } }); } diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js index 1d3e2da..46b5546 100644 --- a/src/web/routes/admin/rbac.js +++ b/src/web/routes/admin/rbac.js @@ -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 }); diff --git a/src/web/routes/admin/users.js b/src/web/routes/admin/users.js index efef88b..9976dee 100644 --- a/src/web/routes/admin/users.js +++ b/src/web/routes/admin/users.js @@ -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, { diff --git a/src/web/routes/auth/accept-invite.js b/src/web/routes/auth/accept-invite.js deleted file mode 100644 index d476349..0000000 --- a/src/web/routes/auth/accept-invite.js +++ /dev/null @@ -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 - }); -}; diff --git a/src/web/routes/auth/email-verification-error.js b/src/web/routes/auth/email-verification-error.js deleted file mode 100644 index 886d746..0000000 --- a/src/web/routes/auth/email-verification-error.js +++ /dev/null @@ -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.' }); -}; diff --git a/src/web/routes/auth/email-verified.js b/src/web/routes/auth/email-verified.js deleted file mode 100644 index 102c0a5..0000000 --- a/src/web/routes/auth/email-verified.js +++ /dev/null @@ -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' }); -}; diff --git a/src/web/routes/auth/index.js b/src/web/routes/auth/index.js index 062a3c7..62d97b9 100644 --- a/src/web/routes/auth/index.js +++ b/src/web/routes/auth/index.js @@ -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) { diff --git a/src/web/routes/auth/login.js b/src/web/routes/auth/login.js index 166bff7..a7f2092 100644 --- a/src/web/routes/auth/login.js +++ b/src/web/routes/auth/login.js @@ -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'] }); }; \ No newline at end of file diff --git a/src/web/routes/auth/reset-password.js b/src/web/routes/auth/reset-password.js index da7f7ed..0136897 100644 --- a/src/web/routes/auth/reset-password.js +++ b/src/web/routes/auth/reset-password.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 }); }; \ No newline at end of file diff --git a/src/web/routes/register.js b/src/web/routes/register.js index a388d60..29554ac 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -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 }); } diff --git a/src/web/routes/settings/invitations/list.js b/src/web/routes/settings/invitations/list.js deleted file mode 100644 index 7ac8614..0000000 --- a/src/web/routes/settings/invitations/list.js +++ /dev/null @@ -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 - }); -}; \ No newline at end of file diff --git a/src/web/routes/settings/rbac/duplicate.js b/src/web/routes/settings/rbac/duplicate.js index 6e0aa74..f985573 100644 --- a/src/web/routes/settings/rbac/duplicate.js +++ b/src/web/routes/settings/rbac/duplicate.js @@ -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) }; } diff --git a/src/web/routes/settings/routes.js b/src/web/routes/settings/routes.js index ad2f2ad..226ea10 100644 --- a/src/web/routes/settings/routes.js +++ b/src/web/routes/settings/routes.js @@ -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'); diff --git a/src/web/routes/settings/users/form-view-model.js b/src/web/routes/settings/users/form-view-model.js index b1f7fdf..2a1dd9f 100644 --- a/src/web/routes/settings/users/form-view-model.js +++ b/src/web/routes/settings/users/form-view-model.js @@ -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), diff --git a/src/web/routes/settings/users/invite.js b/src/web/routes/settings/users/invite.js deleted file mode 100644 index 713b265..0000000 --- a/src/web/routes/settings/users/invite.js +++ /dev/null @@ -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 || {} - }); -}; diff --git a/src/web/views/account/index.hbs b/src/web/views/account/index.hbs index f43db9f..fcd4158 100644 --- a/src/web/views/account/index.hbs +++ b/src/web/views/account/index.hbs @@ -1,7 +1,9 @@ + +
+
+
+

Change password

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
{{passwordRequirementsText}}
+
+
+ +
{{#if allowUserSessionRevocation}}
@@ -71,43 +326,4 @@
{{/if}}
- -
-
-
-

Password

-
-
-
- -
- - -
-
- - -
-
{{passwordRequirementsText}}
-
-
-
-
-
-

Confirmation

-
-
- - -
Required to save account details or change your password.
-
- -
-
diff --git a/src/web/views/auth/accept-invite.hbs b/src/web/views/auth/accept-invite.hbs deleted file mode 100644 index 40935d7..0000000 --- a/src/web/views/auth/accept-invite.hbs +++ /dev/null @@ -1,27 +0,0 @@ - -{{#if isValidInvitation}} -
- {{#if message}}
{{message}}
{{/if}} -
- -
-
-
-
-
-
{{passwordRequirementsText}}
- -
-
-{{else}} - -{{/if}} diff --git a/src/web/views/auth/email-verification-error.hbs b/src/web/views/auth/email-verification-error.hbs deleted file mode 100644 index 9311ac0..0000000 --- a/src/web/views/auth/email-verification-error.hbs +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/src/web/views/auth/email-verified.hbs b/src/web/views/auth/email-verified.hbs deleted file mode 100644 index 06a67db..0000000 --- a/src/web/views/auth/email-verified.hbs +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/src/web/views/auth/forgot-password.hbs b/src/web/views/auth/forgot-password.hbs index c3d2a1f..1073501 100644 --- a/src/web/views/auth/forgot-password.hbs +++ b/src/web/views/auth/forgot-password.hbs @@ -5,7 +5,7 @@
- +
Back to sign in \ No newline at end of file diff --git a/src/web/views/auth/login.hbs b/src/web/views/auth/login.hbs index 5dbe684..e413d7a 100644 --- a/src/web/views/auth/login.hbs +++ b/src/web/views/auth/login.hbs @@ -1,44 +1,63 @@