From 1a266ac0e5f67687e05ac28ffd87747ebfd8665e Mon Sep 17 00:00:00 2001 From: Reuben Molinares Date: Mon, 26 Oct 2026 16:09:13 -0400 Subject: [PATCH 01/14] Initial auth screens + tests (#1989) --- .github/workflows/check-webroot.yml | 14 + webroot/.env.example | 7 + webroot/package.json | 1 + webroot/src/locales/en.json | 38 ++ webroot/src/locales/es.json | 38 ++ webroot/src/pages/AuthLogin/AuthLogin.less | 99 ++++ webroot/src/pages/AuthLogin/AuthLogin.spec.ts | 202 +++++++ webroot/src/pages/AuthLogin/AuthLogin.ts | 533 +++++++++++++++++ webroot/src/pages/AuthLogin/AuthLogin.vue | 125 ++++ .../MfaResetConfirmLicensee.ts | 12 + .../MfaResetStartLicensee.ts | 16 + .../MfaResetStartLicensee.vue | 8 + .../PublicDashboard/PublicDashboard.spec.ts | 24 + .../pages/PublicDashboard/PublicDashboard.ts | 39 ++ .../pages/PublicDashboard/PublicDashboard.vue | 29 +- .../src/plugins/EnvConfig/envConfig.plugin.ts | 15 + webroot/src/router/_guards.ts | 16 + webroot/src/router/router.spec.ts | 47 +- webroot/src/router/routes.ts | 6 + webroot/src/utils/auth.ts | 19 + webroot/src/utils/cognitoAuth.spec.ts | 545 ++++++++++++++++++ webroot/src/utils/cognitoAuth.ts | 425 ++++++++++++++ webroot/tests/mocks/mockEnvConfig.ts | 7 + webroot/yarn.lock | 7 +- 24 files changed, 2264 insertions(+), 8 deletions(-) create mode 100644 webroot/src/pages/AuthLogin/AuthLogin.less create mode 100644 webroot/src/pages/AuthLogin/AuthLogin.spec.ts create mode 100644 webroot/src/pages/AuthLogin/AuthLogin.ts create mode 100644 webroot/src/pages/AuthLogin/AuthLogin.vue create mode 100644 webroot/src/utils/cognitoAuth.spec.ts create mode 100644 webroot/src/utils/cognitoAuth.ts diff --git a/.github/workflows/check-webroot.yml b/.github/workflows/check-webroot.yml index 35fdf9565b..1670e5aca2 100644 --- a/.github/workflows/check-webroot.yml +++ b/.github/workflows/check-webroot.yml @@ -50,17 +50,24 @@ 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_RECAPTCHA_KEY: 6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih + VUE_APP_CUSTOM_AUTH: false steps: - run: echo "馃帀 The job was automatically triggered by a ${{ github.event_name }} event." @@ -127,17 +134,24 @@ 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_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/webroot/.env.example b/webroot/.env.example index 7c4a1aee8f..bcee99e98f 100644 --- a/webroot/.env.example +++ b/webroot/.env.example @@ -22,20 +22,27 @@ VUE_APP_API_USER_ROOT_PSYPACT=https://api.test.psypact.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= 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= 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= 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= 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= 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= 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/package.json b/webroot/package.json index a9491e3e5f..77fb71fed9 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", diff --git a/webroot/src/locales/en.json b/webroot/src/locales/en.json index b1fbdd5b88..93a9beb0e9 100644 --- a/webroot/src/locales/en.json +++ b/webroot/src/locales/en.json @@ -1291,5 +1291,43 @@ "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", + "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", + "mfaSetupInstructions": "Scan the QR code with your authenticator app, or enter the setup key. Then enter the 6-digit code. You have 15 minutes.", + "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." } } diff --git a/webroot/src/locales/es.json b/webroot/src/locales/es.json index 36df8e23d0..20a23ea4ff 100644 --- a/webroot/src/locales/es.json +++ b/webroot/src/locales/es.json @@ -1290,5 +1290,43 @@ "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", + "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", + "mfaSetupInstructions": "Escanee el c贸digo QR con su aplicaci贸n de autenticaci贸n, o ingrese la clave de configuraci贸n. Luego ingrese el c贸digo de 6 d铆gitos. Tiene 15 minutos.", + "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." } } diff --git a/webroot/src/pages/AuthLogin/AuthLogin.less b/webroot/src/pages/AuthLogin/AuthLogin.less new file mode 100644 index 0000000000..5a57837ac3 --- /dev/null +++ b/webroot/src/pages/AuthLogin/AuthLogin.less @@ -0,0 +1,99 @@ +// +// 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-card { + flex-direction: column; + align-items: stretch; + width: 100%; + max-width: 40rem; + margin-top: 2.4rem; + + @media @desktopWidth { + margin-top: 4.8rem; + } + } + + 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-title { + margin-bottom: 1.2rem; + font-size: @fontSizeLarge; + + &:focus { + .element-focus(); + } + } + + .auth-instructions, + .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 0000000000..86c10fc8d9 --- /dev/null +++ b/webroot/src/pages/AuthLogin/AuthLogin.spec.ts @@ -0,0 +1,202 @@ +// +// 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 } from '@utils/auth'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import cognitoAuth, { CognitoAuthStep } from '@utils/cognitoAuth'; + +describe('AuthLogin page', async () => { + const originalClientId = envConfig.cognitoClientIdStaff; + const originalAuthDomain = envConfig.cognitoAuthDomainStaff; + + afterEach(() => { + envConfig.cognitoClientIdStaff = originalClientId; + envConfig.cognitoAuthDomainStaff = originalAuthDomain; + }); + + 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.formData.email).to.not.equal(undefined); + expect(wrapper.vm.formData.password).to.not.equal(undefined); + expect(wrapper.find('img.auth-logo').attributes('alt')).to.equal(wrapper.vm.$t('common.appName')); + }); + 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 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); + }); + 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 0000000000..bef8529f8c --- /dev/null +++ b/webroot/src/pages/AuthLogin/AuthLogin.ts @@ -0,0 +1,533 @@ +// +// AuthLogin.ts +// CompactConnect +// +// Created by InspiringApps on 10/22/2026. +// + +import { + Component, + mixins, + toNative +} from 'vue-facing-decorator'; +import { reactive, computed, nextTick } from 'vue'; +import { AppModes } from '@/app.config'; +import { + authStorage, + AuthTypes, + AUTH_LOGIN_GOTO_PATH, + AUTH_LOGIN_GOTO_PATH_AUTH_TYPE, + getCognitoConfig +} from '@utils/auth'; +import cognitoAuth, { + CognitoAuthResult, + CognitoAuthStep, + formatAuthCountdown, + getTotpIssuer, + OAuthTokenResponse +} from '@utils/cognitoAuth'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import Section from '@components/Section/Section.vue'; +import Card from '@components/Card/Card.vue'; +import InputText from '@components/Forms/InputText/InputText.vue'; +import InputPassword from '@components/Forms/InputPassword/InputPassword.vue'; +import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; +import InputButton from '@components/Forms/InputButton/InputButton.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, + }, +}) +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.loginTitle'); + + 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 instructions(): string { + let instructions = ''; + + if (this.isNewPasswordStep) { + instructions = this.$t('auth.newPasswordInstructions'); + } else if (this.isMfaSetupStep) { + instructions = this.$t('auth.mfaSetupInstructions'); + } else if (this.isMfaCodeStep) { + instructions = this.$t('auth.mfaCodeInstructions'); + } else if (this.isForgotPasswordStep) { + instructions = this.$t('auth.forgotPasswordInstructions'); + } else if (this.isForgotPasswordConfirmStep) { + instructions = this.$t('auth.confirmForgotPasswordInstructions'); + } + + return instructions; + } + + 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; + } + } + } + + 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' }); + } + } +} + +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 0000000000..f8854d6fe9 --- /dev/null +++ b/webroot/src/pages/AuthLogin/AuthLogin.vue @@ -0,0 +1,125 @@ + + + + + + diff --git a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts index 51d9dcc118..38ef956c22 100644 --- a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts +++ b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts @@ -91,6 +91,10 @@ class MfaResetConfirmLicensee extends Vue { return this.$envConfig.isUsingMockApi || false; } + get isCustomAuth(): boolean { + return this.$envConfig.isCustomAuth || false; + } + // // Methods // @@ -189,6 +193,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.$appMode, + }, + }); } else { window.location.replace(this.hostedLoginUriLicensee); } diff --git a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts index f1f167c4f2..797caeff38 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 4a81edb424..600cba7fa1 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); @@ -50,6 +55,7 @@ describe('PublicDashboard page', async () => { scopes: '', clientId: '', authDomain: '', + userPoolId: '', }); }); it('should use fallback idp path in app.config', async () => { @@ -197,4 +203,22 @@ describe('PublicDashboard page', async () => { expect(loginUri).to.contain('&response_type=code'); expect(loginUri).to.contain('%2Fauth%2Fcallback%2Flicensee%2Fpsypact'); }); + 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.$store.dispatch('setAppMode', AppModes.JCC); + await nextTick(); + await flushPromises(); + + const links = wrapper.findAll('.staff-compacts .login-link'); + + expect(links.length).to.be.above(0); + links.forEach((link) => { + 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 49b23ca63d..6f8707fdd3 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.ts @@ -85,6 +85,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); } @@ -140,12 +148,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)); @@ -158,6 +192,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 f6e8094143..7fc4e17a89 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.vue +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.vue @@ -100,8 +100,15 @@
{{ $t('navigation.loginAsProviderSubtext') }}
+