diff --git a/webroot/ADD_COMPACT.md b/webroot/ADD_COMPACT.md new file mode 100644 index 0000000000..8e0214fdad --- /dev/null +++ b/webroot/ADD_COMPACT.md @@ -0,0 +1,158 @@ +# Adding a new compact (new AppMode) + +## Prerequisites + +- Backend API hosts and staff Cognito app exist (or are being added in parallel) +- Cognito callback URL(s) will be registered for the new staff auth path +- Choose an existing `AppGroupMode`: + - `PRIVILEGE_PURCHASE` (JCC-style) + - `MULTI_STATE` (cosmetology / social work–style) +- One compact maps to one `AppModes` value +- Staff Cognito is per AppMode; licensee Cognito is shared unless product requirements change + +## Naming + +| Concept | Example | Notes | +|---------|---------|--------| +| `CompactType` value | `foo` | API / locale key (`compacts[].key`, license `compactKey`) | +| `AppModes` value | `foo` | **Must** equal the auth callback path segment | + +Auth callback path is built as: + +`/auth/callback/staff/{AppModes value}` + +Example: `AppModes.FOO = 'foo'` → `/auth/callback/staff/foo` + +## Already automatic + +Once the config and infra wiring below are in place, these do **not** need per-compact UI lists or interceptor edits: + +- PublicDashboard staff login cards (`$compactsEnabled`) +- CompactSelector (public + permission-based options) +- Logout, token refresh, and token revoke (`getCognitoConfig`) +- Auth callback path string (`getAuthCallbackPath`) +- Network request base URLs (`getApiBaseUrl` + API interceptors) +- `setAppMode` → `appGroupMode` (`getAppGroupModeForAppMode`) +- Router compact param → app mode (`getAppModeForCompact`) + +## Steps + +### 1. Core enums and compact config + +**`src/app.config.ts`** + +- [ ] Add `AppModes.YOUR_MODE = 'yoursegment'` + +**`src/utils/compactConfig.ts`** + +- [ ] Add `CompactType.YOUR_COMPACT = 'abbr'` +- [ ] Add `compactSetups` entry (`type`, `appMode`, `isEnabled`) +- [ ] Add `appModeGroups[AppModes.YOUR_MODE]` → existing `PRIVILEGE_PURCHASE` or `MULTI_STATE` +- [ ] Add `appModeEncumberConfigs[AppModes.YOUR_MODE]` (license + privilege discipline / NPDB lists; reuse shared helpers when possible) + +### 2. Environment + +**`.env` / `.env.example`** + +- [ ] Four API roots for the new mode: state, license, search, user +- [ ] Staff Cognito auth domain + client id + +**`src/plugins/EnvConfig/envConfig.plugin.ts`** + +- [ ] Add fields on `EnvConfig` +- [ ] Map them from `VUE_APP_*` keys + +**`tests/mocks/mockEnvConfig.ts`** + +- [ ] Add matching mock fields + +### 3. Wire mode → infra tables + +**`src/network/apiUrls.ts`** + +- [ ] Add a row to `appModeApiUrls` for all four API families (`state`, `license`, `search`, `user`) + (`Record` will fail to compile until this is done.) + +**`src/utils/auth.ts` → `getCognitoConfig`** + +- [ ] Add a staff branch for the new `AppModes` that reads the new env Cognito fields + +### 4. Auth callback route and page + +**`src/router/routes.ts`** + +- [ ] Add route `/auth/callback/staff/{yoursegment}` + Path must equal `getAuthCallbackPath(AppModes.YOUR_MODE, AuthTypes.STAFF)` + +**`src/pages/AuthCallback/StaffYourMode/`** + +- [ ] Add a thin page (copy `StaffCosmo` / `StaffSocialWork` pattern) +- [ ] Set `appMode = AppModes.YOUR_MODE` and `authType = AuthTypes.STAFF` only +- [ ] Add a mount spec (optional; matches existing AuthCallback pages) + +**`src/router/router.spec.ts`** + +- [ ] Add `{ name, path: getAuthCallbackPath(...) }` for the new staff callback route + +### 5. Store and Compacts plugin flags + +**`src/store/global/global.getters.ts`** + +- [ ] Add `isAppModeYourMode: (state) => state.appMode === AppModes.YOUR_MODE` + +**`src/plugins/Compacts/compacts.plugin.ts`** + +- [ ] Add `'isAppModeYourMode'` to `appModeFlags` + +**`src/plugins/Compacts/compacts.d.ts`** + +- [ ] Declare `$isAppModeYourMode: boolean` + +### 6. i18n / product copy + +**`src/locales/en.json` and `src/locales/es.json`** + +- [ ] Add `compacts[]` entry (`key` = `CompactType` value, `name`, `abbrev`) +- [ ] Add `licensing.licenseTypes` entries with matching `compactKey` as needed + +### 7. Mock data + +**`src/network/mocks/mock.data.ts`** + +Needed when exercising the new compact under the mock API: + +- [ ] Add a `staffAccount.permissions` entry keyed by the new `CompactType` value (mirror `aslp` / `cosm` / `socw`) +- [ ] Add the same key on any other mock staff permission blobs in this file that list every compact +- [ ] If the compact **allows licensee registration**, add it to `compactStatesForRegistration` + (Cosmetology and social work are omitted there on purpose because they do not allow registration.) +- [ ] Add or extend licensee / search fixtures only if you need mock flows for that compact (many fixtures stay on `octp` by default) + +### 8. Optional / situational UI + +Only if the new compact should participate in these flows: + +**`src/pages/PublicDashboard/PublicDashboard.ts` → `bypassRedirect`** + +- [ ] Add a `?bypass=login-staff-…` case if emails or deep links need it (see cosmo / social work) + +**`RegisterLicensee` / `MfaResetStartLicensee`** + +- [ ] These still use hard-coded compact allow-lists — add the new `CompactType` only if those pages should offer it + +**Mode-specific UI audit** + +Decide whether behavior should follow JCC-like or multi-state-like patterns. Prefer `$isAppGroupMode*` when the behavior is really group-scoped. Audit existing `$isAppModeJcc` / `$isAppModeCosmetology` / `$isAppModeSocialWork` usages, for example: + +- LicenseCard / PrivilegeCard +- LicensingDetail (e.g. military affiliation) +- LicenseeSearchLegacy +- UserInvite / UserRowEdit + +### 9. Tests to extend + +- [ ] `src/utils/compactConfig.spec.ts` — setup, app group, encumbrance, enablement gating +- [ ] `src/network/apiUrls.spec.ts` — all four families for the new mode +- [ ] `src/plugins/Compacts/compacts.spec.ts` — list membership / globals if asserted +- [ ] `src/pages/PublicDashboard/PublicDashboard.spec.ts` — staff login URI for the new mode (optional) +- [ ] LicenseCard / PrivilegeCard encumber specs if per-mode assertions are kept there +- [ ] AuthCallback mount + router path consistency diff --git a/webroot/src/app.config.ts b/webroot/src/app.config.ts index 7351434031..ba3ac3be51 100644 --- a/webroot/src/app.config.ts +++ b/webroot/src/app.config.ts @@ -11,7 +11,7 @@ export enum AppModes { JCC = 'jcc', COSMETOLOGY = 'cosmo', - SOCIAL_WORK = 'social-work', + SOCIAL_WORK = 'socialwork', } export enum AppGroupModes { @@ -206,182 +206,6 @@ export const stateList = [ 'VI' ]; -// ============================= -// = Compact configuration = -// ============================= -export const compacts = { - aslp: {}, - octp: {}, - coun: {}, - cosm: {}, -}; - -export const getEncumberConfigLicense = (appMode: AppModes) => { - let disciplineTypes: Array = []; - let npdbTypes: Array = []; - - switch (appMode) { - case AppModes.JCC: - disciplineTypes = [ - 'fine', - 'reprimand', - 'required supervision', - 'completion of continuing education', - 'public reprimand', - 'probation', - 'injunctive action', - 'suspension', - 'revocation', - 'denial', - 'surrender of license', - 'modification of previous action-extension', - 'modification of previous action-reduction', - 'other monitoring', - 'other adjudicated action not listed', - ]; - npdbTypes = [ - 'Non-compliance With Requirements', - 'Criminal Conviction or Adjudication', - 'Confidentiality, Consent or Disclosure Violations', - 'Misconduct or Abuse', - 'Fraud, Deception, or Misrepresentation', - 'Unsafe Practice or Substandard Care', - 'Improper Supervision or Allowing Unlicensed Practice', - 'Other', - ]; - break; - case AppModes.COSMETOLOGY: - disciplineTypes = [ - 'suspension', - 'revocation', - 'surrender of license', - ]; - npdbTypes = [ - 'fraud', - 'consumer harm', - 'other', - ]; - break; - case AppModes.SOCIAL_WORK: - disciplineTypes = [ - 'fine', - 'reprimand', - 'required supervision', - 'completion of continuing education', - 'public reprimand', - 'probation', - 'injunctive action', - 'suspension', - 'revocation', - 'denial', - 'surrender of license', - 'modification of previous action-extension', - 'modification of previous action-reduction', - 'other monitoring', - 'other adjudicated action not listed', - ]; - npdbTypes = [ - 'Non-compliance With Requirements', - 'Conflict of Interest', - 'Substandard Care or Patient Neglect/Abuse', - 'Criminal Conviction or Adjudication', - 'Confidentiality, Consent or Disclosure Violations', - 'Fraud, Deception, or Misrepresentation', - 'Improper Supervision or Allowing Unlicensed Practice', - 'Improper Prescribing, Dispensing, Administering Medication/Drug Violation', - 'Other', - ]; - break; - default: - break; - } - - return { disciplineTypes, npdbTypes }; -}; - -export const getEncumberConfigPrivilege = (appMode: AppModes) => { - let disciplineTypes: Array = []; - let npdbTypes: Array = []; - - switch (appMode) { - case AppModes.JCC: - disciplineTypes = [ - 'fine', - 'reprimand', - 'required supervision', - 'completion of continuing education', - 'public reprimand', - 'probation', - 'injunctive action', - 'suspension', - 'revocation', - 'denial', - 'surrender of privilege', - 'modification of previous action-extension', - 'modification of previous action-reduction', - 'other monitoring', - 'other adjudicated action not listed', - ]; - npdbTypes = [ - 'Non-compliance With Requirements', - 'Criminal Conviction or Adjudication', - 'Confidentiality, Consent or Disclosure Violations', - 'Misconduct or Abuse', - 'Fraud, Deception, or Misrepresentation', - 'Unsafe Practice or Substandard Care', - 'Improper Supervision or Allowing Unlicensed Practice', - 'Other', - ]; - break; - case AppModes.COSMETOLOGY: - disciplineTypes = [ - 'suspension', - 'revocation', - 'surrender of privilege', - ]; - npdbTypes = [ - 'fraud', - 'consumer harm', - 'other', - ]; - break; - case AppModes.SOCIAL_WORK: - disciplineTypes = [ - 'fine', - 'reprimand', - 'required supervision', - 'completion of continuing education', - 'public reprimand', - 'probation', - 'injunctive action', - 'suspension', - 'revocation', - 'denial', - 'surrender of privilege', - 'modification of previous action-extension', - 'modification of previous action-reduction', - 'other monitoring', - 'other adjudicated action not listed', - ]; - npdbTypes = [ - 'Non-compliance With Requirements', - 'Conflict of Interest', - 'Substandard Care or Patient Neglect/Abuse', - 'Criminal Conviction or Adjudication', - 'Confidentiality, Consent or Disclosure Violations', - 'Fraud, Deception, or Misrepresentation', - 'Improper Supervision or Allowing Unlicensed Practice', - 'Improper Prescribing, Dispensing, Administering Medication/Drug Violation', - 'Other', - ]; - break; - default: - break; - } - - return { disciplineTypes, npdbTypes }; -}; - // ============================= // = Feature gate IDs = // ============================= @@ -403,5 +227,4 @@ export default { relativeTimeFormats, UploadFileType, uploadTypes, - compacts, }; diff --git a/webroot/src/components/App/App.ts b/webroot/src/components/App/App.ts index 6589508fb2..91efda4e8a 100644 --- a/webroot/src/components/App/App.ts +++ b/webroot/src/components/App/App.ts @@ -12,7 +12,8 @@ import { toNative } from 'vue-facing-decorator'; import { RouteRecordName } from 'vue-router'; -import { AppModes, relativeTimeFormats } from '@/app.config'; +import { relativeTimeFormats } from '@/app.config'; +import { getAppModeForCompact } from '@utils/compactConfig'; import { authStorage, AuthTypes, @@ -113,18 +114,10 @@ class App extends Vue { } setAppModeFromCompact(compact: CompactType | null): void { - let { appMode } = this.globalStore; + const { appMode } = this.globalStore; if (!appMode) { - if (compact === CompactType.COSMETOLOGY) { - appMode = AppModes.COSMETOLOGY; - } else if (compact === CompactType.SOCIAL_WORK) { - appMode = AppModes.SOCIAL_WORK; - } else { - appMode = AppModes.JCC; - } - - this.$store.dispatch('setAppMode', appMode); + this.$store.dispatch('setAppMode', getAppModeForCompact(compact)); } } diff --git a/webroot/src/components/CompactSelector/CompactSelector.spec.ts b/webroot/src/components/CompactSelector/CompactSelector.spec.ts index 5986246e84..c0fafa26f3 100644 --- a/webroot/src/components/CompactSelector/CompactSelector.spec.ts +++ b/webroot/src/components/CompactSelector/CompactSelector.spec.ts @@ -5,15 +5,85 @@ // Created by InspiringApps on 10/2/2024. // -import { expect } from 'chai'; +import chaiMatchPattern from 'chai-match-pattern'; +import chai from 'chai'; import { mountShallow } from '@tests/helpers/setup'; import CompactSelector from '@components/CompactSelector/CompactSelector.vue'; +import { Compact, CompactType } from '@models/Compact/Compact.model'; +import { MutationTypes } from '@store/user/user.mutations'; +import { StaffUser, CompactPermission } from '@models/StaffUser/StaffUser.model'; +import store from '@store/index'; + +chai.use(chaiMatchPattern); + +const { expect } = chai; + +const buildCompactPermission = (compactType: CompactType | string): CompactPermission => ({ + compact: new Compact({ type: compactType as CompactType }), + isReadPrivate: true, + isReadSsn: false, + isAdmin: false, + states: [], +}); + +// Seed permissions only — omit currentCompact so permission-based init() does not call +// initFormInputs() (which needs $t and can race other MixinForm instances on the shared store). +const seedPermissionBasedUser = (compactTypes: Array) => { + store.commit( + `user/${MutationTypes.STORE_UPDATE_USER}`, + new StaffUser({ + permissions: compactTypes.map((compactType) => buildCompactPermission(compactType)), + }) + ); +}; + +const resetUserStore = () => { + store.commit(`user/${MutationTypes.STORE_RESET_USER}`); +}; describe('CompactSelector component', async () => { + beforeEach(() => { + resetUserStore(); + }); + + afterEach(() => { + resetUserStore(); + }); + it('should mount the component', async () => { const wrapper = await mountShallow(CompactSelector); expect(wrapper.exists()).to.equal(true); expect(wrapper.findComponent(CompactSelector).exists()).to.equal(true); }); + it('should successfully include only enabled compacts in permission-based options', async () => { + seedPermissionBasedUser([ + CompactType.ASLP, + CompactType.COUNSELING, + 'unknown-compact', + ]); + + const wrapper = await mountShallow(CompactSelector, { + props: { isPermissionBased: true }, + }); + const component = wrapper.vm; + const enabledTypes = component.$compactsEnabled.map((compact) => compact.type); + const optionValues = component.compactOptions.map((option) => option.value); + + expect(optionValues).to.include(CompactType.ASLP); + expect(optionValues).to.include(CompactType.COUNSELING); + expect(optionValues).to.not.include('unknown-compact'); + optionValues.forEach((optionValue) => { + expect(enabledTypes).to.include(optionValue); + }); + }); + it('should successfully omit unknown compact types from permission-based options', async () => { + seedPermissionBasedUser(['unknown-compact']); + + const wrapper = await mountShallow(CompactSelector, { + props: { isPermissionBased: true }, + }); + + expect(wrapper.vm.compactOptions).to.matchPattern([]); + }); }); diff --git a/webroot/src/components/CompactSelector/CompactSelector.ts b/webroot/src/components/CompactSelector/CompactSelector.ts index 8db49ff50f..a91ee9a8af 100644 --- a/webroot/src/components/CompactSelector/CompactSelector.ts +++ b/webroot/src/components/CompactSelector/CompactSelector.ts @@ -5,7 +5,6 @@ // Created by InspiringApps on 10/2/2024. // -import { compacts as compactsConfig } from '@/app.config'; import { Component, mixins, @@ -62,26 +61,26 @@ class CompactSelector extends mixins(MixinForm) { return this.user?.permissions || []; } - get allCompacts(): Array { - const compactTypes = Object.keys(compactsConfig) as Array; - const compacts = compactTypes.map((compactType) => new Compact({ type: compactType })); - - return compacts; - } - get compactOptions(): Array { - const options: Array = []; + let options: Array = []; if (this.isPermissionBased) { - this.userPermissions.forEach((permission: CompactPermission) => { - const { compact } = permission; - - options.push({ value: (compact.type as unknown as string), name: compact.name() }); - }); + options = this.userPermissions + .filter((permission: CompactPermission) => { + const compactType = permission.compact.type as CompactType | null | undefined; + + return Boolean(compactType) + && this.$compactsEnabled.some((enabledCompact) => enabledCompact.type === compactType); + }) + .map((permission: CompactPermission) => ({ + value: (permission.compact.type as unknown as string), + name: permission.compact.name(), + })); } else { - this.allCompacts.forEach((compact) => { - options.push({ value: (compact.type as unknown as string), name: compact.name() }); - }); + options = this.$compactsEnabled.map((compact) => ({ + value: compact.type, + name: compact.name, + })); } return options; diff --git a/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.ts b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.ts index 71db3902b7..7bf68efbc4 100644 --- a/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.ts +++ b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.ts @@ -77,14 +77,6 @@ class CompactSettingsConfig extends mixins(MixinForm) { return this.$store.state.user; } - get isAppGroupModePrivilegePurchase(): boolean { - return this.$store.getters.isAppGroupModePrivilegePurchase; - } - - get isAppGroupModeMultiState(): boolean { - return this.$store.getters.isAppGroupModeMultiState; - } - get compactType(): CompactType | null { return this.userStore.currentCompact?.type; } @@ -94,7 +86,7 @@ class CompactSettingsConfig extends mixins(MixinForm) { } get liveStatusLabel(): string { - return (this.isAppGroupModeMultiState) + return (this.$isAppGroupModeMultiState) ? this.$t('compact.licenseRegistrationEnabledSubtextMultiState') : this.$t('compact.licenseRegistrationEnabledSubtext'); } @@ -145,14 +137,14 @@ class CompactSettingsConfig extends mixins(MixinForm) { } initFormInputs(): void { - const { isAppGroupModePrivilegePurchase } = this; + const { $isAppGroupModePrivilegePurchase } = this; this.formData = reactive({ compactFee: new FormInput({ id: 'compact-fee', name: 'compact-fee', label: computed(() => this.$t('compact.compactFee')), - validation: (isAppGroupModePrivilegePurchase) + validation: ($isAppGroupModePrivilegePurchase) ? Joi.number().required().min(0).messages(this.joiMessages.currency) : Joi.any(), value: this.initialCompactConfig?.compactCommissionFee?.feeAmount, @@ -188,7 +180,7 @@ class CompactSettingsConfig extends mixins(MixinForm) { label: computed(() => this.$t('compact.summaryReportEmails')), labelSubtext: computed(() => this.$t('compact.summaryReportEmailsSubtext')), placeholder: computed(() => this.$t('compact.addEmails')), - validation: Joi.array().min(isAppGroupModePrivilegePurchase ? 1 : 0).messages(this.joiMessages.array), + validation: Joi.array().min($isAppGroupModePrivilegePurchase ? 1 : 0).messages(this.joiMessages.array), value: this.initialCompactConfig?.compactSummaryReportNotificationEmails || [], }), isRegistrationEnabled: new FormInput({ @@ -267,7 +259,7 @@ class CompactSettingsConfig extends mixins(MixinForm) { }; // Per compact config fields - if (this.isAppGroupModePrivilegePurchase) { + if (this.$isAppGroupModePrivilegePurchase) { payload.compactCommissionFee = { feeType: FeeType.FLAT_RATE, feeAmount: Number(compactFee), @@ -356,7 +348,7 @@ class CompactSettingsConfig extends mixins(MixinForm) { this.populateFormInput(this.formData.isRegistrationEnabled, true); // Per compact configs - if (this.isAppGroupModePrivilegePurchase) { + if (this.$isAppGroupModePrivilegePurchase) { this.populateFormInput(this.formData.compactFee, 5.55); this.populateFormInput(this.formData.creditCardTransactionFee, 5); this.populateFormInput(this.formData.summaryReportNotificationEmails, ['summary@example.com']); diff --git a/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.vue b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.vue index 0a4f3c606a..f397f4a13c 100644 --- a/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.vue +++ b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.vue @@ -15,19 +15,19 @@
-

