diff --git a/strr-base-web/app/components/form/ContactInfo.vue b/strr-base-web/app/components/form/ContactInfo.vue index f3f2e5c54..ddf571834 100644 --- a/strr-base-web/app/components/form/ContactInfo.vue +++ b/strr-base-web/app/components/form/ContactInfo.vue @@ -106,7 +106,7 @@ onMounted(async () => { name-divider :prepopulate-name="!isLoggedInAsBCeid" :prepopulate-type="$keycloak.tokenParsed?.loginSource" - :error-details="hasFormErrors(compPartyFormRef, ['phone.countryCode', 'phone.number', 'email'])" + :error-details="hasFormErrors(compPartyFormRef, ['phone.countryCode', 'phone.number', 'emailAddress'])" /> @@ -126,6 +126,7 @@ onMounted(async () => { v-model:last-name="primaryRep.lastName" v-model:phone="primaryRep.phone" v-model:email-address="primaryRep.emailAddress" + v-model:confirm-email-address="primaryRep.confirmEmailAddress" v-model:fax-number="primaryRep.faxNumber" v-model:position="primaryRep.position" data-testid="primary-rep" @@ -133,11 +134,18 @@ onMounted(async () => { name-divider :prepopulate-name="isCompletingPartyRep && !isLoggedInAsBCeid" :prepopulate-type="$keycloak.tokenParsed?.loginSource" + show-confirm-email email-warning :section-info="isCompletingPartyRep ? undefined : $t('strr.text.primaryContact')" :error-name="hasFormErrors(primaryRepFormRef, ['firstName', 'middleName', 'lastName'])" :error-details=" - hasFormErrors(primaryRepFormRef, ['position', 'phone.countryCode', 'phone.number', 'email']) + hasFormErrors(primaryRepFormRef, [ + 'position', + 'phone.countryCode', + 'phone.number', + 'emailAddress', + 'confirmEmailAddress' + ]) " /> diff --git a/strr-base-web/app/components/form/common/Contact.vue b/strr-base-web/app/components/form/common/Contact.vue index c01e7c0e4..5e2093620 100644 --- a/strr-base-web/app/components/form/common/Contact.vue +++ b/strr-base-web/app/components/form/common/Contact.vue @@ -10,6 +10,7 @@ const lastName = defineModel('lastName', { required: false }) const position = defineModel('position', { required: false }) const faxNumber = defineModel('faxNumber', { required: false }) const emailAddress = defineModel('emailAddress', { required: false }) +const confirmEmailAddress = defineModel('confirmEmailAddress', { required: false }) const phone = defineModel('phone', { required: false }) defineProps<{ @@ -19,6 +20,7 @@ defineProps<{ sectionInfo?: string nameDivider?: boolean emailWarning?: boolean + showConfirmEmail?: boolean errorName?: boolean errorDetails?: boolean }>() @@ -130,6 +132,16 @@ defineProps<{ :is-required="true" type="email" /> + diff --git a/strr-base-web/app/interfaces/contact.ts b/strr-base-web/app/interfaces/contact.ts index 959032803..ed6ecdf96 100644 --- a/strr-base-web/app/interfaces/contact.ts +++ b/strr-base-web/app/interfaces/contact.ts @@ -6,6 +6,7 @@ export interface Contact { phone: ConnectPhone faxNumber?: string emailAddress: string + confirmEmailAddress?: string } export interface StrrContact extends Contact { diff --git a/strr-base-web/app/stores/strrContact.ts b/strr-base-web/app/stores/strrContact.ts index 0ba0f18ce..6d3bf139e 100644 --- a/strr-base-web/app/stores/strrContact.ts +++ b/strr-base-web/app/stores/strrContact.ts @@ -5,16 +5,23 @@ import type { Contact, StrrContact } from '#imports' export const useStrrContactStore = defineStore('strr/contact', () => { const { t } = useNuxtApp().$i18n const { kcUser } = useKeycloak() - const getContactSchema = (completingParty = false) => { - return z.object({ + const getContactSchema = (completingParty = false, requireEmailConfirmation = false) => { + const schema = z.object({ firstName: optionalOrEmptyString, middleName: optionalOrEmptyString, lastName: getRequiredNonEmptyString(t('validation.name.last')), position: completingParty ? optionalOrEmptyString : getRequiredNonEmptyString(t('validation.position')), phone: getRequiredPhone(t('validation.required'), t('validation.phone.number')), faxNumber: optionalOrEmptyString, - emailAddress: getRequiredEmail(t('validation.email')) + emailAddress: getRequiredEmail(t('validation.email')), + confirmEmailAddress: optionalOrEmptyString }) + + if (!requireEmailConfirmation) { + return schema + } + + return withEmailConfirmation(schema, t('validation.emailMatch')) } const getNewContact = (isActiveUser = false): Contact => { @@ -29,7 +36,8 @@ export const useStrrContactStore = defineStore('strr/contact', () => { extension: '' }, faxNumber: '', - emailAddress: '' + emailAddress: '', + confirmEmailAddress: '' } } @@ -51,6 +59,7 @@ export const useStrrContactStore = defineStore('strr/contact', () => { watch(primaryRep, (val) => { if (val && isCompletingPartyRep.value) { completingParty.value.emailAddress = val?.emailAddress + completingParty.value.confirmEmailAddress = val?.confirmEmailAddress completingParty.value.phone = val?.phone // for BCeID users we need to copy the names over from primary rep to completing party @@ -64,9 +73,9 @@ export const useStrrContactStore = defineStore('strr/contact', () => { const secondaryRep = ref(undefined) - const compPartySchema = getContactSchema(true) - const primaryRepSchema = getContactSchema(false) - const secondaryRepSchema = getContactSchema(false) + const compPartySchema = getContactSchema(true, false) + const primaryRepSchema = getContactSchema(false, true) + const secondaryRepSchema = getContactSchema(false, false) const validateContact = async (returnBool = false): Promise => { const validations = [ diff --git a/strr-base-web/app/utils/connect-validation/email.ts b/strr-base-web/app/utils/connect-validation/email.ts index 7ba468472..ed622ae86 100644 --- a/strr-base-web/app/utils/connect-validation/email.ts +++ b/strr-base-web/app/utils/connect-validation/email.ts @@ -78,3 +78,28 @@ export const getOptionalEmail = (message: string) => } return true }, message) + +/** + * Refines a Zod schema to enforce that an email and its confirmation field match exactly. + * Attaches the validation error to the confirmation field path. + */ +export const withEmailConfirmation = ( + schema: T, + message: string, + emailKey: string = 'emailAddress', + confirmEmailKey: string = 'confirmEmailAddress' +) => { + return schema.refine( + (data: Record) => { + // If neither is entered, allow the required email validator to report on the primary field + if (!data[emailKey] && !data[confirmEmailKey]) { + return true + } + return data[emailKey] === data[confirmEmailKey] + }, + { + message, + path: [confirmEmailKey] + } + ) +} diff --git a/strr-base-web/app/utils/formattingHelpers.ts b/strr-base-web/app/utils/formattingHelpers.ts index 77c6093ab..ebaa347b3 100644 --- a/strr-base-web/app/utils/formattingHelpers.ts +++ b/strr-base-web/app/utils/formattingHelpers.ts @@ -56,6 +56,7 @@ export function formatPartyUI (party: T): Contact { lastName: party.lastName || '', middleName: party.middleName || '', emailAddress: party.emailAddress, + confirmEmailAddress: party.emailAddress || '', phone: formatPhoneNumberUI(party), ...(party.preferredName ? { preferredName: party.preferredName } : {}), ...(party.faxNumber ? { faxNumber: party.faxNumber } : {}) diff --git a/strr-base-web/i18n/locales/en-CA.ts b/strr-base-web/i18n/locales/en-CA.ts index 7b17ded85..bc2cd588d 100644 --- a/strr-base-web/i18n/locales/en-CA.ts +++ b/strr-base-web/i18n/locales/en-CA.ts @@ -167,6 +167,7 @@ export default { citizenshipPR: 'Citizenship/Permanent Residency', emailAddress: 'Email Address', emailAddressOpt: 'Email Address (Optional)', + confirmEmailAddress: 'Confirm Email Address', firstName: 'First Name', middleName: 'Middle Name', middleNameOpt: 'Middle Name (Optional)', @@ -491,6 +492,7 @@ export default { cpbc: 'Please enter a valid CPBC number' }, email: 'Please enter a valid email', + emailMatch: 'Email address does not match', name: { first: 'Please enter a first name', last: 'Please enter a last name', diff --git a/strr-base-web/i18n/locales/fr-CA.ts b/strr-base-web/i18n/locales/fr-CA.ts index b76a8512c..60fbe5fb4 100644 --- a/strr-base-web/i18n/locales/fr-CA.ts +++ b/strr-base-web/i18n/locales/fr-CA.ts @@ -83,7 +83,8 @@ export default { label: { expiryDaysLeft: '{count} jours restants', expiryDaysAgo: 'il y a {count} jours', - expiryToday: "aujourd'hui" + expiryToday: "aujourd'hui", + confirmEmailAddress: "Confirmer l'adresse e-mail" }, labels: { note: 'Note', @@ -94,6 +95,7 @@ export default { citizenship: 'Citoyenneté', citizenshipPR: 'Citoyenneté/Résidence Permanente', emailAddress: 'Adresse e-mail', + confirmEmailAddress: "Confirmer l'adresse e-mail", fullName: 'Nom Légal Complet', preferredName: 'Nom Préféré', address: 'Adresse', @@ -191,6 +193,9 @@ export default { }, table: {}, toast: {}, + validation: { + emailMatch: "L'adresse e-mail ne correspond pas" + }, widgets: { feeSummary: { title: 'Résumé des Frais', diff --git a/strr-base-web/package.json b/strr-base-web/package.json index 54e10248a..2d3320c32 100644 --- a/strr-base-web/package.json +++ b/strr-base-web/package.json @@ -2,7 +2,7 @@ "name": "strr-base-web", "private": true, "type": "module", - "version": "0.0.53", + "version": "0.0.54", "engines": { "node": ">=24" }, diff --git a/strr-base-web/tests/unit/email-verification.spec.ts b/strr-base-web/tests/unit/email-verification.spec.ts new file mode 100644 index 000000000..34f5d8675 --- /dev/null +++ b/strr-base-web/tests/unit/email-verification.spec.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia, defineStore } from 'pinia' +import { ref, watch } from 'vue' +import { + optionalOrEmptyString, + getRequiredNonEmptyString, + getRequiredPhone, + getRequiredEmail, + withEmailConfirmation +} from '../../app/utils' + +vi.stubGlobal('defineStore', defineStore) +vi.stubGlobal('ref', ref) +vi.stubGlobal('watch', watch) +vi.stubGlobal('optionalOrEmptyString', optionalOrEmptyString) +vi.stubGlobal('getRequiredNonEmptyString', getRequiredNonEmptyString) +vi.stubGlobal('getRequiredPhone', getRequiredPhone) +vi.stubGlobal('getRequiredEmail', getRequiredEmail) +vi.stubGlobal('withEmailConfirmation', withEmailConfirmation) + +const { useStrrContactStore } = await import('../../app/stores/strrContact') + +const validContact = { + firstName: 'John', + middleName: '', + lastName: 'Doe', + position: 'Manager', + phone: { + countryCode: '1', + number: '5551234567', + extension: '', + countryIso2: 'CA' + }, + faxNumber: '', + emailAddress: 'primary@example.com', + confirmEmailAddress: 'primary@example.com' +} + +describe('Email Verification Flow (REGBACKLOG-372)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.stubGlobal('useNuxtApp', () => ({ + $i18n: { + t: (key: string) => { + const dict: Record = { + 'validation.required': 'Required', + 'validation.phone.number': 'Please enter a valid phone number', + 'validation.email': 'Please enter a valid email', + 'validation.emailMatch': 'Email address does not match', + 'validation.name.last': 'Please enter a last name', + 'validation.position': 'Please enter a position' + } + return dict[key] || key + } + } + })) + vi.stubGlobal('useKeycloak', () => ({ + kcUser: { value: { firstName: 'Jane', lastName: 'Doe', loginSource: 'BCSC' } } + })) + }) + + describe('AC1 & AC2: Schema configuration for primary vs secondary/completing contacts', () => { + it('primaryRepSchema enforces email confirmation requirement', () => { + const store = useStrrContactStore() + const nonMatching = { + ...validContact, + emailAddress: 'primary@example.com', + confirmEmailAddress: 'other@example.com' + } + const result = store.primaryRepSchema.safeParse(nonMatching) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Email address does not match') + expect(result.error.issues[0]?.path).toEqual(['confirmEmailAddress']) + } + }) + + it('compPartySchema does not enforce email confirmation requirement', () => { + const store = useStrrContactStore() + const noConfirmEmail = { + ...validContact, + position: '', + emailAddress: 'completing@example.com', + confirmEmailAddress: '' + } + const result = store.compPartySchema.safeParse(noConfirmEmail) + expect(result.success).toBe(true) + }) + + it('secondaryRepSchema does not enforce email confirmation requirement', () => { + const store = useStrrContactStore() + const noConfirmEmail = { + ...validContact, + emailAddress: 'secondary@example.com', + confirmEmailAddress: '' + } + const result = store.secondaryRepSchema.safeParse(noConfirmEmail) + expect(result.success).toBe(true) + }) + }) + + describe('AC3: Matching Email Addresses are Accepted', () => { + it('accepts matching email and confirm email addresses', () => { + const store = useStrrContactStore() + const matching = { + ...validContact, + emailAddress: 'contact@domain.com', + confirmEmailAddress: 'contact@domain.com' + } + const result = store.primaryRepSchema.safeParse(matching) + expect(result.success).toBe(true) + }) + }) + + describe('AC4 & AC7: Non-Matching Email Addresses are Rejected', () => { + it('rejects when values do not match and shows error message', () => { + const store = useStrrContactStore() + const nonMatching = { + ...validContact, + emailAddress: 'user1@example.com', + confirmEmailAddress: 'user2@example.com' + } + const result = store.primaryRepSchema.safeParse(nonMatching) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some( + issue => issue.path.includes('confirmEmailAddress') && issue.message === 'Email address does not match' + )).toBe(true) + } + }) + + it('rejects when confirm email is empty while email address is filled', () => { + const store = useStrrContactStore() + const missingConfirm = { + ...validContact, + emailAddress: 'user@example.com', + confirmEmailAddress: '' + } + const result = store.primaryRepSchema.safeParse(missingConfirm) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some( + issue => issue.path.includes('confirmEmailAddress') && issue.message === 'Email address does not match' + )).toBe(true) + } + }) + }) + + describe('AC5: Exact Match Validation (Character for Character)', () => { + it('rejects case mismatch between email and confirm email', () => { + const store = useStrrContactStore() + const caseMismatch = { + ...validContact, + emailAddress: 'user@example.com', + confirmEmailAddress: 'User@example.com' + } + const result = store.primaryRepSchema.safeParse(caseMismatch) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Email address does not match') + } + }) + + it('rejects character discrepancies', () => { + const store = useStrrContactStore() + const typo = { + ...validContact, + emailAddress: 'john.smith@domain.com', + confirmEmailAddress: 'john_smith@domain.com' + } + const result = store.primaryRepSchema.safeParse(typo) + expect(result.success).toBe(false) + }) + }) + + describe('AC8: Existing Email Format Validation Remains Intact', () => { + it('rejects invalid email address format on emailAddress with standard email validation error', () => { + const store = useStrrContactStore() + const invalidEmail = { + ...validContact, + emailAddress: 'invalid-email-address', + confirmEmailAddress: 'invalid-email-address' + } + const result = store.primaryRepSchema.safeParse(invalidEmail) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some( + issue => issue.path.includes('emailAddress') && issue.message === 'Please enter a valid email' + )).toBe(true) + } + }) + }) + + describe('getNewContact', () => { + it('initializes confirmEmailAddress as empty string', () => { + const store = useStrrContactStore() + const newContact = store.getNewContact(false) + expect(newContact.confirmEmailAddress).toBe('') + }) + }) +}) diff --git a/strr-host-pm-web/app/components/form/AddOwners/input/Person.vue b/strr-host-pm-web/app/components/form/AddOwners/input/Person.vue index 531a8dc57..bf2dc9b1f 100644 --- a/strr-host-pm-web/app/components/form/AddOwners/input/Person.vue +++ b/strr-host-pm-web/app/components/form/AddOwners/input/Person.vue @@ -279,7 +279,7 @@ const renewalDisabledFields: AddressField[] = [
+
diff --git a/strr-host-pm-web/app/stores/hostOwner.ts b/strr-host-pm-web/app/stores/hostOwner.ts index c4b2fcb9d..c63132f89 100644 --- a/strr-host-pm-web/app/stores/hostOwner.ts +++ b/strr-host-pm-web/app/stores/hostOwner.ts @@ -7,7 +7,7 @@ export const useHostOwnerStore = defineStore('host/owner', () => { const getHostOwnerSchema = (type: OwnerType, role?: OwnerRole) => { const { isRegistrationRenewal } = storeToRefs(useHostPermitStore()) - return z.object({ + const baseSchema = z.object({ role: z.enum([OwnerRole.HOST, OwnerRole.CO_HOST, OwnerRole.PROPERTY_MANAGER], { errorMap: () => ({ message: t('validation.ownerRole') }) }), @@ -17,6 +17,7 @@ export const useHostOwnerStore = defineStore('host/owner', () => { preferredName: optionalOrEmptyString, faxNumber: optionalOrEmptyString, emailAddress: getRequiredEmail(t('validation.email')), + confirmEmailAddress: optionalOrEmptyString, phone: getRequiredPhone(t('validation.required'), t('validation.phone.number')), mailingAddress: getRequiredAddress( t('validation.address.street'), @@ -38,6 +39,12 @@ export const useHostOwnerStore = defineStore('host/owner', () => { ? getRequiredSin(t('validation.sin')) : optionalOrEmptyString }) + + if (role === OwnerRole.HOST) { + return withEmailConfirmation(baseSchema, t('validation.emailMatch')) + } + + return baseSchema } const getNewHostOwner = (isActiveUser = false, ownerType: OwnerType): HostOwner => ({ diff --git a/strr-host-pm-web/package.json b/strr-host-pm-web/package.json index 51be77294..4a73a3f29 100644 --- a/strr-host-pm-web/package.json +++ b/strr-host-pm-web/package.json @@ -2,7 +2,7 @@ "name": "strr-host-pm-web", "private": true, "type": "module", - "version": "1.3.32", + "version": "1.3.33", "engines": { "node": ">=24" }, diff --git a/strr-host-pm-web/tests/mocks/mockedData.ts b/strr-host-pm-web/tests/mocks/mockedData.ts index d762f95eb..d987ff8c7 100644 --- a/strr-host-pm-web/tests/mocks/mockedData.ts +++ b/strr-host-pm-web/tests/mocks/mockedData.ts @@ -129,6 +129,7 @@ export const mockHostOwner: HostOwner = { lastName: 'Smith', faxNumber: '555-987-6543', emailAddress: 'jane.smith@example.com', + confirmEmailAddress: 'jane.smith@example.com', phone: { number: '555-123-4567' } diff --git a/strr-host-pm-web/tests/unit/store-host-owner.spec.ts b/strr-host-pm-web/tests/unit/store-host-owner.spec.ts index 39008a260..2d308d285 100644 --- a/strr-host-pm-web/tests/unit/store-host-owner.spec.ts +++ b/strr-host-pm-web/tests/unit/store-host-owner.spec.ts @@ -101,6 +101,7 @@ const validOwner = { preferredName: '', faxNumber: '', emailAddress: 'test@example.com', + confirmEmailAddress: 'test@example.com', phone: { countryCode: '1', number: '5551234567' }, mailingAddress: { street: '123 Main St', city: 'Victoria', region: 'BC', postalCode: 'V8V1A1', country: 'CA' }, businessLegalName: '', @@ -169,6 +170,39 @@ describe('useHostOwnerStore - getHostOwnerSchema', () => { expect(store.getHostOwnerSchema(OwnerType.INDIVIDUAL, OwnerRole.HOST).safeParse({ ...validOwner, lastName: '' }).success).toBe(false) + + // INDIVIDUAL + HOST: email confirmation must match email address exactly + expect(store.getHostOwnerSchema(OwnerType.INDIVIDUAL, OwnerRole.HOST).safeParse({ + ...validOwner, + emailAddress: 'host@example.com', + confirmEmailAddress: 'host@example.com' + }).success).toBe(true) + + expect(store.getHostOwnerSchema(OwnerType.INDIVIDUAL, OwnerRole.HOST).safeParse({ + ...validOwner, + emailAddress: 'host@example.com', + confirmEmailAddress: 'different@example.com' + }).success).toBe(false) + + expect(store.getHostOwnerSchema(OwnerType.INDIVIDUAL, OwnerRole.HOST).safeParse({ + ...validOwner, + emailAddress: 'host@example.com', + confirmEmailAddress: 'Host@example.com' // character for character match + }).success).toBe(false) + + expect(store.getHostOwnerSchema(OwnerType.INDIVIDUAL, OwnerRole.HOST).safeParse({ + ...validOwner, + emailAddress: 'host@example.com', + confirmEmailAddress: '' + }).success).toBe(false) + + // CO_HOST / PROPERTY_MANAGER: email confirmation is not required for non-host + expect(store.getHostOwnerSchema(OwnerType.INDIVIDUAL, OwnerRole.CO_HOST).safeParse({ + ...validOwner, + role: OwnerRole.CO_HOST, + emailAddress: 'cohost@example.com', + confirmEmailAddress: '' + }).success).toBe(true) }) }) diff --git a/strr-platform-web/package.json b/strr-platform-web/package.json index 5156ee0be..612e52dac 100644 --- a/strr-platform-web/package.json +++ b/strr-platform-web/package.json @@ -2,7 +2,7 @@ "name": "strr-platform-web", "private": true, "type": "module", - "version": "1.2.4", + "version": "1.2.5", "engines": { "node": ">=24" }, diff --git a/strr-strata-web/package.json b/strr-strata-web/package.json index 1d7e7dea0..92a6eef15 100644 --- a/strr-strata-web/package.json +++ b/strr-strata-web/package.json @@ -2,7 +2,7 @@ "name": "strr-strata-web", "private": true, "type": "module", - "version": "1.2.7", + "version": "1.2.8", "engines": { "node": ">=24" },