diff --git a/.agents/skills/churchcrm/cypress-testing.md b/.agents/skills/churchcrm/cypress-testing.md index d4f8c75908..e21feaff77 100644 --- a/.agents/skills/churchcrm/cypress-testing.md +++ b/.agents/skills/churchcrm/cypress-testing.md @@ -268,8 +268,8 @@ describe('API - User Creation', () => { # 1. Clear logs before reproducing rm -f src/logs/$(date +%Y-%m-%d)-*.log -# 2. Run the failing test -npx cypress run --spec "cypress/e2e/api/path/to/test.spec.js" +# 2. Run the failing test (always include --config-file) +npx cypress run --config-file cypress/configs/docker.config.ts --spec "cypress/e2e/api/path/to/test.spec.js" # 3. Check PHP logs for error cat src/logs/$(date +%Y-%m-%d)-php.log | tail -50 @@ -289,58 +289,70 @@ cat src/logs/$(date +%Y-%m-%d)-app.log ## Configuration -### Cypress Config Files +### Cypress Config Files -**Development:** `cypress.config.ts` -```typescript -export default defineConfig({ - e2e: { - baseUrl: 'http://localhost:8000', - env: { - 'admin.username': 'admin', - 'admin.password': 'changeme', - } - } -}); -``` +Config files live in `cypress/configs/` (NOT `docker/`): -**CI/Docker:** `docker/cypress.config.ts` -```typescript -export default defineConfig({ - e2e: { - baseUrl: 'http://web:8080', // Docker service name - specPattern: 'cypress/e2e/**/*.spec.ts', - } -}); -``` +- `cypress/configs/docker.config.ts` — standard CI/dev config (uses Docker container at `http://localhost`) +- `cypress/configs/new-system.config.ts` — setup wizard / fresh install tests +- `cypress/configs/base.config.ts` + `_shared.ts` — shared base configuration + +**CRITICAL: `npx cypress run` without `--config-file` will fail** — Cypress won't find baseUrl or test credentials. Always pass the config file. -### Running Tests Locally +**CRITICAL: Always install Cypress via `npm install`** +- Never use `npx cypress install` — it can produce a corrupt binary with wrong permissions. +- If Cypress binary is broken or missing, fix with: `npx cypress cache clear && npm install` +- The config points at a Docker container. Start the stack (`npm run docker:test`) before running tests. + +### Running Tests (ALWAYS use --config-file) ```bash -# Interactive browser testing +# Full suite headless (standard) +npm run test + +# Interactive browser runner +npm run test:open + +# API tests only +npm run test:api + +# UI tests only npm run test:ui -# Headless testing (CI mode) -npm run test +# Single spec file — ALWAYS pass --config-file +npx cypress run --config-file cypress/configs/docker.config.ts --spec "cypress/e2e/ui/user/standard.user.password.spec.js" -# Run specific test file -npx cypress run --spec "cypress/e2e/ui/users/create-user.cy.ts" +# Or use npm script with -- to forward the --spec flag +npm run test:ui -- --spec "cypress/e2e/ui/user/standard.user.password.spec.js" -# Run with debug logging -DEBUG=cypress:* npm run test +# Setup wizard tests +npm run test:new-system ``` -### Running Tests in Docker +### Running Tests in Docker (Required Workflow) + +Tests cannot run locally without Docker — the app server lives in a container. ```bash -# Start test containers -npm run docker:test:start +# 0. Ensure Node 24 is active (project requires >=24 <25) +node --version # must be v24.x + +# 1. Start test containers +npm run docker:test -# Run all tests -npx cypress run +# 2. Run tests +npm run test # full suite +npm run test:ui # UI tests only +npm run test:api # API tests only -# View logs after failures +# 3. Single spec +npx cypress run --config-file cypress/configs/docker.config.ts --spec "cypress/e2e/ui/user/standard.user.password.spec.js" + +# 4. View logs after failures npm run docker:test:logs + +# 5. Teardown +npm run docker:test:down ``` ## Test File Best Practices diff --git a/locale/terms/messages.po b/locale/terms/messages.po index c7419519e7..53db0a4acd 100644 --- a/locale/terms/messages.po +++ b/locale/terms/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-06 15:05+0000\n" +"POT-Creation-Date: 2026-03-07 09:55-0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1817,10 +1817,6 @@ msgstr "" msgid "Credit Card" msgstr "" -#. Context: query_qry -msgid "Credit Card People" -msgstr "" - msgid "Critical Configuration Error" msgstr "" @@ -2866,6 +2862,7 @@ msgstr "" msgid "Ethiopia" msgstr "" +#. Context: queryparameters_qrp msgid "Event" msgstr "" @@ -3236,10 +3233,6 @@ msgstr "" msgid "Family Navigation" msgstr "" -#. Context: query_qry -msgid "Family Pledge by Fiscal Year" -msgstr "" - msgid "Family Pledges" msgstr "" @@ -3429,16 +3422,16 @@ msgstr "" msgid "Find Duplicate Emails" msgstr "" -#. Context: query_qry -msgid "Find Registered students" -msgstr "" - msgid "Find family->" msgstr "" msgid "Find in MailChimp: Audience > Settings > Audience name and defaults > Audience ID" msgstr "" +#. Context: query_qry +msgid "Find people who didn't attend an event" +msgstr "" + #. Context: query_qry msgid "Find people who pledged one year but not another" msgstr "" @@ -3505,14 +3498,9 @@ msgstr "" msgid "FirstName MiddleName LastName" msgstr "" -#. Context: queryparameters_qrp msgid "Fiscal Year" msgstr "" -#. Context: queryparameters_qrp -msgid "Fiscal Year." -msgstr "" - msgid "Focus" msgstr "" @@ -3849,10 +3837,6 @@ msgstr "" msgid "Group not found" msgstr "" -#. Context: queryparameters_qrp -msgid "Group of registered students" -msgstr "" - msgid "Group removed from cart" msgstr "" @@ -4508,6 +4492,9 @@ msgstr "" msgid "Latvia (Latvija)" msgstr "" +msgid "Leave blank to keep existing" +msgstr "" + msgid "Leave blank to keep existing password" msgstr "" @@ -5036,15 +5023,16 @@ msgstr "" msgid "Missing PHP ZipArchive" msgstr "" -msgid "Missing Secret Keys" -msgstr "" - msgid "Missing calendar access token" msgstr "" msgid "Missing families? Update coordinates to include them on the map." msgstr "" +#. Context: query_qry +msgid "Missing people" +msgstr "" + #. Context: query_qry msgid "Missing pledges" msgstr "" @@ -5179,9 +5167,6 @@ msgstr "" msgid "Must be a valid port number (e.g., 3306)" msgstr "" -msgid "Must be set to 'true'" -msgstr "" - msgid "Must specify non-zero check number" msgstr "" @@ -5948,10 +5933,6 @@ msgstr "" msgid "People not in" msgstr "" -#. Context: query_qry -msgid "People who are configured to pay by credit card." -msgstr "" - #. Context: query_qry msgid "People with birthdays in a particular month" msgstr "" @@ -6299,10 +6280,6 @@ msgstr "" msgid "Pledge or Payment" msgstr "" -#. Context: query_qry -msgid "Pledge summary by family name for each fund for the selected fiscal year" -msgstr "" - #. Context: queryparameters_qrp msgid "Pledged this year" msgstr "" @@ -6577,10 +6554,6 @@ msgstr "" msgid "Register another family!" msgstr "" -#. Context: query_qry -msgid "Registered students" -msgstr "" - msgid "Registration Complete" msgstr "" @@ -7035,9 +7008,6 @@ msgstr "" msgid "Second volunteer opportunity choice" msgstr "" -msgid "Secret keys missing from Config.php" -msgstr "" - msgid "Secure" msgstr "" @@ -7144,6 +7114,10 @@ msgstr "" msgid "Select start and end date/time" msgstr "" +#. Context: queryparameters_qrp +msgid "Select the desired event" +msgstr "" + #. Context: queryparameters_qrp msgid "Select the desired family role." msgstr "" @@ -7689,9 +7663,6 @@ msgstr "" msgid "System Users" msgstr "" -msgid "System configuration " -msgstr "" - msgid "System upgrade completed successfully!" msgstr "" @@ -8192,9 +8163,6 @@ msgstr "" msgid "Two characters from FirstName" msgstr "" -msgid "Two factor authentication requires ChurchCRM administrators to configure a few parameters" -msgstr "" - msgid "Two-Factor Authentication" msgstr "" @@ -8254,9 +8222,6 @@ msgstr "" msgid "Ukrainian" msgstr "" -msgid "Unable To Begin Two Factor Authentication Enrollment" -msgstr "" - msgid "Unable to display map: church address has not been geocoded yet." msgstr "" @@ -8332,9 +8297,6 @@ msgstr "" msgid "Unsuccessful API Key authentication attempt" msgstr "" -msgid "Unsupported Two Factor Authentication Configuration" -msgstr "" - msgid "Upcoming Birthdays" msgstr "" @@ -8502,10 +8464,6 @@ msgstr "" msgid "User permission to create directories" msgstr "" -#. Context: userconfig_ucfg -msgid "User permission to export CSV files" -msgstr "" - #. Context: userconfig_ucfg msgid "User permission to send email via mailto: links" msgstr "" @@ -9244,9 +9202,6 @@ msgstr "" msgid "mismatches detected" msgstr "" -msgid "must be configured with an encryption key" -msgstr "" - msgid "needs to be picked up from" msgstr "" diff --git a/src/ChurchCRM/Authentication/AuthenticationProviders/LocalAuthentication.php b/src/ChurchCRM/Authentication/AuthenticationProviders/LocalAuthentication.php index 13fd4d9e67..5a3250aaf3 100644 --- a/src/ChurchCRM/Authentication/AuthenticationProviders/LocalAuthentication.php +++ b/src/ChurchCRM/Authentication/AuthenticationProviders/LocalAuthentication.php @@ -48,10 +48,6 @@ public function getPasswordChangeURL(): string return SystemURLs::getRootPath() . '/v2/user/current/changepassword'; } - public static function getIsTwoFactorAuthSupported(): bool - { - return SystemConfig::getBooleanValue('bEnable2FA') && KeyManagerUtils::getAreAllSecretsDefined(); - } public static function getTwoFactorQRCode($username, $secret): QrCode { @@ -145,17 +141,17 @@ public function authenticate(AuthenticationRequest $AuthenticationRequest): Auth $authenticationResult->isAuthenticated = false; $authenticationResult->message = gettext('Invalid login or password'); LoggerUtils::getAuthLogger()->warning('Invalid login attempt', $logCtx); - } elseif (SystemConfig::getBooleanValue('bEnable2FA') && $this->currentUser->is2FactorAuthEnabled()) { - // Only redirect the user to the 2FA sign-ing page if it's - // enabled both at system AND user level. + } elseif ($this->currentUser->is2FactorAuthEnabled()) { + // User has enrolled in 2FA — redirect to verification step $authenticationResult->isAuthenticated = false; $authenticationResult->nextStepURL = SystemURLs::getRootPath() . '/session/two-factor'; $this->bPendingTwoFactorAuth = true; LoggerUtils::getAuthLogger()->info('User partially authenticated, pending 2FA', $logCtx); } elseif (SystemConfig::getBooleanValue('bRequire2FA') && !$this->currentUser->is2FactorAuthEnabled()) { - $authenticationResult->isAuthenticated = false; - $authenticationResult->message = gettext('Invalid login or password'); - LoggerUtils::getAuthLogger()->warning('User attempted login with valid credentials, but missing mandatory 2FA enrollment. Denying access for user', $logCtx); + // Allow login but force enrollment — user will be redirected on every request until enrolled + $this->prepareSuccessfulLoginOperations(); + $authenticationResult->isAuthenticated = true; + LoggerUtils::getAuthLogger()->info('User logged in, redirecting to mandatory 2FA enrollment', $logCtx); } else { $this->prepareSuccessfulLoginOperations(); $authenticationResult->isAuthenticated = true; @@ -234,6 +230,16 @@ public function validateUserSessionIsActive(bool $updateLastOperationTimestamp): $authenticationResult->nextStepURL = $this->getPasswordChangeURL(); } + // If 2FA is required and user hasn't enrolled, redirect to enrollment on every request + // but don't redirect if they're already on the enrollment page + $enrollmentURL = SystemURLs::getRootPath() . '/v2/user/current/manage2fa'; + $isOnEnrollmentPage = str_contains($_SERVER['REQUEST_URI'], '/v2/user/current/manage2fa') + || str_contains($_SERVER['REQUEST_URI'], '/v2/user/current/enroll2fa'); + if (SystemConfig::getBooleanValue('bRequire2FA') && !$this->currentUser->is2FactorAuthEnabled() && !$isOnEnrollmentPage) { + LoggerUtils::getAuthLogger()->info('User must enroll in mandatory 2FA before accessing system', $logCtx); + $authenticationResult->nextStepURL = $enrollmentURL; + } + // Finally, if the above tests pass, this user "is authenticated" $authenticationResult->isAuthenticated = true; LoggerUtils::getAuthLogger()->debug('Session validated for user', $logCtx); diff --git a/src/ChurchCRM/Service/AdminService.php b/src/ChurchCRM/Service/AdminService.php index 530ea89622..1c24007fda 100644 --- a/src/ChurchCRM/Service/AdminService.php +++ b/src/ChurchCRM/Service/AdminService.php @@ -4,7 +4,6 @@ use ChurchCRM\dto\SystemConfig; use ChurchCRM\dto\SystemURLs; -use ChurchCRM\Utils\KeyManagerUtils; use ChurchCRM\Utils\URLValidator; /** @@ -76,16 +75,6 @@ public function getSystemWarnings(): array ]; } - // Secrets configuration check - only show if 2FA is enabled - if (SystemConfig::getBooleanValue('bEnable2FA') && !KeyManagerUtils::getAreAllSecretsDefined()) { - $warnings[] = [ - 'title' => gettext('Missing Secret Keys'), - 'desc' => gettext('Secret keys missing from Config.php'), - 'link' => SystemURLs::getSupportURL('SecretsConfigurationCheckTask'), - 'severity' => 'danger', - ]; - } - // HTTPS check if (!isset($_SERVER['HTTPS'])) { $warnings[] = [ diff --git a/src/ChurchCRM/Service/UserService.php b/src/ChurchCRM/Service/UserService.php index a9e11d82ac..d43257db56 100644 --- a/src/ChurchCRM/Service/UserService.php +++ b/src/ChurchCRM/Service/UserService.php @@ -115,7 +115,6 @@ public function getUserSettingsConfig(): array 'iMinPasswordLength', 'iMinPasswordChange', 'aDisallowedPasswords', - 'bEnable2FA', 'bRequire2FA', 's2FAApplicationName' ]; diff --git a/src/ChurchCRM/dto/SystemConfig.php b/src/ChurchCRM/dto/SystemConfig.php index 0fbdceeb5a..73a336960e 100644 --- a/src/ChurchCRM/dto/SystemConfig.php +++ b/src/ChurchCRM/dto/SystemConfig.php @@ -227,8 +227,7 @@ private static function buildConfigs(): array 'bAllowPrereleaseUpgrade' => new ConfigItem(2065, 'bAllowPrereleaseUpgrade', 'boolean', '0', gettext("Allow system upgrades to release marked as 'pre release' on GitHub")), 'bSearchIncludeCalendarEvents' => new ConfigItem(2066, 'bSearchIncludeCalendarEvents', 'boolean', '1', gettext('Search Calendar Events')), 'bSearchIncludeCalendarEventsMax' => new ConfigItem(2067, 'bSearchIncludeCalendarEventsMax', 'text', '15', gettext('Maximum number of Calendar Events')), - 'bEnable2FA' => new ConfigItem(2068, 'bEnable2FA', 'boolean', '1', gettext('Allow users to self-enroll in 2 factor authentication')), - 'bRequire2FA' => new ConfigItem(2069, 'bRequire2FA', 'boolean', '0', gettext('Requires users to self-enroll in 2 factor authentication')), + 'bRequire2FA' => new ConfigItem(2069, 'bRequire2FA', 'boolean', '0', gettext('Require all users to enroll in two-factor authentication')), 's2FAApplicationName' => new ConfigItem(2070, 's2FAApplicationName', 'text', gettext('ChurchCRM'), gettext('Specify the application name to be displayed in authenticator app')), 'sTwoFASecretKey' => new ConfigItem(2075, 'sTwoFASecretKey', 'password', '', gettext('Encryption key for storing 2FA secret keys in the database')), 'bSendUserDeletedEmail' => new ConfigItem(2071, 'bSendUserDeletedEmail', 'boolean', '0', gettext('Send an email notifying users when their account has been deleted')), @@ -289,7 +288,6 @@ private static function buildCategories(): array gettext('Quick Search') => ['bSearchIncludePersons', 'bSearchIncludePersonsMax', 'bSearchIncludeAddresses', 'bSearchIncludeAddressesMax', 'bSearchIncludeFamilies', 'bSearchIncludeFamiliesMax', 'bSearchIncludeFamilyHOH', 'bSearchIncludeFamilyHOHMax', 'bSearchIncludeGroups', 'bSearchIncludeGroupsMax', 'bSearchIncludeDeposits', 'bSearchIncludeDepositsMax', 'bSearchIncludePayments', 'bSearchIncludePaymentsMax', 'bSearchIncludeFamilyCustomProperties', 'bSearchIncludeCalendarEvents', 'bSearchIncludeCalendarEventsMax'], gettext('Localization') => ['sLanguage', 'sDistanceUnit', 'sPhoneFormat', 'sPhoneFormatWithExt', 'sPhoneFormatCell', 'sDateFormatLong', 'sDateFormatNoYear', 'sDateTimeFormat', 'sDateFilenameFormat', 'sDatePickerFormat', 'sDatePickerPlaceHolder'], gettext('Church Services') => ['iPersonConfessionFatherCustomField', 'iPersonConfessionDateCustomField'], - gettext('Two-Factor Authentication') => ['sTwoFASecretKey'], gettext('System Settings') => ['sLogLevel', 'bEnforceCSP', 'bHSTSEnable', 'iDashboardServiceIntervalTime'], ]; } @@ -356,12 +354,14 @@ public static function getSettingsConfig(array $settingNames): array $tooltip = $configItem->getTooltip(); $label = strtok($tooltip, "\n") ?: ucwords(str_replace(['i', 'b', 's', 'a'], '', $settingName)); - $configurations[] = [ + $entry = [ 'name' => $settingName, 'type' => self::mapConfigTypeToSettingType($configItem->getType()), 'label' => $label, 'tooltip' => $tooltip ]; + + $configurations[] = $entry; } } @@ -380,6 +380,8 @@ private static function mapConfigTypeToSettingType(string $configType): string return 'number'; case 'boolean': return 'boolean'; + case 'password': + return 'password'; case 'text': default: return 'text'; diff --git a/src/Include/Header.php b/src/Include/Header.php index 713130af29..511ec0e78d 100644 --- a/src/Include/Header.php +++ b/src/Include/Header.php @@ -1,7 +1,6 @@ - - - - - + + + diff --git a/src/Include/LoadConfigs.php b/src/Include/LoadConfigs.php index 3766716745..6286fd9c06 100644 --- a/src/Include/LoadConfigs.php +++ b/src/Include/LoadConfigs.php @@ -25,4 +25,11 @@ // Initialize KeyManager with 2FA secret from SystemConfig $twoFASecretKey = SystemConfig::getValue('sTwoFASecretKey'); + +// Auto-generate encryption key if not yet set (required for 2FA enrollment) +if (empty($twoFASecretKey)) { + $twoFASecretKey = bin2hex(random_bytes(32)); + SystemConfig::setValue('sTwoFASecretKey', $twoFASecretKey); +} + KeyManagerUtils::init($twoFASecretKey); diff --git a/src/SettingsUser.php b/src/SettingsUser.php index 92f2e4fd00..e705fac0c7 100644 --- a/src/SettingsUser.php +++ b/src/SettingsUser.php @@ -4,8 +4,8 @@ require_once __DIR__ . '/Include/Functions.php'; use ChurchCRM\Authentication\AuthenticationManager; +use ChurchCRM\dto\SystemURLs; use ChurchCRM\Utils\InputUtils; -use ChurchCRM\Utils\RedirectUtils; // Security AuthenticationManager::redirectHomeIfNotAdmin(); @@ -140,7 +140,7 @@ - + diff --git a/src/admin/routes/api/system/system-config.php b/src/admin/routes/api/system/system-config.php index 619181698e..1105a4d900 100644 --- a/src/admin/routes/api/system/system-config.php +++ b/src/admin/routes/api/system/system-config.php @@ -16,14 +16,27 @@ function getConfigValueByNameAPI(Request $request, Response $response, array $args): Response { - return SlimUtils::renderJSON($response, ['value' => SystemConfig::getValue($args['configName'])]); + $configName = $args['configName']; + // Never return password values to the browser + if (SystemConfig::getConfigItem($configName)->getType() === 'password') { + return SlimUtils::renderJSON($response, ['value' => '']); + } + + return SlimUtils::renderJSON($response, ['value' => SystemConfig::getValue($configName)]); } function setConfigValueByNameAPI(Request $request, Response $response, array $args): Response { $configName = $args['configName']; $input = $request->getParsedBody(); - SystemConfig::setValue($configName, $input['value']); + $value = $input['value'] ?? ''; - return SlimUtils::renderJSON($response, ['value' => SystemConfig::getValue($configName)]); + // Never overwrite a password with an empty value + if (SystemConfig::getConfigItem($configName)->getType() === 'password' && empty($value)) { + return SlimUtils::renderJSON($response, ['value' => '']); + } + + SystemConfig::setValue($configName, $value); + + return SlimUtils::renderJSON($response, ['value' => '']); } diff --git a/src/admin/views/users.php b/src/admin/views/users.php index f0280efc23..ab65e712db 100644 --- a/src/admin/views/users.php +++ b/src/admin/views/users.php @@ -105,40 +105,41 @@ - - + - + - getPerson()->getFullName()) ?> + getPerson()->getFullName()) ?> - getUserName()) ?> + getUserName()) ?> getLastLogin(SystemConfig::getValue('sDateTimeFormat')) ?> - getLoginCount() ?> - isLocked()) { ?> - getFailedLogins() ?> - getFailedLogins(); - } ?> + getFailedLogins() > 0) { ?> + getFailedLogins() ?> + + + - is2FactorAuthEnabled() ? gettext("Enabled") : gettext("Disabled") ?> + is2FactorAuthEnabled()) { ?> + + + +