+

{{ $t('compact.privilegeFees') }}

- +
-
+
+ {{ $t('licensing.category') }}: + {{ item.licenseTypeName() }} + +
+
{ - const options = this.$tm('compacts').map((compact) => ({ - value: compact.key, + const options: Array<{ value: string, name: string | ComputedRef }> = this.$compactsEnabled.map((compact) => ({ + value: compact.type, name: compact.name, })); @@ -381,13 +373,13 @@ class LicenseeSearch extends mixins(MixinForm) { ]; // Per compact search props - if (this.isAppGroupModePrivilegePurchase) { + if (this.$isAppGroupModePrivilegePurchase) { allowedSearchProps.push('privilegeState'); allowedSearchProps.push('privilegePurchaseStartDate'); allowedSearchProps.push('privilegePurchaseEndDate'); allowedSearchProps.push('militaryStatus'); allowedSearchProps.push('npi'); - } else if (this.isAppGroupModeMultiState) { + } else if (this.$isAppGroupModeMultiState) { allowedSearchProps.push('licenseNumber'); allowedSearchProps.push('dob'); } @@ -427,14 +419,14 @@ class LicenseeSearch extends mixins(MixinForm) { this.formData.encumberStartDate.value = moment().startOf('month').format('YYYY-MM-DD'); this.formData.encumberEndDate.value = moment().endOf('month').format('YYYY-MM-DD'); - if (this.isAppGroupModePrivilegePurchase) { + if (this.$isAppGroupModePrivilegePurchase) { this.formData.privilegeState.value = 'co'; this.formData.privilegePurchaseStartDate.value = moment().startOf('month').format('YYYY-MM-DD'); this.formData.privilegePurchaseEndDate.value = moment().endOf('month').format('YYYY-MM-DD'); this.formData.militaryStatus.value = 'approved'; this.formData.investigationStatus.value = 'underInvestigation'; this.formData.npi.value = 'ABC123'; - } else if (this.isAppGroupModeMultiState) { + } else if (this.$isAppGroupModeMultiState) { this.formData.licenseNumber.value = 'ABC123'; this.formData.dob.value = moment('1970-01-01').format('YYYY-MM-DD'); } diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue index 1875e58cdb..46e8c7f805 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue @@ -34,7 +34,7 @@ @input="updateCurrentCompact" />
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
{ - let options = this.$tm('compacts').map((compact) => ({ - value: compact.key, + const options: Array = this.$compactsEnabled.map((compact) => ({ + value: compact.type, name: compact.name, })); - if (this.$envConfig.isAppProduction) { // @NOTE: Hide compacts that have no Prod infra - options = options.filter((option) => !['cosm', 'socw'].includes(option.value)); - } - options.unshift({ value: '', name: computed(() => this.$t('common.selectOption')), @@ -120,6 +112,22 @@ class LicenseeSearch extends mixins(MixinForm) { return compactMemberStates; } + get licenseTypeOptions(): Array<{ value: string, name: string | ComputedRef }> { + const { compactType } = this; + const compactLicenseTypes = this.$tm('licensing.licenseTypes') + ?.filter((licenseType) => licenseType.compactKey === compactType) + .map((licenseType) => ({ value: licenseType.key, name: licenseType.name })) || []; + const defaultSelectOption: { value: string, name: string | ComputedRef } = { value: '', name: '' }; + + if (compactLicenseTypes.length) { + defaultSelectOption.name = computed(() => this.$t('common.selectOption')); + } + + compactLicenseTypes.unshift(defaultSelectOption); + + return compactLicenseTypes; + } + get isMockPopulateEnabled(): boolean { return Boolean(this.$envConfig.isDevelopment); } @@ -164,6 +172,29 @@ class LicenseeSearch extends mixins(MixinForm) { value: this.searchParams.licenseNumber || '', enforceMax: true, }), + licenseType: new FormInput({ + id: 'license-type', + name: 'license-type', + label: computed(() => ((this.compactType === CompactType.SOCIAL_WORK) + ? this.$t('licensing.category') + : this.$t('licensing.licenseType'))), + valueOptions: this.licenseTypeOptions, + value: this.searchParams.licenseType || '', + }), + cuid: new FormInput({ + id: 'cuid', + name: 'cuid', + label: computed(() => this.$t('licensing.cuid')), + labelInfo: computed(() => this.$t('licensing.cuidSearchMatch')), + placeholder: 'SWC-9999-99...', + validation: Joi.string() + .min(0) + .pattern(/^[Ss][Ww][Cc]-[0-9]{4}-[1-9][0-9]*$/) + .allow('') + .messages(this.joiMessages.cuid), + value: this.searchParams.cuid || '', + enforceMax: true, + }), submit: new FormInput({ isSubmitInput: true, id: 'submit', @@ -211,9 +242,13 @@ class LicenseeSearch extends mixins(MixinForm) { ]; // Per compact search props - if (this.isAppGroupModeMultiState) { + if (this.$isAppGroupModeMultiState) { allowedSearchProps.push('licenseNumber'); } + if (this.$isAppModeSocialWork) { + allowedSearchProps.push('licenseType'); + allowedSearchProps.push('cuid'); + } allowedSearchProps.forEach((searchProp) => { searchProps[searchProp] = this.formValues[searchProp]; }); this.$emit('searchParams', searchProps); @@ -227,7 +262,7 @@ class LicenseeSearch extends mixins(MixinForm) { const { firstName, lastName } = this.formData; const shouldSkip = (asTouched) ? false : !lastName.isTouched; - if (this.isAppModeJcc) { // Currently only JCC requires this check + if (this.$isAppModeJcc) { // Currently only JCC requires this check if (!shouldSkip && firstName.value && !lastName.value) { lastName.isValid = false; lastName.errorMessage = this.$t('inputErrors.lastNameRequired'); @@ -250,6 +285,8 @@ class LicenseeSearch extends mixins(MixinForm) { this.formData.lastName.value = ''; this.formData.state.value = ''; this.formData.licenseNumber.value = ''; + this.formData.licenseType.value = ''; + this.formData.cuid.value = ''; this.isFormLoading = false; this.isFormSuccessful = false; this.isFormError = false; @@ -258,8 +295,8 @@ class LicenseeSearch extends mixins(MixinForm) { } async mockPopulate(): Promise { - if (this.enableCompactSelect) { - this.formData.compact.value = (this.isAppModeJcc) + if (this.enableCompactSelect && !this.formData.compact.value) { + this.formData.compact.value = (this.$isAppModeJcc) ? CompactType.OT : this.formData.compact.valueOptions[1]; @@ -270,9 +307,13 @@ class LicenseeSearch extends mixins(MixinForm) { this.formData.lastName.value = 'User'; this.formData.state.value = 'co'; - if (this.isAppGroupModeMultiState) { + if (this.$isAppGroupModeMultiState) { this.formData.licenseNumber.value = 'ABC123'; } + if (this.$isAppModeSocialWork) { + this.formData.licenseType.value = 'licensed clinical social worker'; + this.formData.cuid.value = 'SwC-8879-1510662364862837507201851701209841388880384284903247330'; + } this.validateAll({ asTouched: true }); await nextTick(); @@ -284,6 +325,10 @@ class LicenseeSearch extends mixins(MixinForm) { // // Watch // + @Watch('compactType') updateCompactInputs() { + this.formData.licenseType.valueOptions = this.licenseTypeOptions; + } + @Watch('compactStates') updateStateInput() { this.formData.state.valueOptions = this.stateOptions; } diff --git a/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue index 45fcce6318..b8cfdbe17f 100644 --- a/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue +++ b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue @@ -56,12 +56,24 @@ @blur="customValidateLastName(true)" />
-
+
+
+ +
+
+ +
{ it('should mount the component', async () => { diff --git a/webroot/src/components/PrivilegeCard/PrivilegeCard.ts b/webroot/src/components/PrivilegeCard/PrivilegeCard.ts index d1d12b6f53..e9d4f98dc0 100644 --- a/webroot/src/components/PrivilegeCard/PrivilegeCard.ts +++ b/webroot/src/components/PrivilegeCard/PrivilegeCard.ts @@ -16,7 +16,8 @@ import { ComputedRef, nextTick } from 'vue'; -import { dateFormatPatterns, AppModes, getEncumberConfigPrivilege } from '@/app.config'; +import { dateFormatPatterns } from '@/app.config'; +import { getEncumberConfigPrivilege } from '@utils/compactConfig'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import InputTextarea from '@components/Forms/InputTextarea/InputTextarea.vue'; import InputDate from '@components/Forms/InputDate/InputDate.vue'; @@ -87,26 +88,6 @@ class PrivilegeCard extends mixins(MixinForm) { return this.$store.state.user; } - get appMode(): AppModes { - return this.$store.state.appMode; - } - - get isAppModeJcc(): boolean { - return this.$store.getters.isAppModeJcc; - } - - get isAppModeCosmetology(): boolean { - return this.$store.getters.isAppModeCosmetology; - } - - get isAppModeSocialWork(): boolean { - return this.$store.getters.isAppModeSocialWork; - } - - get isAppGroupModePrivilegePurchase(): boolean { - return this.$store.getters.isAppGroupModePrivilegePurchase; - } - get currentUser(): StaffUser { return this.userStore.model; } @@ -231,8 +212,8 @@ class PrivilegeCard extends mixins(MixinForm) { } get shouldShowDiscipline(): boolean { - return this.isAppGroupModePrivilegePurchase // JCC compacts public & staff - || this.isAppModeSocialWork // Social Work compact public & staff + return this.$isAppGroupModePrivilegePurchase // JCC compacts public & staff + || this.$isAppModeSocialWork // Social Work compact public & staff || this.isCurrentUserPrivilegeAdmin; // Any compact if staff user is admin of that state } @@ -245,7 +226,7 @@ class PrivilegeCard extends mixins(MixinForm) { } get encumberDisciplineOptions(): Array<{ value: string, name: string | ComputedRef }> { - const includeList: Array = getEncumberConfigPrivilege(this.appMode).disciplineTypes; + const includeList: Array = getEncumberConfigPrivilege(this.$appMode).disciplineTypes; const options = this.$tm('licensing.disciplineTypes').map((disciplineType) => ({ value: disciplineType.key, name: disciplineType.name, @@ -261,7 +242,7 @@ class PrivilegeCard extends mixins(MixinForm) { } get npdbCategoryOptions(): Array<{ value: string, name: string | ComputedRef }> { - const includeList: Array = getEncumberConfigPrivilege(this.appMode).npdbTypes; + const includeList: Array = getEncumberConfigPrivilege(this.$appMode).npdbTypes; const options = this.$tm('licensing.npdbTypes').map((npdbType) => ({ value: npdbType.key, name: npdbType.name, @@ -276,7 +257,7 @@ class PrivilegeCard extends mixins(MixinForm) { } get shouldAllowNpdbMultiSelect(): boolean { - return this.isAppModeJcc || this.isAppModeSocialWork; + return this.$isAppModeJcc || this.$isAppModeSocialWork; } get endInvestigationModalTitle(): string { diff --git a/webroot/src/components/PrivilegeCard/PrivilegeCard.vue b/webroot/src/components/PrivilegeCard/PrivilegeCard.vue index ac7790249f..6e65311e44 100644 --- a/webroot/src/components/PrivilegeCard/PrivilegeCard.vue +++ b/webroot/src/components/PrivilegeCard/PrivilegeCard.vue @@ -88,11 +88,11 @@
- +
-
+
{{ $t('licensing.activeFrom') }}
{{ (isActive) ? activeFromContent : $t('licensing.deactivated') }}
@@ -100,20 +100,17 @@
{{expiresTitle}}
{{expiresContent}}
-
+
{{$t('licensing.privilegeNumSymbol')}}
{{privilegeId}}
-
+
{{ $t('licensing.disciplineStatus') }}
{{disciplineContent}}
-
+
{{ $t('licensing.privilegeId') }}
{{ privilegeId }}
@@ -283,7 +280,7 @@

{{ $t('licensing.confirmPrivilegeEncumberSuccess') }}

{{ licenseeName }}
-
{{ privilegeId }}
+
{{ privilegeId }}
-
+
{{ $t('licensing.privilegeId') }}
{{ privilegeId }}
@@ -499,7 +496,9 @@

{{ $t('licensing.confirmPrivilegeInvestigationStartSuccess') }}

{{ licenseeName }}
-
{{ privilegeId }}
+
+ {{ privilegeId }} +
{{ $t('licensing.confirmPrivilegeInvestigationEndSuccess') }}
{{ licenseeName }}
-
{{ privilegeId }}
+
{{ privilegeId }}
this.$t('compact.jurisprudenceExamRequired')), - validation: (isAppGroupModePrivilegePurchase) + validation: ($isAppGroupModePrivilegePurchase) ? Joi.boolean().required().messages(this.joiMessages.boolean) : Joi.any(), valueOptions: [ @@ -197,7 +189,7 @@ class StateSettingsConfig extends mixins(MixinForm) { label: computed(() => this.$t('compact.summaryReportEmails')), labelSubtext: computed(() => this.$t('compact.summaryReportEmailsSubtext')), placeholder: computed(() => this.$t('compact.addEmails')), - validation: Joi.array().min(isAppGroupModePrivilegePurchase ? 1 : 0).messages(this.joiMessages.array), + validation: Joi.array().min($isAppGroupModePrivilegePurchase ? 1 : 0).messages(this.joiMessages.array), value: this.initialStateConfig?.jurisdictionSummaryReportNotificationEmails || [], }), isPurchaseEnabled: new FormInput({ @@ -324,7 +316,7 @@ class StateSettingsConfig extends mixins(MixinForm) { }; // Per compact config fields - if (this.isAppGroupModePrivilegePurchase) { + if (this.$isAppGroupModePrivilegePurchase) { payload.privilegeFees = feeInputsCore.map((feeInputCore) => { // Map indeterminate set of privilege fee inputs to their payload structure const [ licenseType ] = feeInputCore.id.split('-'); @@ -413,7 +405,7 @@ class StateSettingsConfig extends mixins(MixinForm) { this.populateFormInput(this.formData.isPurchaseEnabled, true); // Per compact state configs - if (this.isAppGroupModePrivilegePurchase) { + if (this.$isAppGroupModePrivilegePurchase) { this.feeInputs.forEach((feeInput) => { this.populateFormInput(feeInput, 5); }); diff --git a/webroot/src/components/StateSettingsConfig/StateSettingsConfig.vue b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.vue index f3662fa06d..dfffd2e409 100644 --- a/webroot/src/components/StateSettingsConfig/StateSettingsConfig.vue +++ b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.vue @@ -15,11 +15,11 @@
-

+

{{ $t('compact.privilegeFees') }}

-
-
+
diff --git a/webroot/src/pages/Logout/Logout.spec.ts b/webroot/src/pages/Logout/Logout.spec.ts index d3a030298d..b338bc05ca 100644 --- a/webroot/src/pages/Logout/Logout.spec.ts +++ b/webroot/src/pages/Logout/Logout.spec.ts @@ -98,8 +98,8 @@ describe('Logout page', async () => { expect(logEventStub.firstCall.args[1]).to.equal(1); expect(logEventStub.firstCall.args[2]).to.matchPattern({ authType: AuthTypes.STAFF, - appMode: component.appMode, - appGroupMode: component.appGroupMode, + appMode: component.$appMode, + appGroupMode: component.$appGroupMode, errorName: 'Error', errorCode: undefined, httpStatus: undefined, diff --git a/webroot/src/pages/Logout/Logout.ts b/webroot/src/pages/Logout/Logout.ts index faff91725c..34a52bd060 100644 --- a/webroot/src/pages/Logout/Logout.ts +++ b/webroot/src/pages/Logout/Logout.ts @@ -6,7 +6,6 @@ // import { Component, Vue } from 'vue-facing-decorator'; -import { AppModes } from '@/app.config'; import { authStorage, tokens, @@ -14,6 +13,7 @@ import { AUTH_TYPE, AUTH_LOGIN_GOTO_PATH, AUTH_LOGIN_GOTO_PATH_AUTH_TYPE, + getCognitoConfig, revokeCognitoRefreshToken } from '@utils/auth'; import LoadingSpinner from '@components/LoadingSpinner/LoadingSpinner.vue'; @@ -35,14 +35,6 @@ export default class Logout extends Vue { // // Computed // - get appMode(): AppModes { - return this.$store.state.appMode; - } - - get appGroupMode() { - return this.$store.state.appGroupMode; - } - get userStore() { return this.$store.state.user; } @@ -52,35 +44,15 @@ export default class Logout extends Vue { } get hostedLogoutUriStaff(): string { - const { - domain, - cognitoAuthDomainStaff, - cognitoClientIdStaff, - cognitoAuthDomainStaffCosmo, - cognitoClientIdStaffCosmo, - cognitoAuthDomainStaffSw, - cognitoClientIdStaffSw - } = this.$envConfig; - let cognitoAuthDomain = cognitoAuthDomainStaff; - let cognitoClientId = cognitoClientIdStaff; - - // Adjust cognito params based on app mode - if (this.appMode === AppModes.COSMETOLOGY) { - cognitoAuthDomain = cognitoAuthDomainStaffCosmo; - cognitoClientId = cognitoClientIdStaffCosmo; - } else if (this.appMode === AppModes.SOCIAL_WORK) { - cognitoAuthDomain = cognitoAuthDomainStaffSw; - cognitoClientId = cognitoClientIdStaffSw; - } - - // Create the logout URI + const { domain } = this.$envConfig; + const { clientId, authDomain } = getCognitoConfig(this.$appMode, AuthTypes.STAFF); const logoutLink = encodeURIComponent(`${(domain as string)}/Logout`); const logoutUriQuery = [ - `?client_id=${cognitoClientId}`, + `?client_id=${clientId}`, `&logout_uri=${logoutLink}` ].join(''); const idpPath = '/logout'; - const logoutUri = `${cognitoAuthDomain}${idpPath}${logoutUriQuery}`; + const logoutUri = `${authDomain}${idpPath}${logoutUriQuery}`; return logoutUri; } @@ -92,13 +64,13 @@ export default class Logout extends Vue { } get hostedLogoutUriLicensee(): string { - const { cognitoAuthDomainLicensee, cognitoClientIdLicensee } = this.$envConfig; + const { clientId, authDomain } = getCognitoConfig(this.$appMode, AuthTypes.LICENSEE); const logoutUriQuery = [ - `?client_id=${cognitoClientIdLicensee}`, + `?client_id=${clientId}`, `&logout_uri=${encodeURIComponent(this.loginURL)}` ].join(''); const idpPath = '/logout'; - const logoutUri = `${cognitoAuthDomainLicensee}${idpPath}${logoutUriQuery}`; + const logoutUri = `${authDomain}${idpPath}${logoutUriQuery}`; return logoutUri; } @@ -133,12 +105,12 @@ export default class Logout extends Vue { } async revokeTokens(authType: AuthTypes): Promise { - await revokeCognitoRefreshToken(this.appMode, authType).catch((err) => Promise.resolve().then(() => { + await revokeCognitoRefreshToken(this.$appMode, authType).catch((err) => Promise.resolve().then(() => { // https://console.statsig.com/3KcYv8LC2YCc1vsTkVi3Fb/metrics/metrics_catalog/Cognito%20Token%20Revocation%20Failure/event_count_custom?unitType=overall this.$analytics.logEvent('cognito_token_revoke_failed', 1, { authType, - appMode: this.appMode, - appGroupMode: this.appGroupMode, + appMode: this.$appMode, + appGroupMode: this.$appGroupMode, errorName: err?.name, errorCode: err?.code, httpStatus: err?.response?.status, diff --git a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts index 0c187eb4b7..51d9dcc118 100644 --- a/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts +++ b/webroot/src/pages/MfaResetConfirmLicensee/MfaResetConfirmLicensee.ts @@ -11,7 +11,6 @@ import { Watch, toNative } from 'vue-facing-decorator'; -import { AppModes } from '@/app.config'; import { authStorage, AuthTypes, @@ -70,10 +69,6 @@ class MfaResetConfirmLicensee extends Vue { // // Computed // - get appMode(): AppModes { - return this.$store.state.appMode; - } - get compactQuery(): string { const compact: string = (this.$route.query?.compact as string) || ''; @@ -89,7 +84,7 @@ class MfaResetConfirmLicensee extends Vue { } get hostedLoginUriLicensee(): string { - return getHostedLoginUri(this.appMode, AuthTypes.LICENSEE, '/login', this.csrfState, this.pkceChallenge); + return getHostedLoginUri(this.$appMode, AuthTypes.LICENSEE, '/login', this.csrfState, this.pkceChallenge); } get isUsingMockApi(): boolean { diff --git a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts index 5a0038916c..9a49f5a399 100644 --- a/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts +++ b/webroot/src/pages/MfaResetStartLicensee/MfaResetStartLicensee.ts @@ -19,8 +19,7 @@ import { } from 'vue'; import { stateList, - dateFormatPatterns, - AppModes + dateFormatPatterns } from '@/app.config'; import { AuthTypes, @@ -95,10 +94,6 @@ class MfaResetStartLicensee extends mixins(MixinForm) { // // Computed // - get appMode(): AppModes { - return this.$store.state.appMode; - } - get stateOptions(): Array { const options = [{ value: '', name: `- ${this.$t('common.select')} -`, isDisabled: true }]; @@ -187,7 +182,7 @@ class MfaResetStartLicensee extends mixins(MixinForm) { get hostedForgotPasswordUriLicensee(): string { return getHostedLoginUri( - this.appMode, + this.$appMode, AuthTypes.LICENSEE, '/forgotPassword', this.csrfState, diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.spec.ts b/webroot/src/pages/PublicDashboard/PublicDashboard.spec.ts index cdee869215..fc9d0fa918 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.spec.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.spec.ts @@ -5,7 +5,7 @@ // Created by InspiringApps on 8/12/2024. // -import { mountShallow } from '@tests/helpers/setup'; +import { mountFull, mountShallow } from '@tests/helpers/setup'; import PublicDashboard from '@pages/PublicDashboard/PublicDashboard.vue'; import { AppModes } from '@/app.config'; import { AuthTypes, getCognitoConfig, getHostedLoginUri } from '@utils/auth'; @@ -63,15 +63,17 @@ describe('PublicDashboard page', async () => { await nextTick(); await flushPromises(); + const loginUri = component.staffLoginUri(AppModes.JCC); + expect(component.csrfState).to.be.a('string').with.length.above(0); expect(component.pkceChallenge).to.be.a('string').with.length.above(0); - expect(component.hostedLoginUriStaff).to.contain('/login'); - expect(component.hostedLoginUriStaff).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); - expect(component.hostedLoginUriStaff).to.contain(`&state=${component.csrfState}`); - expect(component.hostedLoginUriStaff).to.contain(`&code_challenge=${component.pkceChallenge}`); - expect(component.hostedLoginUriStaff).to.contain('&code_challenge_method=S256'); - expect(component.hostedLoginUriStaff).to.contain('&response_type=code'); - expect(component.hostedLoginUriStaff).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fjcc'); + expect(loginUri).to.contain('/login'); + expect(loginUri).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); + expect(loginUri).to.contain(`&state=${component.csrfState}`); + expect(loginUri).to.contain(`&code_challenge=${component.pkceChallenge}`); + expect(loginUri).to.contain('&code_challenge_method=S256'); + expect(loginUri).to.contain('&response_type=code'); + expect(loginUri).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fjcc'); }); it('should get correct hosted login uri config for staff (cosmetology)', async () => { const wrapper = await mountShallow(PublicDashboard); @@ -80,15 +82,17 @@ describe('PublicDashboard page', async () => { await nextTick(); await flushPromises(); + const loginUri = component.staffLoginUri(AppModes.COSMETOLOGY); + expect(component.csrfState).to.be.a('string').with.length.above(0); expect(component.pkceChallenge).to.be.a('string').with.length.above(0); - expect(component.hostedLoginUriStaffCosmo).to.contain('/login'); - expect(component.hostedLoginUriStaffCosmo).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); - expect(component.hostedLoginUriStaffCosmo).to.contain(`&state=${component.csrfState}`); - expect(component.hostedLoginUriStaffCosmo).to.contain(`&code_challenge=${component.pkceChallenge}`); - expect(component.hostedLoginUriStaffCosmo).to.contain('&code_challenge_method=S256'); - expect(component.hostedLoginUriStaffCosmo).to.contain('&response_type=code'); - expect(component.hostedLoginUriStaffCosmo).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fcosmo'); + expect(loginUri).to.contain('/login'); + expect(loginUri).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); + expect(loginUri).to.contain(`&state=${component.csrfState}`); + expect(loginUri).to.contain(`&code_challenge=${component.pkceChallenge}`); + expect(loginUri).to.contain('&code_challenge_method=S256'); + expect(loginUri).to.contain('&response_type=code'); + expect(loginUri).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fcosmo'); }); it('should get correct hosted login uri config for staff (social work)', async () => { const wrapper = await mountShallow(PublicDashboard); @@ -97,15 +101,33 @@ describe('PublicDashboard page', async () => { await nextTick(); await flushPromises(); + const loginUri = component.staffLoginUri(AppModes.SOCIAL_WORK); + expect(component.csrfState).to.be.a('string').with.length.above(0); expect(component.pkceChallenge).to.be.a('string').with.length.above(0); - expect(component.hostedLoginUriStaffSw).to.contain('/login'); - expect(component.hostedLoginUriStaffSw).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); - expect(component.hostedLoginUriStaffSw).to.contain(`&state=${component.csrfState}`); - expect(component.hostedLoginUriStaffSw).to.contain(`&code_challenge=${component.pkceChallenge}`); - expect(component.hostedLoginUriStaffSw).to.contain('&code_challenge_method=S256'); - expect(component.hostedLoginUriStaffSw).to.contain('&response_type=code'); - expect(component.hostedLoginUriStaffSw).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fsocialwork'); + expect(loginUri).to.contain('/login'); + expect(loginUri).to.contain('scope=email%20openid%20phone%20profile%20aws.cognito.signin.user.admin'); + expect(loginUri).to.contain(`&state=${component.csrfState}`); + expect(loginUri).to.contain(`&code_challenge=${component.pkceChallenge}`); + expect(loginUri).to.contain('&code_challenge_method=S256'); + expect(loginUri).to.contain('&response_type=code'); + expect(loginUri).to.contain('%2Fauth%2Fcallback%2Fstaff%2Fsocialwork'); + }); + it('should successfully render one staff login link per enabled compact', async () => { + const wrapper = await mountFull(PublicDashboard); + const component = wrapper.vm; + + await nextTick(); + await flushPromises(); + + const compactLinks = wrapper.findAll('.staff-compacts .login-link'); + const enabledCompacts = component.$compactsEnabled; + + expect(enabledCompacts.length).to.be.above(0); + expect(compactLinks.length).to.equal(enabledCompacts.length); + compactLinks.forEach((compactLink, index) => { + expect(compactLink.text()).to.equal(component.getCompactDisplay(enabledCompacts[index])); + }); }); it('should get correct hosted login uri config for licensee (jcc)', async () => { const wrapper = await mountShallow(PublicDashboard); diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.ts b/webroot/src/pages/PublicDashboard/PublicDashboard.ts index 28492155a0..36a06fb45d 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.ts @@ -23,6 +23,7 @@ import RegisterIcon from '@components/Icons/RegisterAlt/RegisterAlt.vue'; import StaffUserIcon from '@components/Icons/StaffUser/StaffUser.vue'; import LicenseeUserIcon from '@components/Icons/LicenseeUser/LicenseeUser.vue'; import InputButton from '@components/Forms/InputButton/InputButton.vue'; +import { CompactConfig } from '@plugins/Compacts/compacts.plugin'; import { CompactType } from '@models/Compact/Compact.model'; @Component({ @@ -58,10 +59,6 @@ export default class DashboardPublic extends Vue { // // Computed // - get appMode(): AppModes { - return this.$store.state.appMode; - } - get bypassQuery(): string { const bypass: string = (this.$route.query?.bypass as string) || ''; @@ -78,36 +75,6 @@ export default class DashboardPublic extends Vue { return (this.shouldRemoteLogout) ? '/logout' : '/login'; } - get hostedLoginUriStaff(): string { - return getHostedLoginUri( - AppModes.JCC, - AuthTypes.STAFF, - this.hostedLoginUriPath, - this.csrfState, - this.pkceChallenge - ); - } - - get hostedLoginUriStaffCosmo(): string { - return getHostedLoginUri( - AppModes.COSMETOLOGY, - AuthTypes.STAFF, - this.hostedLoginUriPath, - this.csrfState, - this.pkceChallenge - ); - } - - get hostedLoginUriStaffSw(): string { - return getHostedLoginUri( - AppModes.SOCIAL_WORK, - AuthTypes.STAFF, - this.hostedLoginUriPath, - this.csrfState, - this.pkceChallenge - ); - } - get hostedLoginUriLicensee(): string { return getHostedLoginUri( AppModes.JCC, @@ -118,10 +85,6 @@ export default class DashboardPublic extends Vue { ); } - get compactTypes(): typeof CompactType { - return CompactType; - } - get isUsingMockApi(): boolean { return this.$envConfig.isUsingMockApi || false; } @@ -132,13 +95,13 @@ export default class DashboardPublic extends Vue { bypassRedirect(): void { switch (this.bypassQuery) { case 'login-staff': - this.bypassToStaffLogin(); + this.bypassToStaffLogin(AppModes.JCC); break; case 'login-staff-cosmo': - this.bypassToStaffLoginCosmo(); + this.bypassToStaffLogin(AppModes.COSMETOLOGY); break; case 'login-staff-sw': - this.bypassToStaffLoginSw(); + this.bypassToStaffLogin(AppModes.SOCIAL_WORK); break; case 'login-practitioner': this.bypassToLicenseeLogin(); @@ -151,39 +114,25 @@ export default class DashboardPublic extends Vue { } } - bypassToStaffLogin(compactType?: CompactType): void { - if (this.isUsingMockApi) { - if (compactType) { - this.setGotoCompact(compactType); - } - this.mockStaffLogin(AppModes.JCC); - } else { - this.$store.dispatch('startLoading'); - window.location.replace(this.hostedLoginUriStaff); - } - } - - bypassToStaffLoginCosmo(compactType?: CompactType): void { - if (this.isUsingMockApi) { - if (compactType) { - this.setGotoCompact(compactType); - } - this.mockStaffLogin(AppModes.COSMETOLOGY); - } else { - this.$store.dispatch('startLoading'); - window.location.replace(this.hostedLoginUriStaffCosmo); - } + staffLoginUri(appMode: AppModes): string { + return getHostedLoginUri( + appMode, + AuthTypes.STAFF, + this.hostedLoginUriPath, + this.csrfState, + this.pkceChallenge + ); } - bypassToStaffLoginSw(compactType?: CompactType): void { + bypassToStaffLogin(appMode: AppModes, compactType?: CompactType): void { if (this.isUsingMockApi) { if (compactType) { this.setGotoCompact(compactType); } - this.mockStaffLogin(AppModes.SOCIAL_WORK); + this.mockStaffLogin(appMode); } else { this.$store.dispatch('startLoading'); - window.location.replace(this.hostedLoginUriStaffSw); + window.location.replace(this.staffLoginUri(appMode)); } } @@ -209,21 +158,15 @@ export default class DashboardPublic extends Vue { }); } - getCompactDisplay(compactType: CompactType): string { - const compacts = this.$tm('compacts') || []; - const selectedCompact = compacts.find((compact) => compact?.key === compactType); + getCompactDisplay(compact: CompactConfig): string { const shouldAddAbbrev = [ CompactType.ASLP, CompactType.OT, - ].includes(compactType); - let compactDisplay = ''; - - if (selectedCompact) { - compactDisplay += selectedCompact.name; + ].includes(compact.type); + let compactDisplay = compact.name || ''; - if (shouldAddAbbrev && selectedCompact.abbrev) { - compactDisplay += ` (${selectedCompact.abbrev})`; - } + if (shouldAddAbbrev && compact.abbrev) { + compactDisplay += ` (${compact.abbrev})`; } return compactDisplay.trim(); diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.vue b/webroot/src/pages/PublicDashboard/PublicDashboard.vue index b5ce198a54..994c037181 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.vue +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.vue @@ -91,110 +91,26 @@
- - - - - - - - - -