diff --git a/.github/workflows/check-webroot.yml b/.github/workflows/check-webroot.yml index 1abeb8aae..5627fe675 100644 --- a/.github/workflows/check-webroot.yml +++ b/.github/workflows/check-webroot.yml @@ -54,21 +54,30 @@ jobs: VUE_APP_COGNITO_REGION: us-east-1 VUE_APP_COGNITO_AUTH_DOMAIN_STAFF: https://ia-cc-staff-test.auth.us-east-1.amazoncognito.com VUE_APP_COGNITO_CLIENT_ID_STAFF: ${{ secrets.DEV_WEBROOT_COGNITO_CLIENT_ID_STAFF }} + VUE_APP_COGNITO_USER_POOL_ID_STAFF: '' VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE: https://ia-cc-provider-test.auth.us-east-1.amazoncognito.com VUE_APP_COGNITO_CLIENT_ID_LICENSEE: ${{ secrets.DEV_WEBROOT_COGNITO_CLIENT_ID_LICENSEE }} + VUE_APP_COGNITO_USER_POOL_ID_LICENSEE: '' VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_COSMO: https://staff-auth.test.cosmetology.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF_COSMO: ${{ secrets.DEV_WEBROOT_COGNITO_CLIENT_ID_STAFF_COSMO }} + VUE_APP_COGNITO_USER_POOL_ID_STAFF_COSMO: '' VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_SW: https://staff-auth.test.socialwork.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF_SW: ${{ secrets.DEV_WEBROOT_COGNITO_CLIENT_ID_STAFF_SW }} + VUE_APP_COGNITO_USER_POOL_ID_STAFF_SW: '' VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_PSYPACT: https://staff-auth.test.psypact.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF_PSYPACT: TODO + VUE_APP_COGNITO_USER_POOL_ID_STAFF_PSYPACT: '' VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_PSYPACT: https://licensee-auth.test.psypact.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_LICENSEE_PSYPACT: TODO + VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_PSYPACT: '' VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_DIET: https://staff-auth.test.dietitian.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF_DIET: TODO + VUE_APP_COGNITO_USER_POOL_ID_STAFF_DIET: '' VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_DIET: https://licensee-auth.test.dietitian.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_LICENSEE_DIET: TODO + VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_DIET: '' VUE_APP_RECAPTCHA_KEY: 6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih + VUE_APP_CUSTOM_AUTH: false steps: - run: echo "馃帀 The job was automatically triggered by a ${{ github.event_name }} event." @@ -139,21 +148,30 @@ jobs: VUE_APP_COGNITO_REGION: ${{ env.VUE_APP_COGNITO_REGION }} VUE_APP_COGNITO_AUTH_DOMAIN_STAFF: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_STAFF }} VUE_APP_COGNITO_CLIENT_ID_STAFF: ${{ env.VUE_APP_COGNITO_CLIENT_ID_STAFF }} + VUE_APP_COGNITO_USER_POOL_ID_STAFF: ${{ env.VUE_APP_COGNITO_USER_POOL_ID_STAFF }} VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE }} VUE_APP_COGNITO_CLIENT_ID_LICENSEE: ${{ env.VUE_APP_COGNITO_CLIENT_ID_LICENSEE }} + VUE_APP_COGNITO_USER_POOL_ID_LICENSEE: ${{ env.VUE_APP_COGNITO_USER_POOL_ID_LICENSEE }} VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_COSMO: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_COSMO }} VUE_APP_COGNITO_CLIENT_ID_STAFF_COSMO: ${{ env.VUE_APP_COGNITO_CLIENT_ID_STAFF_COSMO }} + VUE_APP_COGNITO_USER_POOL_ID_STAFF_COSMO: ${{ env.VUE_APP_COGNITO_USER_POOL_ID_STAFF_COSMO }} VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_SW: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_SW }} VUE_APP_COGNITO_CLIENT_ID_STAFF_SW: ${{ env.VUE_APP_COGNITO_CLIENT_ID_STAFF_SW }} + VUE_APP_COGNITO_USER_POOL_ID_STAFF_SW: ${{ env.VUE_APP_COGNITO_USER_POOL_ID_STAFF_SW }} VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_PSYPACT: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_PSYPACT }} VUE_APP_COGNITO_CLIENT_ID_STAFF_PSYPACT: ${{ env.VUE_APP_COGNITO_CLIENT_ID_STAFF_PSYPACT }} + VUE_APP_COGNITO_USER_POOL_ID_STAFF_PSYPACT: ${{ env.VUE_APP_COGNITO_USER_POOL_ID_STAFF_PSYPACT }} VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_PSYPACT: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_PSYPACT }} VUE_APP_COGNITO_CLIENT_ID_LICENSEE_PSYPACT: ${{ env.VUE_APP_COGNITO_CLIENT_ID_LICENSEE_PSYPACT }} + VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_PSYPACT: ${{ env.VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_PSYPACT }} VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_DIET: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_DIET }} VUE_APP_COGNITO_CLIENT_ID_STAFF_DIET: ${{ env.VUE_APP_COGNITO_CLIENT_ID_STAFF_DIET }} + VUE_APP_COGNITO_USER_POOL_ID_STAFF_DIET: ${{ env.VUE_APP_COGNITO_USER_POOL_ID_STAFF_DIET }} VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_DIET: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_DIET }} VUE_APP_COGNITO_CLIENT_ID_LICENSEE_DIET: ${{ env.VUE_APP_COGNITO_CLIENT_ID_LICENSEE_DIET }} + VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_DIET: ${{ env.VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_DIET }} VUE_APP_RECAPTCHA_KEY: ${{ env.VUE_APP_RECAPTCHA_KEY }} + VUE_APP_CUSTOM_AUTH: ${{ env.VUE_APP_CUSTOM_AUTH }} VUE_APP_MOCK_API: true run: yarn build working-directory: ./webroot diff --git a/backend/common-cdk/common_constructs/user_pool.py b/backend/common-cdk/common_constructs/user_pool.py index 5c37f1489..7c2becb0d 100644 --- a/backend/common-cdk/common_constructs/user_pool.py +++ b/backend/common-cdk/common_constructs/user_pool.py @@ -323,7 +323,7 @@ def add_ui_client( # (and automated testing in test environments) admin_user_password=True, custom=False, - user_srp=False if self.security_profile == SecurityProfile.RECOMMENDED else True, + user_srp=True, user_password=False, ), o_auth=OAuthSettings( diff --git a/backend/compact-connect-ui-app/requirements-dev.txt b/backend/compact-connect-ui-app/requirements-dev.txt index 1bae48a74..daf3d8273 100644 --- a/backend/compact-connect-ui-app/requirements-dev.txt +++ b/backend/compact-connect-ui-app/requirements-dev.txt @@ -38,7 +38,7 @@ markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py -msgpack==1.2.1 +msgpack==1.2.3 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib diff --git a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt index ae35c2a2b..63821124f 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt @@ -76,7 +76,7 @@ mdurl==0.1.2 # via markdown-it-py moto[dynamodb,s3]==5.2.2 # via -r requirements-dev.in -msgpack==1.2.1 +msgpack==1.2.3 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib diff --git a/backend/compact-connect/requirements-dev.txt b/backend/compact-connect/requirements-dev.txt index 2b8dc527a..401e9ff4e 100644 --- a/backend/compact-connect/requirements-dev.txt +++ b/backend/compact-connect/requirements-dev.txt @@ -40,7 +40,7 @@ markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py -msgpack==1.2.1 +msgpack==1.2.3 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib diff --git a/backend/cosmetology-app/requirements-dev.txt b/backend/cosmetology-app/requirements-dev.txt index c3d0e1127..ac5f8a1d9 100644 --- a/backend/cosmetology-app/requirements-dev.txt +++ b/backend/cosmetology-app/requirements-dev.txt @@ -40,7 +40,7 @@ markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py -msgpack==1.2.1 +msgpack==1.2.3 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib diff --git a/backend/dietitian-app/lambdas/nodejs/package.json b/backend/dietitian-app/lambdas/nodejs/package.json index 006a9ebf5..da3d15cf6 100644 --- a/backend/dietitian-app/lambdas/nodejs/package.json +++ b/backend/dietitian-app/lambdas/nodejs/package.json @@ -6,7 +6,7 @@ "resolutions": { "fast-xml-parser": "5.7.3", "postcss": "8.5.24", - "sanitize-html": "2.17.7", + "sanitize-html": "2.18.1", "source-map-js": "1.2.2" }, "scripts": { @@ -52,7 +52,7 @@ "@aws-sdk/client-sesv2": "^3.1045.0", "@aws-sdk/util-dynamodb": "^3.996.2", "@csg-org/email-builder": "^0.0.15", - "nodemailer": "^10.1.1", - "zod": "^3.23.8" + "nodemailer": "^10.1.4", + "zod": "^4.6.6" } } diff --git a/backend/dietitian-app/lambdas/nodejs/yarn.lock b/backend/dietitian-app/lambdas/nodejs/yarn.lock index c514e2640..975ad8b98 100644 --- a/backend/dietitian-app/lambdas/nodejs/yarn.lock +++ b/backend/dietitian-app/lambdas/nodejs/yarn.lock @@ -3311,10 +3311,10 @@ csstype@^3.2.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== -dayjs@^1.11.7: - version "1.11.21" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.21.tgz#57f87562e62de76f3c704bd2b8d522fc33068eb2" - integrity sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA== +dayjs@^1.11.23: + version "1.11.23" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.23.tgz#b0a363506dde5f36cf5075e42ebe8115165a8c79" + integrity sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ== debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.5, debug@^4.4.3: version "4.4.3" @@ -3345,7 +3345,7 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge@^4.2.2, deepmerge@^4.3.1: +deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -3949,10 +3949,10 @@ is-plain-obj@^2.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== -is-plain-object@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" - integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== +is-plain-object@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.1.0.tgz#22ed7b5e856bb57966348e5b9468dada42be87f4" + integrity sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw== is-stream@^2.0.0: version "2.0.1" @@ -4452,12 +4452,12 @@ lambda-local@^2.2.0: dotenv "^16.3.1" winston "^3.10.0" -launder@^1.7.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/launder/-/launder-1.7.1.tgz#ef7155ab0c3ddec2323089c961d2e9a249aa5b0d" - integrity sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw== +launder@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/launder/-/launder-1.7.2.tgz#5ddfddbcb565d208e4c702e94b043a11bfb32653" + integrity sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg== dependencies: - dayjs "^1.11.7" + dayjs "^1.11.23" leven@^3.1.0: version "3.1.0" @@ -4709,10 +4709,10 @@ node-releases@^2.0.27: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== -nodemailer@^10.1.1: - version "10.1.1" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-10.1.1.tgz#c2cc02c3aef8a1697bae6501d362a594551fdd6b" - integrity sha512-pbqZRoeYUO+1E8TJ/11WeY4D2EVujj1hsYjz3qff/vUFDtGnmi7p3iqzTFslYOfQ6PnjVcWxx/CIUSHCSrO/QA== +nodemailer@^10.1.4: + version "10.3.1" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-10.3.1.tgz#77295b3b5dd00b93e96962a55255a21321ce8f32" + integrity sha512-uF0AFsJC07Y1vdW7p580DKAnh7rAO+4UPSSB3mDCPNDufoiaOebV6JmrzpxB14OjEEEiBsNdYoM2Y+fN8M7BBA== normalize-path@^3.0.0: version "3.0.0" @@ -4882,7 +4882,7 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -postcss@8.5.24, postcss@^8.3.11: +postcss@8.5.24, postcss@^8.5.27: version "8.5.24" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.24.tgz#01d8b032451e1b9ec41ae66eaf02843f42a720d2" integrity sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw== @@ -4994,18 +4994,17 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== -sanitize-html@2.17.7, sanitize-html@^2.17.0: - version "2.17.7" - resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.17.7.tgz#b16417c348ea5f99c2451964b6e6621ca6b1da94" - integrity sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg== +sanitize-html@2.18.1, sanitize-html@^2.17.0: + version "2.18.1" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.18.1.tgz#a2b5447020db598fb65f6865bdf37e25e8cff09d" + integrity sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig== dependencies: - deepmerge "^4.2.2" escape-string-regexp "^4.0.0" htmlparser2 "^12.0.0" - is-plain-object "^5.0.0" - launder "^1.7.1" + is-plain-object "^5.1.0" + launder "^1.7.2" parse-srcset "^1.0.2" - postcss "^8.3.11" + postcss "^8.5.27" scheduler@^0.23.2: version "0.23.2" @@ -5564,7 +5563,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.23.8: - version "3.25.76" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" - integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== +zod@^4.6.6: + version "4.6.7" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.6.7.tgz#e806cf64a7f4e37635ce9f2f52c04c3766f75e5b" + integrity sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg== diff --git a/backend/dietitian-app/lambdas/python/cognito-backup/requirements-dev.txt b/backend/dietitian-app/lambdas/python/cognito-backup/requirements-dev.txt index f4f05243a..2bb22149f 100644 --- a/backend/dietitian-app/lambdas/python/cognito-backup/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/cognito-backup/requirements-dev.txt @@ -16,7 +16,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/common/requirements-dev.txt b/backend/dietitian-app/lambdas/python/common/requirements-dev.txt index 99db025b3..215c36809 100644 --- a/backend/dietitian-app/lambdas/python/common/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/common/requirements-dev.txt @@ -33,7 +33,7 @@ botocore==1.43.78 # s3transfer botocore-stubs==1.43.14 # via boto3-stubs -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/common/requirements.txt b/backend/dietitian-app/lambdas/python/common/requirements.txt index 20d725b5a..7501b0e38 100644 --- a/backend/dietitian-app/lambdas/python/common/requirements.txt +++ b/backend/dietitian-app/lambdas/python/common/requirements.txt @@ -16,7 +16,7 @@ botocore==1.43.78 # via # boto3 # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via diff --git a/backend/dietitian-app/lambdas/python/compact-configuration/requirements-dev.txt b/backend/dietitian-app/lambdas/python/compact-configuration/requirements-dev.txt index aed157c96..571273d3b 100644 --- a/backend/dietitian-app/lambdas/python/compact-configuration/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/compact-configuration/requirements-dev.txt @@ -11,7 +11,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/custom-resources/requirements-dev.txt b/backend/dietitian-app/lambdas/python/custom-resources/requirements-dev.txt index c917b1379..ca176cb86 100644 --- a/backend/dietitian-app/lambdas/python/custom-resources/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/custom-resources/requirements-dev.txt @@ -11,7 +11,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/data-events/requirements-dev.txt b/backend/dietitian-app/lambdas/python/data-events/requirements-dev.txt index 08aedc90e..c5caa8e84 100644 --- a/backend/dietitian-app/lambdas/python/data-events/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/data-events/requirements-dev.txt @@ -11,7 +11,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/disaster-recovery/requirements-dev.txt b/backend/dietitian-app/lambdas/python/disaster-recovery/requirements-dev.txt index 889926dd5..b0bf944cc 100644 --- a/backend/dietitian-app/lambdas/python/disaster-recovery/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/disaster-recovery/requirements-dev.txt @@ -11,7 +11,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/provider-data-v1/requirements-dev.txt b/backend/dietitian-app/lambdas/python/provider-data-v1/requirements-dev.txt index 27a705803..151ce00da 100644 --- a/backend/dietitian-app/lambdas/python/provider-data-v1/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/provider-data-v1/requirements-dev.txt @@ -11,7 +11,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/search/requirements-dev.txt b/backend/dietitian-app/lambdas/python/search/requirements-dev.txt index b0ab88dc5..026d45d72 100644 --- a/backend/dietitian-app/lambdas/python/search/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/search/requirements-dev.txt @@ -11,7 +11,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/search/requirements.txt b/backend/dietitian-app/lambdas/python/search/requirements.txt index d45095eb0..55283c083 100644 --- a/backend/dietitian-app/lambdas/python/search/requirements.txt +++ b/backend/dietitian-app/lambdas/python/search/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements.in # -certifi==2026.6.17 +certifi==2026.10.19 # via # opensearch-py # requests diff --git a/backend/dietitian-app/lambdas/python/staff-user-pre-token/requirements-dev.txt b/backend/dietitian-app/lambdas/python/staff-user-pre-token/requirements-dev.txt index 27a7df00e..22a4ea1f9 100644 --- a/backend/dietitian-app/lambdas/python/staff-user-pre-token/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/staff-user-pre-token/requirements-dev.txt @@ -11,7 +11,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/lambdas/python/staff-users/requirements-dev.txt b/backend/dietitian-app/lambdas/python/staff-users/requirements-dev.txt index aa52e1833..72202b560 100644 --- a/backend/dietitian-app/lambdas/python/staff-users/requirements-dev.txt +++ b/backend/dietitian-app/lambdas/python/staff-users/requirements-dev.txt @@ -11,7 +11,7 @@ botocore==1.43.78 # boto3 # moto # s3transfer -certifi==2026.6.17 +certifi==2026.10.19 # via requests cffi==2.0.0 # via cryptography diff --git a/backend/dietitian-app/requirements-dev.txt b/backend/dietitian-app/requirements-dev.txt index 1894548be..401e9ff4e 100644 --- a/backend/dietitian-app/requirements-dev.txt +++ b/backend/dietitian-app/requirements-dev.txt @@ -12,7 +12,7 @@ cachecontrol[filecache]==0.14.4 # via # cachecontrol # pip-audit -certifi==2026.6.17 +certifi==2026.10.19 # via requests charset-normalizer==3.4.7 # via requests @@ -28,7 +28,7 @@ defusedxml==0.7.1 # via py-serializable faker==40.36.0 # via -r requirements-dev.in -filelock==3.29.4 +filelock==4.2.4 # via cachecontrol idna==3.18 # via requests @@ -40,7 +40,7 @@ markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py -msgpack==1.2.1 +msgpack==1.2.3 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib diff --git a/backend/social-work-app/requirements-dev.txt b/backend/social-work-app/requirements-dev.txt index c3d0e1127..ac5f8a1d9 100644 --- a/backend/social-work-app/requirements-dev.txt +++ b/backend/social-work-app/requirements-dev.txt @@ -40,7 +40,7 @@ markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py -msgpack==1.2.1 +msgpack==1.2.3 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib diff --git a/webroot/.env.example b/webroot/.env.example index 41597bfce..3a7b03086 100644 --- a/webroot/.env.example +++ b/webroot/.env.example @@ -26,24 +26,33 @@ VUE_APP_API_USER_ROOT_DIET=https://api.test.dietitian.jcc.iaapi.io VUE_APP_COGNITO_REGION=us-east-1 VUE_APP_COGNITO_AUTH_DOMAIN_STAFF=https://staff-auth.test.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF=15mh24ea4af3of8jcnv8h2ic10 +VUE_APP_COGNITO_USER_POOL_ID_STAFF=us-east-1_MockStaf1 VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE=https://licensee-auth.test.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_LICENSEE=2uv8d9iom5tq8onicri9akot3r +VUE_APP_COGNITO_USER_POOL_ID_LICENSEE=us-east-1_MockLicn1 VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_COSMO=https://staff-auth.test.cosmetology.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF_COSMO=42km9ho786d28dp812j88kvscq +VUE_APP_COGNITO_USER_POOL_ID_STAFF_COSMO=us-east-1_MockCosm1 VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_SW=https://staff-auth.test.socialwork.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF_SW=4eh1uo803lckmmdgdou9la69s6 +VUE_APP_COGNITO_USER_POOL_ID_STAFF_SW=us-east-1_MockSocw1 VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_PSYPACT=https://staff-auth.test.psypact.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF_PSYPACT=7j8edjf4p4dppq5aniq38r3e7f +VUE_APP_COGNITO_USER_POOL_ID_STAFF_PSYPACT=us-east-1_MockPsSt1 VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_PSYPACT=https://licensee-auth.test.psypact.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_LICENSEE_PSYPACT=264639h8fgdggvt29r7ldggn5u +VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_PSYPACT=us-east-1_MockPsLi1 VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_DIET=https://staff-auth.test.dietitian.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_STAFF_DIET=21a0k2iv0r431rofp4u75ktd12 +VUE_APP_COGNITO_USER_POOL_ID_STAFF_DIET=us-east-1_MockDtSt1 VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_DIET=https://licensee-auth.test.dietitian.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_LICENSEE_DIET=64itcer8m6v83m3vet68cbd4dm +VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_DIET=us-east-1_MockDtLi1 VUE_APP_RECAPTCHA_KEY=6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih VUE_APP_STATSIG_KEY=TODO VUE_APP_STATSIG_DISABLED=false VUE_APP_MOCK_API=false +VUE_APP_CUSTOM_AUTH=false VUE_APP_MOCK_API_PAYMENT_LOGIN_ID=TODO VUE_APP_MOCK_API_PAYMENT_CLIENT_KEY=TODO LOCAL_DEV_PORT=3018 diff --git a/webroot/README.md b/webroot/README.md index 61053f756..240346d38 100644 --- a/webroot/README.md +++ b/webroot/README.md @@ -245,6 +245,15 @@ - Prod: `3dp0nf7acvtavqlbec6p4t20to` - _Local_ :arrow_heading_down: - `topd4vhftng5cfm3ccgkb6ejd` + - **`VUE_APP_COGNITO_USER_POOL_ID_LICENSEE`** + - Cognito user pool id for JCC practitioner login. Needed for the in-app login screens. Hosted login ignores it + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - `us-east-1_MockLicn1` - **`VUE_APP_COGNITO_AUTH_DOMAIN_STAFF`** - _Server_ :arrow_heading_up: - IA Test: `https://staff-auth.test.jcc.iaapi.io` @@ -261,6 +270,15 @@ - Prod: `4mnd3u2rp30ssgnm7dk81jcqsc` - _Local_ :arrow_heading_down: - `15mh24ea4af3of8jcnv8h2ic10` + - **`VUE_APP_COGNITO_USER_POOL_ID_STAFF`** + - Cognito user pool id for JCC staff login. Needed for the in-app login screens. Hosted login ignores it + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - `us-east-1_MockStaf1` - **`VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_COSMO`** - _Server_ :arrow_heading_up: - IA Test: `https://staff-auth.test.cosmetology.jcc.iaapi.io` @@ -277,6 +295,15 @@ - Prod: `TODO` - _Local_ :arrow_heading_down: - `42km9ho786d28dp812j88kvscq` + - **`VUE_APP_COGNITO_USER_POOL_ID_STAFF_COSMO`** + - Cognito user pool id for Cosmetology staff login. Needed for the in-app login screens. Hosted login ignores it + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - `us-east-1_MockCosm1` - **`VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_SW`** - _Server_ :arrow_heading_up: - IA Test: `https://staff-auth.test.socialwork.jcc.iaapi.io` @@ -293,6 +320,15 @@ - Prod: `TODO` - _Local_ :arrow_heading_down: - `42km9ho786d28dp812j88kvscq` + - **`VUE_APP_COGNITO_USER_POOL_ID_STAFF_SW`** + - Cognito user pool id for Social Work staff login. Needed for the in-app login screens. Hosted login ignores it + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - `us-east-1_MockSocw1` - **`VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_PSYPACT`** - _Server_ :arrow_heading_up: - IA Test: `https://staff-auth.test.psypact.jcc.iaapi.io` @@ -309,6 +345,15 @@ - Prod: `TODO` - _Local_ :arrow_heading_down: - `7j8edjf4p4dppq5aniq38r3e7f` + - **`VUE_APP_COGNITO_USER_POOL_ID_STAFF_PSYPACT`** + - Cognito user pool id for PsyPact staff login. Needed for the in-app login screens. Hosted login ignores it + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - `us-east-1_MockPsSt1` - **`VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_PSYPACT`** - _Server_ :arrow_heading_up: - IA Test: `https://licensee-auth.test.psypact.jcc.iaapi.io` @@ -325,6 +370,15 @@ - Prod: `TODO` - _Local_ :arrow_heading_down: - `264639h8fgdggvt29r7ldggn5u` + - **`VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_PSYPACT`** + - Cognito user pool id for PsyPact practitioner login. Needed for the in-app login screens. Hosted login ignores it + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - `us-east-1_MockPsLi1` - **`VUE_APP_COGNITO_AUTH_DOMAIN_STAFF_DIET`** - _Server_ :arrow_heading_up: - IA Test: `https://staff-auth.test.dietitian.jcc.iaapi.io` @@ -341,6 +395,15 @@ - Prod: `TODO` - _Local_ :arrow_heading_down: - `21a0k2iv0r431rofp4u75ktd12` + - **`VUE_APP_COGNITO_USER_POOL_ID_STAFF_DIET`** + - Cognito user pool id for Dietitian staff login. Needed for the in-app login screens. Hosted login ignores it + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - `us-east-1_MockDtSt1` - **`VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE_DIET`** - _Server_ :arrow_heading_up: - IA Test: `https://licensee-auth.test.dietitian.jcc.iaapi.io` @@ -357,6 +420,15 @@ - Prod: `TODO` - _Local_ :arrow_heading_down: - `64itcer8m6v83m3vet68cbd4dm` + - **`VUE_APP_COGNITO_USER_POOL_ID_LICENSEE_DIET`** + - Cognito user pool id for Dietitian practitioner login. Needed for the in-app login screens. Hosted login ignores it + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - `us-east-1_MockDtLi1` - **`VUE_APP_RECAPTCHA_KEY`** - _Server_ :arrow_heading_up: - IA Test: `6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih` @@ -381,6 +453,17 @@ - Prod: `false` - _Local_ :arrow_heading_down: - `true` or `false` as needed + - **`VUE_APP_CUSTOM_AUTH`** + - `false` keeps Cognito's hosted login pages + - `true` uses the in-app login screens + - The value is fixed when the app is served or built. Restart `yarn serve` after changing it + - _Server_ :arrow_heading_up: + - IA Test: `false` + - CSG Test: `false` + - Beta: `false` + - Prod: `false` + - _Local_ :arrow_heading_down: + - `false` - **`VUE_APP_MOCK_API`** :arrow_heading_down: - Only used for local development - `true` if mock API should be used @@ -560,25 +643,25 @@ Note that testing the **built** app locally will require a running web server; f --- ## Auth -- We use two cognito user pools with hosted login pages to authenticate users as Provider users and Staff users respectively -- Cognito's somewhat opinionated functionality combined with this set up introduces some complexity to make the app function in a secure and expected way - -- **Cognito's functionality:** - - When a user logs in to Cognito, it saves an http only cookie allowing the user to log back in without entering credentials for an hour - - Cognito has no way to totally log a user out except for visiting the hosted logout url in the browser - - Using the token revocation endpoint, the user can invalidate their reresh token, but the http only cookie is not removed so if - they logged in within the last hour they are not totally logged out -- **Two user pools with their own hosted login pages:** - - If a user is logged in to one user pool, they can still log in to the second as they are not aware of each other - - The downstream effect of this point is that the app needs to handle users being logged in to both user pools in an expected and secure way: - - When a user logs out they must be logged out from all user pools they are logged into - - When a user logs in to the second user pool, the app must treat them as only logged into the second user pool -- **How the app handles this situation:** - - Firstly, the user being logged into both pools is very unlikely as there is not natural way to do this within the app, they would need to manually visit the - second hosted login page after logging in normally to the first - - When the user logs in as to the second user pool we record that the app should treat them as the second user type and save the initial access token - - When the user logs out we check the existence of the access tokens to see which user pools we need to log out, and then chain logout redirects - to visit all necessary logout pages +- We use two Cognito user pools to authenticate users as Provider users and Staff users +- By default, login uses Cognito's hosted login pages. `VUE_APP_CUSTOM_AUTH` is false +- Set `VUE_APP_CUSTOM_AUTH` to true to use the in-app login screens instead. Those screens cover sign-in, a new password, MFA setup, the MFA code, and forgot password. They do not set Cognito's hosted login cookie +- Hosted login, combined with two separate pools, adds some complexity so the app can stay secure and behave as expected + +- **Hosted login:** + - When a user logs in through a hosted page, Cognito saves an http-only cookie. That cookie lets them sign back in without entering credentials for about an hour + - Cognito does not fully log that user out unless the browser visits the hosted logout URL + - Revoking the refresh token invalidates the token, but it does not remove the cookie. If they logged in within the last hour, they are not fully logged out +- **Two user pools:** + - The pools are not aware of each other, so a user can be logged in to both + - The app has to handle that in a secure, expected way: + - Logging out must log the user out of every pool they are logged in to + - Logging in to the second pool must make the app treat them as only that second user type +- **How the app handles this:** + - With hosted login, the only way to be in both pools is to manually open the second hosted login page + - With in-app login, both logins are available from the dashboard + - When the user logs in to the second pool, we record that the app should treat them as that user type and we save that pool's access token + - On logout we revoke the refresh token for one pool and send the browser to that pool's hosted logout page. A staff access token selects the staff logout page. Otherwise we use the practitioner logout page. This still happens after an in-app login - **Registration** - Licensee users have the ability to self-register an account, provided matching data can be found in the state data. - The registration form utilizes recaptcha (Google recaptcha v3) diff --git a/webroot/package.json b/webroot/package.json index a9491e3e5..549fc1c99 100644 --- a/webroot/package.json +++ b/webroot/package.json @@ -18,6 +18,7 @@ "@statsig/web-analytics": "^3.33.4", "@vue/compat": "3.5.41", "@vuepic/vue-datepicker": "^11.0.3", + "aws-amplify": "^6.22.2", "axios": "^1.19.0", "caniuse-lite": "^1.0.30001809", "click-outside-vue3": "^4.0.1", @@ -37,7 +38,7 @@ "uuid": "^14.0.1", "vue": "^3.5.41", "vue-facing-decorator": "^3.0.4", - "vue-i18n": "^11.4.8", + "vue-i18n": "^11.4.14", "vue-responsiveness": "^0.2.5", "vue-router": "^4.5.1", "vue3-lazyload": "^0.4.2", diff --git a/webroot/src/components/AuthHelper/AuthHelper.less b/webroot/src/components/AuthHelper/AuthHelper.less new file mode 100644 index 000000000..bf861d2c3 --- /dev/null +++ b/webroot/src/components/AuthHelper/AuthHelper.less @@ -0,0 +1,62 @@ +// +// AuthHelper.less +// CompactConnect +// +// Created by InspiringApps on 10/28/2026. +// + +.auth-instructions { + margin-bottom: 1.6rem; +} + +.auth-helper { + width: 100%; + margin-bottom: 1.6rem; + padding-bottom: 0.8rem; + border-bottom: 0.1rem solid @midGrey; + color: @fontColor; + + h2, + h3 { + margin-bottom: 0.8rem; + font-size: @fontSize; + } + + h3 { + margin-top: 1.6rem; + } + + p, + li { + margin-bottom: 0.8rem; + font-size: @fontSizeMedium; + } + + ul, + ol { + margin: 0 0 0.8rem; + padding-left: 1.8rem; + font-size: @fontSizeMedium; + } + + ul li { + font-size: @fontSizeMedium; + list-style: disc; + } + + a { + color: @midBlue; + text-decoration: underline; + + &:focus { + .element-focus(); + } + } + + .app-links { + a, + .app-link-separator { + margin-left: 0.8rem; + } + } +} diff --git a/webroot/src/components/AuthHelper/AuthHelper.spec.ts b/webroot/src/components/AuthHelper/AuthHelper.spec.ts new file mode 100644 index 000000000..26d397398 --- /dev/null +++ b/webroot/src/components/AuthHelper/AuthHelper.spec.ts @@ -0,0 +1,64 @@ +// +// AuthHelper.spec.ts +// CompactConnect +// +// Created by InspiringApps on 10/28/2026. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import AuthHelper from '@components/AuthHelper/AuthHelper.vue'; +import { CognitoAuthStep } from '@utils/cognitoAuth'; + +describe('AuthHelper component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(AuthHelper); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(AuthHelper).exists()).to.equal(true); + }); + it('should explain authenticator setup on the MFA screen', async () => { + const wrapper = await mountShallow(AuthHelper, { + props: { + screen: CognitoAuthStep.MFA_SETUP, + }, + }); + const text = wrapper.text(); + + expect(wrapper.find('.auth-helper').exists()).to.equal(true); + expect(text).to.contain(wrapper.vm.$t('auth.helper.mfaSetupTitle')); + expect(text).to.contain(wrapper.vm.$t('auth.helper.mfaStepScan')); + expect(text).to.contain('15 minutes'); + expect(text).to.not.contain('temporary'); + expect(text).to.not.contain('3-minute'); + expect(wrapper.find('a[href*="google.android.apps.authenticator2"]').attributes('target')).to.equal('_blank'); + }); + it('should show the short instructions for password and code screens', async () => { + const screens = [ + [CognitoAuthStep.NEW_PASSWORD, 'auth.newPasswordInstructions'], + [CognitoAuthStep.MFA_CODE, 'auth.mfaCodeInstructions'], + [CognitoAuthStep.FORGOT_PASSWORD, 'auth.forgotPasswordInstructions'], + [CognitoAuthStep.FORGOT_PASSWORD_CONFIRM, 'auth.confirmForgotPasswordInstructions'], + ]; + + await screens.reduce(async (previous, [screen, key]) => { + await previous; + const wrapper = await mountShallow(AuthHelper, { + props: { screen }, + }); + + expect(wrapper.find('.auth-instructions').text()).to.equal(wrapper.vm.$t(key)); + expect(wrapper.find('.auth-helper').exists()).to.equal(false); + }, Promise.resolve()); + }); + it('should stay empty on the login screen', async () => { + const wrapper = await mountShallow(AuthHelper, { + props: { + screen: CognitoAuthStep.LOGIN, + }, + }); + + expect(wrapper.find('.auth-helper').exists()).to.equal(false); + expect(wrapper.find('.auth-instructions').exists()).to.equal(false); + }); +}); diff --git a/webroot/src/components/AuthHelper/AuthHelper.ts b/webroot/src/components/AuthHelper/AuthHelper.ts new file mode 100644 index 000000000..9c729ec8f --- /dev/null +++ b/webroot/src/components/AuthHelper/AuthHelper.ts @@ -0,0 +1,46 @@ +// +// AuthHelper.ts +// CompactConnect +// +// Created by InspiringApps on 10/28/2026. +// + +import { + Component, + Prop, + Vue, + toNative +} from 'vue-facing-decorator'; +import { CognitoAuthStep } from '@utils/cognitoAuth'; + +@Component({ + name: 'AuthHelper', +}) +class AuthHelper extends Vue { + @Prop({ default: '' }) screen!: string; + + // + // Computed + // + get isMfaSetup(): boolean { + return this.screen === CognitoAuthStep.MFA_SETUP; + } + + get instructions(): string { + let instructions = ''; + + if (this.screen === CognitoAuthStep.NEW_PASSWORD) { + instructions = this.$t('auth.newPasswordInstructions'); + } else if (this.screen === CognitoAuthStep.MFA_CODE) { + instructions = this.$t('auth.mfaCodeInstructions'); + } else if (this.screen === CognitoAuthStep.FORGOT_PASSWORD) { + instructions = this.$t('auth.forgotPasswordInstructions'); + } else if (this.screen === CognitoAuthStep.FORGOT_PASSWORD_CONFIRM) { + instructions = this.$t('auth.confirmForgotPasswordInstructions'); + } + + return instructions; + } +} + +export default toNative(AuthHelper); diff --git a/webroot/src/components/AuthHelper/AuthHelper.vue b/webroot/src/components/AuthHelper/AuthHelper.vue new file mode 100644 index 000000000..d316b1bb1 --- /dev/null +++ b/webroot/src/components/AuthHelper/AuthHelper.vue @@ -0,0 +1,76 @@ + + + + + + diff --git a/webroot/src/locales/en.json b/webroot/src/locales/en.json index 661dd776f..a4f594971 100644 --- a/webroot/src/locales/en.json +++ b/webroot/src/locales/en.json @@ -1313,5 +1313,60 @@ "successSubtext": "Your former home state and new home state will be automatically notified of this change.", "eligibleLicenseOption": "{state} (eligible {licenseType} license) \u2713", "noEligibleLicenseOption": "{state} (no eligible license)" + }, + "auth": { + "loginTitle": "Log in", + "staffLoginTitle": "Staff log in", + "practitionerLoginTitle": "Practitioner log in", + "loggingIn": "Logging in...", + "forgotPassword": "Forgot password?", + "sendingCode": "Sending code...", + "verifyingCode": "Verifying...", + "forgotPasswordTitle": "Forgot password", + "forgotPasswordInstructions": "Enter your email and we will send you a code to reset your password.", + "sendCode": "Send code", + "resetCode": "Reset code", + "confirmForgotPasswordTitle": "Choose a new password", + "confirmForgotPasswordInstructions": "Enter the code from your email and a new password.", + "newPasswordTitle": "Set a new password", + "newPasswordInstructions": "Your temporary password must be replaced before you can continue.", + "mfaSetupTitle": "Set up authenticator", + "mfaSetupKey": "Setup key", + "mfaSetupQr": "Authenticator QR code", + "copyKey": "Copy key", + "copiedKey": "Copied", + "mfaCodeTitle": "Authenticator code", + "mfaCodeInstructions": "Enter the 6-digit code from your authenticator app.", + "mfaCode": "Authentication code", + "timeRemaining": "Time remaining: {time}", + "sessionExpired": "This step timed out. Start again from the login screen.", + "invalidCredentials": "The email or password is incorrect.", + "invalidCode": "That code is incorrect.", + "expiredCode": "That code has expired. Request a new one.", + "tooManyAttempts": "Too many attempts. Wait a few minutes and try again.", + "invalidPassword": "The password does not meet the requirements.", + "passwordResetRequired": "You need to reset your password before you can log in.", + "genericError": "There was an error logging you in.", + "unsupportedLogin": "This login is not available.", + "backToLogin": "Back to login", + "backToDashboard": "Back to dashboard", + "codeSent": "If an account exists for that email, a reset code is on the way.", + "passwordUpdated": "Your password was updated. Log in with the new password.", + "helper": { + "mfaSetupTitle": "Multi-factor authentication (MFA)", + "mfaSetupIntro": "An authenticator app generates a 6-digit code that changes every 30 seconds. Enter that code on this page to finish signing in.", + "mfaAppsTitle": "Recommended authenticator apps", + "android": "Android", + "ios": "iOS", + "googleAuthenticator": "Google Authenticator", + "microsoftAuthenticator": "Microsoft Authenticator", + "authy": "Authy", + "mfaStepsTitle": "On this page", + "mfaStepDownload": "Download one of the authenticator apps above if you do not already have one.", + "mfaStepScan": "In the app, add an account and scan the QR code below, or enter the setup key.", + "mfaStepCode": "Enter the current 6-digit code. Codes refresh every 30 seconds.", + "mfaStepContinue": "Choose Continue.", + "mfaTimeLimit": "You have 15 minutes from when you signed in to finish this step. If time runs out, sign in again. If you are asked to set up MFA again, delete the previous entry for this account from your authenticator app before you scan the new code." + } } } diff --git a/webroot/src/locales/es.json b/webroot/src/locales/es.json index 5626c5611..f3c0c97c7 100644 --- a/webroot/src/locales/es.json +++ b/webroot/src/locales/es.json @@ -1312,5 +1312,60 @@ "successSubtext": "Su antiguo estado de residencia y el nuevo ser谩n notificados autom谩ticamente de este cambio.", "eligibleLicenseOption": "{state} (licencia {licenseType} elegible) \u2713", "noEligibleLicenseOption": "{state} (sin licencia elegible)" + }, + "auth": { + "loginTitle": "Iniciar sesi贸n", + "staffLoginTitle": "Inicio de sesi贸n del personal", + "practitionerLoginTitle": "Inicio de sesi贸n de practicante", + "loggingIn": "Iniciando sesi贸n...", + "forgotPassword": "驴Olvid贸 su contrase帽a?", + "sendingCode": "Enviando c贸digo...", + "verifyingCode": "Verificando...", + "forgotPasswordTitle": "Olvid贸 su contrase帽a", + "forgotPasswordInstructions": "Ingrese su correo electr贸nico y le enviaremos un c贸digo para restablecer su contrase帽a.", + "sendCode": "Enviar c贸digo", + "resetCode": "C贸digo de restablecimiento", + "confirmForgotPasswordTitle": "Elija una nueva contrase帽a", + "confirmForgotPasswordInstructions": "Ingrese el c贸digo de su correo electr贸nico y una nueva contrase帽a.", + "newPasswordTitle": "Establecer una nueva contrase帽a", + "newPasswordInstructions": "Debe reemplazar su contrase帽a temporal antes de continuar.", + "mfaSetupTitle": "Configurar autenticador", + "mfaSetupKey": "Clave de configuraci贸n", + "mfaSetupQr": "C贸digo QR del autenticador", + "copyKey": "Copiar clave", + "copiedKey": "Copiado", + "mfaCodeTitle": "C贸digo del autenticador", + "mfaCodeInstructions": "Ingrese el c贸digo de 6 d铆gitos de su aplicaci贸n de autenticaci贸n.", + "mfaCode": "C贸digo de autenticaci贸n", + "timeRemaining": "Tiempo restante: {time}", + "sessionExpired": "Este paso caduc贸. Comience de nuevo desde la pantalla de inicio de sesi贸n.", + "invalidCredentials": "El correo electr贸nico o la contrase帽a son incorrectos.", + "invalidCode": "Ese c贸digo es incorrecto.", + "expiredCode": "Ese c贸digo ha caducado. Solicite uno nuevo.", + "tooManyAttempts": "Demasiados intentos. Espere unos minutos e int茅ntelo de nuevo.", + "invalidPassword": "La contrase帽a no cumple los requisitos.", + "passwordResetRequired": "Debe restablecer su contrase帽a antes de poder iniciar sesi贸n.", + "genericError": "Hubo un error al iniciar sesi贸n.", + "unsupportedLogin": "Este inicio de sesi贸n no est谩 disponible.", + "backToLogin": "Volver al inicio de sesi贸n", + "backToDashboard": "Volver al panel", + "codeSent": "Si existe una cuenta para ese correo electr贸nico, un c贸digo de restablecimiento est谩 en camino.", + "passwordUpdated": "Su contrase帽a fue actualizada. Inicie sesi贸n con la nueva contrase帽a.", + "helper": { + "mfaSetupTitle": "Autenticaci贸n multifactor (MFA)", + "mfaSetupIntro": "Una aplicaci贸n de autenticaci贸n genera un c贸digo de 6 d铆gitos que cambia cada 30 segundos. Ingrese ese c贸digo en esta p谩gina para terminar de iniciar sesi贸n.", + "mfaAppsTitle": "Aplicaciones de autenticaci贸n recomendadas", + "android": "Android", + "ios": "iOS", + "googleAuthenticator": "Google Authenticator", + "microsoftAuthenticator": "Microsoft Authenticator", + "authy": "Authy", + "mfaStepsTitle": "En esta p谩gina", + "mfaStepDownload": "Descargue una de las aplicaciones de autenticaci贸n anteriores si a煤n no tiene una.", + "mfaStepScan": "En la aplicaci贸n, agregue una cuenta y escanee el c贸digo QR de abajo, o ingrese la clave de configuraci贸n.", + "mfaStepCode": "Ingrese el c贸digo actual de 6 d铆gitos. Los c贸digos se actualizan cada 30 segundos.", + "mfaStepContinue": "Seleccione Continuar.", + "mfaTimeLimit": "Tiene 15 minutos desde que inici贸 sesi贸n para terminar este paso. Si se acaba el tiempo, inicie sesi贸n de nuevo. Si se le pide configurar la MFA otra vez, elimine la entrada anterior de esta cuenta en su aplicaci贸n de autenticaci贸n antes de escanear el c贸digo nuevo." + } } } diff --git a/webroot/src/pages/AuthLogin/AuthLogin.less b/webroot/src/pages/AuthLogin/AuthLogin.less new file mode 100644 index 000000000..a6c3fd74a --- /dev/null +++ b/webroot/src/pages/AuthLogin/AuthLogin.less @@ -0,0 +1,132 @@ +// +// AuthLogin.less +// CompactConnect +// +// Created by InspiringApps on 10/22/2026. +// + +.auth-login-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 2.4rem 1rem; + + @media @tabletWidth { + min-height: 100vh; + padding: 2.4rem 1.6rem; + } + + .auth-panel { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + max-width: 50rem; + margin-top: 2.4rem; + + @media @desktopWidth { + margin-top: 4.8rem; + } + } + + .auth-return-link { + color: @midBlue; + + &:hover { + text-decoration: underline; + } + + &:focus { + .element-focus(); + } + } + + .auth-card { + flex-direction: column; + align-items: stretch; + width: 100%; + max-width: 50rem; + margin-top: 1.6rem; + padding-right: 2rem; + padding-left: 2rem; + + @media @desktopWidth { + padding-right: 4rem; + padding-left: 4rem; + } + } + + form { + width: 100%; + } + + .auth-logo-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 8rem; + margin-bottom: 2rem; + text-align: center; + + .auth-logo { + display: inline-block; + width: auto; + height: 100%; + } + } + + .auth-compact { + margin-bottom: 0.4rem; + color: @fontColorLight; + font-size: @fontSizeSmaller; + } + + .auth-title { + margin-bottom: 2rem; + font-size: @fontSizeLarge; + + &:focus { + .element-focus(); + } + } + + .auth-countdown { + margin-bottom: 1.6rem; + } + + .mfa-setup { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 1.6rem; + } + + .mfa-qr { + width: 220px; + height: 220px; + margin-bottom: 1.2rem; + } + + .setup-key-label { + margin-bottom: 0.4rem; + } + + .setup-key { + width: 100%; + margin-bottom: 0.8rem; + padding: 0.8rem; + font-family: monospace; + } + + .auth-text-link { + align-self: flex-start; + margin-top: 1.2rem; + color: @midBlue; + } + + .copy-status { + .visually-hidden(); + } +} diff --git a/webroot/src/pages/AuthLogin/AuthLogin.spec.ts b/webroot/src/pages/AuthLogin/AuthLogin.spec.ts new file mode 100644 index 000000000..bc3359b76 --- /dev/null +++ b/webroot/src/pages/AuthLogin/AuthLogin.spec.ts @@ -0,0 +1,275 @@ +// +// AuthLogin.spec.ts +// CompactConnect +// +// Created by InspiringApps on 10/22/2026. +// + +import { expect } from 'chai'; +import sinon from 'sinon'; +import { nextTick } from 'vue'; +import { flushPromises } from '@vue/test-utils'; +import { mountShallow } from '@tests/helpers/setup'; +import AuthLogin from '@pages/AuthLogin/AuthLogin.vue'; +import { AppModes } from '@/app.config'; +import { AuthTypes, authStorage, AUTH_LOGIN_GOTO_COMPACT } from '@utils/auth'; +import { CompactType } from '@utils/compactConfig'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import cognitoAuth, { CognitoAuthStep } from '@utils/cognitoAuth'; +import AuthHelper from '@components/AuthHelper/AuthHelper.vue'; + +describe('AuthLogin page', async () => { + const originalClientId = envConfig.cognitoClientIdStaff; + const originalAuthDomain = envConfig.cognitoAuthDomainStaff; + const originalLicenseeClientId = envConfig.cognitoClientIdLicensee; + const originalLicenseeAuthDomain = envConfig.cognitoAuthDomainLicensee; + const originalCustomAuth = envConfig.isCustomAuth; + + beforeEach(() => { + // The login route redirects away unless custom auth is on. + envConfig.isCustomAuth = true; + }); + + afterEach(() => { + envConfig.cognitoClientIdStaff = originalClientId; + envConfig.cognitoAuthDomainStaff = originalAuthDomain; + envConfig.cognitoClientIdLicensee = originalLicenseeClientId; + envConfig.cognitoAuthDomainLicensee = originalLicenseeAuthDomain; + envConfig.isCustomAuth = originalCustomAuth; + authStorage.removeItem(AUTH_LOGIN_GOTO_COMPACT); + }); + + it('should mount the page component', async () => { + const wrapper = await mountShallow(AuthLogin); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(AuthLogin).exists()).to.equal(true); + }); + it('should show login fields for a supported staff compact', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc'); + wrapper.vm.applyRoute(); + wrapper.vm.initFormInputs(); + await nextTick(); + + expect(wrapper.vm.isSupportedTarget).to.equal(true); + expect(wrapper.vm.appMode).to.equal(AppModes.JCC); + expect(wrapper.vm.authType).to.equal(AuthTypes.STAFF); + expect(wrapper.vm.isLoginStep).to.equal(true); + expect(wrapper.vm.title).to.equal(wrapper.vm.$t('auth.staffLoginTitle')); + expect(wrapper.vm.compactLabel).to.equal(''); + expect(wrapper.find('.auth-compact').exists()).to.equal(false); + expect(wrapper.vm.formData.email).to.not.equal(undefined); + expect(wrapper.vm.formData.password).to.not.equal(undefined); + const returnLink = wrapper.find('.auth-return-link'); + + expect(returnLink.text()).to.equal(wrapper.vm.$t('auth.backToDashboard')); + expect(returnLink.attributes('href')).to.equal('/Dashboard'); + expect(wrapper.find('img.auth-logo').attributes('alt')).to.equal(wrapper.vm.$t('common.appName')); + }); + it('should name the selected compact above the staff login title', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + authStorage.setItem(AUTH_LOGIN_GOTO_COMPACT, CompactType.ASLP); + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc'); + wrapper.vm.applyRoute(); + await nextTick(); + + expect(wrapper.find('.auth-compact').text()).to.equal('Audiology and Speech Language Pathology (ASLP)'); + expect(wrapper.find('h1.auth-title').text()).to.equal(wrapper.vm.$t('auth.staffLoginTitle')); + }); + it('should title a practitioner login separately from staff', async () => { + envConfig.cognitoClientIdLicensee = 'licensee-client'; + envConfig.cognitoAuthDomainLicensee = 'https://licensee.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/licensee/jcc'); + wrapper.vm.applyRoute(); + await nextTick(); + + expect(wrapper.vm.authType).to.equal(AuthTypes.LICENSEE); + expect(wrapper.find('h1.auth-title').text()).to.equal(wrapper.vm.$t('auth.practitionerLoginTitle')); + }); + it('should label the login button as logging in while the request is in progress', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc'); + wrapper.vm.applyRoute(); + wrapper.vm.initFormInputs(); + wrapper.vm.isFormLoading = true; + + expect(wrapper.vm.submitLabel).to.equal(wrapper.vm.$t('auth.loggingIn')); + }); + it('should label the forgot-password button as sending code while the request is in progress', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc?step=forgot-password'); + wrapper.vm.applyRoute(); + wrapper.vm.initFormInputs(); + wrapper.vm.isFormLoading = true; + + expect(wrapper.vm.submitLabel).to.equal(wrapper.vm.$t('auth.sendingCode')); + }); + it('should label the MFA code button as verifying while the request is in progress', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc'); + wrapper.vm.applyRoute(); + wrapper.vm.step = CognitoAuthStep.MFA_CODE; + wrapper.vm.initFormInputs(); + wrapper.vm.isFormLoading = true; + + expect(wrapper.vm.submitLabel).to.equal(wrapper.vm.$t('auth.verifyingCode')); + }); + it('should open forgot password from the query step', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc?step=forgot-password'); + wrapper.vm.applyRoute(); + wrapper.vm.initFormInputs(); + await nextTick(); + + expect(wrapper.vm.isForgotPasswordStep).to.equal(true); + expect(wrapper.vm.formData.email).to.not.equal(undefined); + expect(wrapper.vm.formData.password).to.equal(undefined); + }); + it('should reapply the target and step when the login route changes', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + envConfig.cognitoClientIdLicensee = 'licensee-client'; + envConfig.cognitoAuthDomainLicensee = 'https://licensee.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc?step=forgot-password'); + await nextTick(); + + expect(wrapper.vm.authType).to.equal(AuthTypes.STAFF); + expect(wrapper.vm.isForgotPasswordStep).to.equal(true); + wrapper.vm.formData.email.value = 'user@example.com'; + + await wrapper.vm.$router.push('/auth/login/licensee/jcc'); + await nextTick(); + + expect(wrapper.vm.authType).to.equal(AuthTypes.LICENSEE); + expect(wrapper.vm.appMode).to.equal(AppModes.JCC); + expect(wrapper.vm.isLoginStep).to.equal(true); + expect(wrapper.vm.formData.password).to.not.equal(undefined); + expect(wrapper.vm.formData.email.value).to.equal(''); + }); + it('should move to MFA setup after a successful password challenge', async () => { + const signInStub = sinon.stub(cognitoAuth, 'signInWithPassword').resolves({ + step: CognitoAuthStep.MFA_SETUP, + totpSecret: 'SECRETKEY', + totpSetupUri: 'otpauth://totp/CompactConnect:user@example.com?secret=SECRETKEY', + mfaSetupExpiresAt: Date.now() + (15 * 60 * 1000), + }); + + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc'); + wrapper.vm.applyRoute(); + wrapper.vm.initFormInputs(); + wrapper.vm.formData.email.value = 'user@example.com'; + wrapper.vm.formData.password.value = 'temporary-password'; + await wrapper.vm.handleSubmit(); + await flushPromises(); + + expect(signInStub.calledOnce).to.equal(true); + expect(wrapper.vm.isMfaSetupStep).to.equal(true); + expect(wrapper.vm.totpSecret).to.equal('SECRETKEY'); + expect(wrapper.vm.qrCodeDataUrl).to.be.a('string').with.length.above(0); + expect(wrapper.vm.remainingMs).to.be.above(14 * 60 * 1000); + expect(wrapper.findComponent(AuthHelper).props('screen')).to.equal(CognitoAuthStep.MFA_SETUP); + }); + it('should keep the email and focus the step title when opening forgot password', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc'); + wrapper.vm.applyRoute(); + wrapper.vm.initFormInputs(); + wrapper.vm.formData.email.value = 'user@example.com'; + + const focus = sinon.spy(HTMLElement.prototype, 'focus'); + + await wrapper.vm.showForgotPassword(); + + const title = wrapper.find('h1.auth-title'); + + expect(wrapper.vm.isForgotPasswordStep).to.equal(true); + expect(wrapper.vm.formData.email.value).to.equal('user@example.com'); + expect(title.attributes('tabindex')).to.equal('-1'); + expect(focus.calledOn(title.element)).to.equal(true); + expect(wrapper.vm.formData.email.isTouched).to.equal(false); + expect(wrapper.vm.formData.email.errorMessage).to.equal(''); + }); + it('should show the required error only after forgot password is submitted empty', async () => { + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc'); + wrapper.vm.applyRoute(); + wrapper.vm.initFormInputs(); + await wrapper.vm.showForgotPassword(); + await wrapper.vm.handleSubmit(); + + expect(wrapper.vm.formData.email.isTouched).to.equal(true); + expect(wrapper.vm.formData.email.errorMessage).to.equal(wrapper.vm.$t('inputErrors.required')); + }); + it('should announce when the setup key is copied', async () => { + const writeText = sinon.stub().resolves(); + const originalClipboard = navigator.clipboard; + + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + envConfig.cognitoClientIdStaff = 'staff-client'; + envConfig.cognitoAuthDomainStaff = 'https://staff.example.com'; + + const wrapper = await mountShallow(AuthLogin); + + await wrapper.vm.$router.push('/auth/login/staff/jcc'); + wrapper.vm.applyRoute(); + wrapper.vm.step = CognitoAuthStep.MFA_SETUP; + wrapper.vm.totpSecret = 'SECRETKEY'; + await nextTick(); + await wrapper.vm.copySetupKey(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); + + expect(writeText.calledOnceWith('SECRETKEY')).to.equal(true); + expect(wrapper.vm.copyStatus).to.equal(wrapper.vm.$t('auth.copiedKey')); + expect(wrapper.find('[role="status"]').text()).to.equal(wrapper.vm.$t('auth.copiedKey')); + }); +}); diff --git a/webroot/src/pages/AuthLogin/AuthLogin.ts b/webroot/src/pages/AuthLogin/AuthLogin.ts new file mode 100644 index 000000000..1566df5c6 --- /dev/null +++ b/webroot/src/pages/AuthLogin/AuthLogin.ts @@ -0,0 +1,558 @@ +// +// AuthLogin.ts +// CompactConnect +// +// Created by InspiringApps on 10/22/2026. +// + +import { + Component, + mixins, + Watch, + toNative +} from 'vue-facing-decorator'; +import { reactive, computed, nextTick } from 'vue'; +import { AppModes } from '@/app.config'; +import { + authStorage, + AuthTypes, + AUTH_LOGIN_GOTO_COMPACT, + AUTH_LOGIN_GOTO_PATH, + AUTH_LOGIN_GOTO_PATH_AUTH_TYPE, + getCognitoConfig +} from '@utils/auth'; +import { + CompactType, + getCompactSetup, + getImpliedCompactForAppMode +} from '@utils/compactConfig'; +import { getCompactConfig } from '@plugins/Compacts/compacts.plugin'; +import cognitoAuth, { + CognitoAuthResult, + CognitoAuthStep, + formatAuthCountdown, + getTotpIssuer, + OAuthTokenResponse +} from '@utils/cognitoAuth'; +import AuthHelper from '@components/AuthHelper/AuthHelper.vue'; +import Card from '@components/Card/Card.vue'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import InputButton from '@components/Forms/InputButton/InputButton.vue'; +import InputPassword from '@components/Forms/InputPassword/InputPassword.vue'; +import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; +import InputText from '@components/Forms/InputText/InputText.vue'; +import Section from '@components/Section/Section.vue'; +import { FormInput } from '@models/FormInput/FormInput.model'; +import Joi from 'joi'; +import { joiPasswordExtendCore } from 'joi-password'; +import QRCode from 'qrcode'; + +const joiPassword = Joi.extend(joiPasswordExtendCore); +const successMessageKeys = ['auth.codeSent', 'auth.passwordUpdated']; + +@Component({ + name: 'AuthLogin', + components: { + Section, + Card, + InputText, + InputPassword, + InputSubmit, + InputButton, + AuthHelper, + }, +}) +class AuthLogin extends mixins(MixinForm) { + // + // Data + // + step: CognitoAuthStep = CognitoAuthStep.LOGIN; + appMode: AppModes = AppModes.JCC; + authType: AuthTypes = AuthTypes.STAFF; + isSupportedTarget = false; + totpSecret = ''; + qrCodeDataUrl = ''; + mfaSetupExpiresAt = 0; + remainingMs = 0; + countdownTimerId: number | null = null; + didCopyKey = false; + copyStatus = ''; + resetUsername = ''; + + // + // Lifecycle + // + created(): void { + this.applyRoute(); + this.initFormInputs(); + } + + beforeUnmount(): void { + this.clearCountdown(); + } + + // + // Computed + // + get isLoginStep(): boolean { + return this.step === CognitoAuthStep.LOGIN; + } + + get isNewPasswordStep(): boolean { + return this.step === CognitoAuthStep.NEW_PASSWORD; + } + + get isMfaSetupStep(): boolean { + return this.step === CognitoAuthStep.MFA_SETUP; + } + + get isMfaCodeStep(): boolean { + return this.step === CognitoAuthStep.MFA_CODE; + } + + get isForgotPasswordStep(): boolean { + return this.step === CognitoAuthStep.FORGOT_PASSWORD; + } + + get isForgotPasswordConfirmStep(): boolean { + return this.step === CognitoAuthStep.FORGOT_PASSWORD_CONFIRM; + } + + get showsEmail(): boolean { + return this.isLoginStep || this.isForgotPasswordStep || this.isForgotPasswordConfirmStep; + } + + get showsLoginPassword(): boolean { + return this.isLoginStep; + } + + get showsNewPassword(): boolean { + return this.isNewPasswordStep || this.isForgotPasswordConfirmStep; + } + + get showsMfaCode(): boolean { + return this.isMfaSetupStep || this.isMfaCodeStep; + } + + get isMfaSetupExpired(): boolean { + return this.isMfaSetupStep && this.mfaSetupExpiresAt > 0 && this.remainingMs <= 0; + } + + get title(): string { + let title = this.$t('auth.staffLoginTitle'); + + if (this.authType === AuthTypes.LICENSEE) { + title = this.$t('auth.practitionerLoginTitle'); + } + + if (this.isNewPasswordStep) { + title = this.$t('auth.newPasswordTitle'); + } else if (this.isMfaSetupStep) { + title = this.$t('auth.mfaSetupTitle'); + } else if (this.isMfaCodeStep) { + title = this.$t('auth.mfaCodeTitle'); + } else if (this.isForgotPasswordStep) { + title = this.$t('auth.forgotPasswordTitle'); + } else if (this.isForgotPasswordConfirmStep) { + title = this.$t('auth.confirmForgotPasswordTitle'); + } + + return title; + } + + get compactLabel(): string { + const storedCompact = authStorage.getItem(AUTH_LOGIN_GOTO_COMPACT); + const storedSetup = getCompactSetup(storedCompact); + const compactType = (storedSetup?.appMode === this.appMode) + ? storedCompact + : getImpliedCompactForAppMode(this.appMode); + const compact = getCompactConfig(compactType); + const shouldAddAbbrev = compact?.type === CompactType.ASLP || compact?.type === CompactType.OT; + let label = compact?.name || ''; + + if (shouldAddAbbrev && compact?.abbrev) { + label = `${label} (${compact.abbrev})`; + } + + return label; + } + + get countdownLabel(): string { + return this.$t('auth.timeRemaining', { time: formatAuthCountdown(this.remainingMs) }); + } + + get copyLabel(): string { + let label = this.$t('auth.copyKey'); + + if (this.didCopyKey) { + label = this.$t('auth.copiedKey'); + } + + return label; + } + + get submitLabel(): string { + let label = this.$t('navigation.login'); + + if (this.isFormLoading && this.isLoginStep) { + label = this.$t('auth.loggingIn'); + } else if (this.isFormLoading && this.isForgotPasswordStep) { + label = this.$t('auth.sendingCode'); + } else if (this.isFormLoading && this.isMfaCodeStep) { + label = this.$t('auth.verifyingCode'); + } else if (this.isFormLoading) { + label = this.$t('common.saving'); + } else if (this.isForgotPasswordStep) { + label = this.$t('auth.sendCode'); + } else if (this.isNewPasswordStep || this.isForgotPasswordConfirmStep) { + label = this.$t('common.confirm'); + } else if (this.isMfaSetupStep || this.isMfaCodeStep) { + label = this.$t('common.continue'); + } + + return label; + } + + get isSubmitEnabled(): boolean { + return !this.isFormLoading && !this.isMfaSetupExpired; + } + + get totpIssuer(): string { + return getTotpIssuer(this.appMode); + } + + // + // Methods + // + applyRoute(): void { + const authTypeParam = String(this.$route.params.authType || ''); + const appModeParam = String(this.$route.params.appMode || ''); + const stepParam = String(this.$route.query.step || ''); + const isStaff = authTypeParam === AuthTypes.STAFF; + const isLicensee = authTypeParam === AuthTypes.LICENSEE; + const isKnownMode = Object.values(AppModes).some((mode) => mode === appModeParam); + let supported = false; + + if ((isStaff || isLicensee) && isKnownMode) { + supported = Boolean(getCognitoConfig(appModeParam as AppModes, authTypeParam as AuthTypes).clientId); + } + + this.isSupportedTarget = supported; + + if (supported) { + this.authType = authTypeParam as AuthTypes; + this.appMode = appModeParam as AppModes; + this.$store.dispatch('setAppMode', this.appMode); + + if (stepParam === CognitoAuthStep.FORGOT_PASSWORD) { + this.step = CognitoAuthStep.FORGOT_PASSWORD; + } else { + this.step = CognitoAuthStep.LOGIN; + } + + this.clearCountdown(); + } + } + + passwordValidation(): Joi.Schema { + return joiPassword + .string() + .min(12) + .minOfLowercase(1) + .minOfNumeric(1) + .messages({ + ...this.joiMessages.string, + ...this.joiMessages.password, + }); + } + + initFormInputs(): void { + const formData: Record = {}; + + if (this.showsEmail) { + formData.email = new FormInput({ + id: 'auth-email', + name: 'email', + label: computed(() => this.$t('common.emailAddress')), + placeholder: computed(() => this.$t('common.emailAddress')), + autocomplete: 'username', + value: this.resetUsername, + validation: Joi.string().email({ tlds: false }).required() + .messages(this.joiMessages.string), + }); + } + + if (this.showsLoginPassword) { + formData.password = new FormInput({ + id: 'auth-password', + name: 'password', + label: computed(() => this.$t('common.password')), + placeholder: computed(() => this.$t('common.password')), + autocomplete: 'current-password', + validation: Joi.string().required().messages(this.joiMessages.string), + }); + } + + if (this.isForgotPasswordConfirmStep) { + formData.resetCode = new FormInput({ + id: 'auth-reset-code', + name: 'reset-code', + label: computed(() => this.$t('auth.resetCode')), + placeholder: computed(() => this.$t('auth.resetCode')), + autocomplete: 'one-time-code', + validation: Joi.string().required().messages(this.joiMessages.string), + }); + } + + if (this.showsNewPassword) { + formData.newPassword = new FormInput({ + id: 'auth-new-password', + name: 'new-password', + label: computed(() => this.$t('common.newPassword')), + placeholder: computed(() => this.$t('common.newPassword')), + autocomplete: 'new-password', + validation: this.passwordValidation(), + }); + formData.confirmPassword = new FormInput({ + id: 'auth-confirm-password', + name: 'confirm-password', + label: computed(() => this.$t('common.confirmNewPassword')), + placeholder: computed(() => this.$t('common.confirmNewPassword')), + autocomplete: 'new-password', + validation: Joi.string().required().messages(this.joiMessages.string), + }); + } + + if (this.showsMfaCode) { + formData.mfaCode = new FormInput({ + id: 'auth-mfa-code', + name: 'mfa-code', + label: computed(() => this.$t('auth.mfaCode')), + placeholder: computed(() => this.$t('auth.mfaCode')), + autocomplete: 'one-time-code', + validation: Joi.string().length(6).pattern(/^\d+$/).required() + .messages(this.joiMessages.string), + }); + } + + formData.submit = new FormInput({ + isSubmitInput: true, + id: 'auth-submit', + }); + this.formData = reactive(formData); + this.watchFormInputs(); + } + + validatePasswordMatch(): void { + const confirmPasswordInput = this.formData.confirmPassword; + const { newPassword, confirmPassword } = this.formValues; + + if (confirmPasswordInput && confirmPasswordInput.isTouched && newPassword !== confirmPassword) { + confirmPasswordInput.errorMessage = this.$t('common.passwordsMustMatch'); + confirmPasswordInput.isValid = false; + this.checkValidForAll(); + } else if (confirmPasswordInput && confirmPasswordInput.isTouched) { + confirmPasswordInput.errorMessage = ''; + confirmPasswordInput.isValid = true; + this.checkValidForAll(); + } + } + + validateAll(config: any = {}): void { + const shouldSkipFieldFocus = !config.asTouched; + const nextConfig = { + ...config, + skipErrorScroll: shouldSkipFieldFocus || Boolean(config.skipErrorScroll), + }; + + MixinForm.prototype.validateAll.call(this, nextConfig); + } + + async handleSubmit(): Promise { + this.validateAll({ asTouched: true }); + this.validatePasswordMatch(); + + if (this.isFormValid && !this.isMfaSetupExpired) { + this.startFormLoading(); + await this.submitCurrentStep().catch(() => { + this.setError(this.$t('auth.genericError')); + }); + this.endFormLoading(); + } else if (this.isMfaSetupExpired) { + this.setError(this.$t('auth.sessionExpired')); + } + } + + async submitCurrentStep(): Promise { + const { + email, + password, + newPassword, + confirmPassword, + resetCode, + mfaCode, + } = this.formValues; + let result: CognitoAuthResult = {}; + + if (this.isLoginStep) { + result = await cognitoAuth.signInWithPassword( + this.appMode, + this.authType, + email, + password, + this.totpIssuer + ); + } else if (this.isNewPasswordStep) { + result = await cognitoAuth.confirmChallenge(newPassword, this.totpIssuer); + } else if (this.isMfaSetupStep || this.isMfaCodeStep) { + result = await cognitoAuth.confirmChallenge(mfaCode, this.totpIssuer); + } else if (this.isForgotPasswordStep) { + result = await cognitoAuth.requestPasswordReset(this.appMode, this.authType, email); + } else if (this.isForgotPasswordConfirmStep) { + result = await cognitoAuth.confirmPasswordReset(email || '', resetCode, newPassword || confirmPassword); + } + + await this.applyAuthResult(result); + } + + async applyAuthResult(result: CognitoAuthResult): Promise { + const isSuccessMessage = Boolean(result.messageKey) && successMessageKeys.indexOf(result.messageKey || '') !== -1; + + if (result.tokenResponse) { + await this.finishLogin(result.tokenResponse); + } else { + this.rememberEmail(); + + if (result.step) { + await this.setStep(result.step, result); + } + + if (result.messageKey && isSuccessMessage) { + this.updateFormSubmitSuccess(this.$t(result.messageKey)); + } else if (result.messageKey) { + this.setError(this.$t(result.messageKey)); + } + } + } + + async setStep(step: CognitoAuthStep, result: CognitoAuthResult = {}): Promise { + this.step = step; + this.didCopyKey = false; + this.copyStatus = ''; + this.totpSecret = result.totpSecret || ''; + this.qrCodeDataUrl = ''; + this.mfaSetupExpiresAt = result.mfaSetupExpiresAt || 0; + this.remainingMs = (this.mfaSetupExpiresAt) ? this.mfaSetupExpiresAt - Date.now() : 0; + this.clearCountdown(); + this.initFormInputs(); + + if (result.totpSetupUri) { + this.qrCodeDataUrl = await QRCode.toDataURL(result.totpSetupUri, { margin: 1, width: 220 }); + } + + if (this.mfaSetupExpiresAt) { + this.startCountdown(); + } + + await this.focusStepTitle(); + } + + async focusStepTitle(): Promise { + await nextTick(); + const title = this.$refs.stepTitle as HTMLElement | undefined; + + if (title) { + if (typeof title.scrollIntoView === 'function') { + title.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + title.focus(); + } + } + + rememberEmail(): void { + if (this.formValues.email) { + this.resetUsername = this.formValues.email; + } + } + + startCountdown(): void { + this.clearCountdown(); + this.countdownTimerId = window.setInterval(() => { + this.remainingMs = this.mfaSetupExpiresAt - Date.now(); + + if (this.remainingMs <= 0) { + this.remainingMs = 0; + this.clearCountdown(); + this.setError(this.$t('auth.sessionExpired')); + } + }, 1000); + } + + clearCountdown(): void { + if (this.countdownTimerId !== null) { + window.clearInterval(this.countdownTimerId); + this.countdownTimerId = null; + } + } + + async copySetupKey(): Promise { + let didCopy = false; + + if (this.totpSecret && navigator.clipboard) { + await navigator.clipboard.writeText(this.totpSecret).then(() => { + didCopy = true; + }).catch(() => { + didCopy = false; + }); + } + + this.didCopyKey = didCopy; + this.copyStatus = ''; + + if (didCopy) { + await nextTick(); + this.copyStatus = this.$t('auth.copiedKey'); + } + } + + showForgotPassword(): Promise { + this.rememberEmail(); + + return this.setStep(CognitoAuthStep.FORGOT_PASSWORD); + } + + showLogin(): Promise { + this.rememberEmail(); + + return this.setStep(CognitoAuthStep.LOGIN); + } + + async finishLogin(tokenResponse: OAuthTokenResponse): Promise { + const goto = authStorage.getItem(AUTH_LOGIN_GOTO_PATH); + const gotoAuthType = authStorage.getItem(AUTH_LOGIN_GOTO_PATH_AUTH_TYPE); + + authStorage.removeItem(AUTH_LOGIN_GOTO_PATH); + authStorage.removeItem(AUTH_LOGIN_GOTO_PATH_AUTH_TYPE); + this.$store.dispatch('setAppMode', this.appMode); + await this.$store.dispatch('user/updateAuthTokens', { tokenResponse, authType: this.authType }); + await this.$store.dispatch('user/loginSuccess', this.authType); + + if (goto && (!gotoAuthType || gotoAuthType === this.authType)) { + this.$router.push({ path: goto }); + } else { + await nextTick(); + this.$router.push({ name: 'Home' }); + } + } + + // + // Watchers + // + @Watch('$route.fullPath') onRouteChange(): void { + this.applyRoute(); + this.initFormInputs(); + } +} + +export default toNative(AuthLogin); diff --git a/webroot/src/pages/AuthLogin/AuthLogin.vue b/webroot/src/pages/AuthLogin/AuthLogin.vue new file mode 100644 index 000000000..e39f3cb37 --- /dev/null +++ b/webroot/src/pages/AuthLogin/AuthLogin.vue @@ -0,0 +1,131 @@ + + + + + + diff --git a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.spec.ts b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.spec.ts index 84bccf510..ccfd68783 100644 --- a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.spec.ts +++ b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.spec.ts @@ -6,8 +6,12 @@ // import { expect } from 'chai'; +import sinon from 'sinon'; import { mountShallow } from '@tests/helpers/setup'; import MfaResetConfirmLicensee from '@pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.vue'; +import mockEnvConfig from '@tests/mocks/mockEnvConfig'; +import { AppModes } from '@/app.config'; +import { AuthTypes } from '@utils/auth'; describe('MfaResetConfirmLicensee page', async () => { it('should mount the page component', async () => { @@ -16,4 +20,27 @@ describe('MfaResetConfirmLicensee page', async () => { expect(wrapper.exists()).to.equal(true); expect(wrapper.findComponent(MfaResetConfirmLicensee).exists()).to.equal(true); }); + it('should open in-app licensee login for the compact in the reset link', async () => { + const wasCustomAuth = mockEnvConfig.isCustomAuth; + const wasMockApi = mockEnvConfig.isUsingMockApi; + const wrapper = await mountShallow(MfaResetConfirmLicensee); + const replace = sinon.stub(wrapper.vm.$router, 'replace'); + + mockEnvConfig.isCustomAuth = true; + mockEnvConfig.isUsingMockApi = false; + await wrapper.vm.$router.push({ path: '/MfaResetConfirm', query: { compact: 'psypact' }}); + wrapper.vm.goToLogin(); + + expect(replace.calledOnce).to.equal(true); + expect(replace.firstCall.args[0]).to.deep.equal({ + name: 'AuthLogin', + params: { + authType: AuthTypes.LICENSEE, + appMode: AppModes.PSYPACT, + }, + }); + + mockEnvConfig.isCustomAuth = wasCustomAuth; + mockEnvConfig.isUsingMockApi = wasMockApi; + }); }); diff --git a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts index 3d0d508bc..7ff17dbfa 100644 --- a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts +++ b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts @@ -97,6 +97,10 @@ class MfaResetConfirmLicensee extends Vue { return this.$envConfig.isUsingMockApi || false; } + get isCustomAuth(): boolean { + return this.$envConfig.isCustomAuth || false; + } + // // Methods // @@ -195,6 +199,14 @@ class MfaResetConfirmLicensee extends Vue { goToLogin(): void { if (this.isUsingMockApi) { this.mockLicenseeLogin(); + } else if (this.isCustomAuth) { + this.$router.replace({ + name: 'AuthLogin', + params: { + authType: AuthTypes.LICENSEE, + appMode: this.loginAppMode, + }, + }); } else { window.location.replace(this.hostedLoginUriLicensee); } diff --git a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts index 6752fd7cc..d0e2093c9 100644 --- a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts +++ b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts @@ -17,6 +17,7 @@ import { ComputedRef, nextTick } from 'vue'; +import { RouteLocationRaw } from 'vue-router'; import { stateList, dateFormatPatterns @@ -191,6 +192,21 @@ class MfaResetStartLicensee extends mixins(MixinForm) { ); } + get isCustomAuth(): boolean { + return this.$envConfig.isCustomAuth || false; + } + + get inAppForgotPasswordLocation(): RouteLocationRaw { + return { + name: 'AuthLogin', + params: { + authType: AuthTypes.LICENSEE, + appMode: this.$appMode, + }, + query: { step: 'forgot-password' }, + }; + } + get isUsingMockApi(): boolean { return this.$envConfig.isUsingMockApi || false; } diff --git a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.vue b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.vue index 4a81edb42..600cba7fa 100644 --- a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.vue +++ b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.vue @@ -85,7 +85,15 @@ v-html="$t('account.resetAccountPasswordSubtext')" />
+ + {{ $t('account.resetAccountForgotPassword') }} + { + afterEach(() => { + mockEnvConfig.isCustomAuth = false; + mockEnvConfig.isUsingMockApi = true; + }); it('should mount the page component', async () => { const wrapper = await mountShallow(PublicDashboard); @@ -52,6 +57,7 @@ describe('PublicDashboard page', async () => { scopes: '', clientId: '', authDomain: '', + userPoolId: '', }); }); it('should use fallback idp path in app.config', async () => { @@ -373,4 +379,20 @@ describe('PublicDashboard page', async () => { expect(loginUri).to.contain('&response_type=code'); expect(loginUri).to.contain('%2Fauth%2Fcallback%2Flicensee%2Fdietitian'); }); + it('should link staff login to the in-app route when custom auth is enabled', async () => { + mockEnvConfig.isCustomAuth = true; + mockEnvConfig.isUsingMockApi = false; + + const wrapper = await mountFull(PublicDashboard); + const component = wrapper.vm; + + await component.selectCompact(CompactType.COUNSELING); + await nextTick(); + await flushPromises(); + + const link = wrapper.find('#staff-login'); + + expect(link.exists()).to.equal(true); + expect(link.attributes('href') || '').to.contain('/auth/login/staff/'); + }); }); diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.ts b/webroot/src/pages/PublicDashboard/PublicDashboard.ts index 33f59fddd..b0773b097 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.ts @@ -98,6 +98,14 @@ export default class DashboardPublic extends Vue { return this.$envConfig.isUsingMockApi || false; } + get isCustomAuth(): boolean { + return this.$envConfig.isCustomAuth || false; + } + + get authTypes(): typeof AuthTypes { + return AuthTypes; + } + get isAppTypeSelectorEnabled(): boolean { return Boolean(this.$envConfig.isAppLocal); } @@ -207,12 +215,38 @@ export default class DashboardPublic extends Vue { ); } + inAppLoginLocation(appMode: AppModes, authType: AuthTypes, step = ''): { + name: string; + params: { authType: AuthTypes; appMode: AppModes }; + query?: { step: string }; + } { + const location: { + name: string; + params: { authType: AuthTypes; appMode: AppModes }; + query?: { step: string }; + } = { + name: 'AuthLogin', + params: { authType, appMode }, + }; + + if (step) { + location.query = { step }; + } + + return location; + } + bypassToStaffLogin(appMode: AppModes, compactType?: CompactType): void { if (this.isUsingMockApi) { if (compactType) { this.setGotoCompact(compactType); } this.mockStaffLogin(appMode); + } else if (this.isCustomAuth) { + if (compactType) { + this.setGotoCompact(compactType); + } + this.$router.push(this.inAppLoginLocation(appMode, AuthTypes.STAFF)); } else { this.$store.dispatch('startLoading'); window.location.replace(this.staffLoginUri(appMode)); @@ -225,6 +259,11 @@ export default class DashboardPublic extends Vue { this.setGotoCompact(compactType); } this.mockLicenseeLogin(appMode); + } else if (this.isCustomAuth) { + if (compactType) { + this.setGotoCompact(compactType); + } + this.$router.push(this.inAppLoginLocation(appMode, AuthTypes.LICENSEE)); } else { this.$store.dispatch('startLoading'); window.location.replace(this.licenseeLoginUri(appMode)); diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.vue b/webroot/src/pages/PublicDashboard/PublicDashboard.vue index ceacbb696..b78141102 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.vue +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.vue @@ -198,8 +198,18 @@

+