From f9756ce0fcf08c9d6a9e59b3772608a843a84f3d Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 23 Sep 2026 15:10:43 +0300 Subject: [PATCH 1/4] Add a utility that check if phone numbers are US or Canada The SMS provider we are using for verifying phone numbers only validates US and Canada, so this utility will check to see what area this number is from. Issue: PER-10742 --- src/app/shared/utilities/phone-number.spec.ts | 164 ++++++++++++++++++ src/app/shared/utilities/phone-number.ts | 105 +++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/app/shared/utilities/phone-number.spec.ts create mode 100644 src/app/shared/utilities/phone-number.ts diff --git a/src/app/shared/utilities/phone-number.spec.ts b/src/app/shared/utilities/phone-number.spec.ts new file mode 100644 index 000000000..e1afd58aa --- /dev/null +++ b/src/app/shared/utilities/phone-number.spec.ts @@ -0,0 +1,164 @@ +import { + storedPhoneNumberNeedsCountryWarning, + typedPhoneNumberNeedsCountryWarning, +} from './phone-number'; + +describe('storedPhoneNumberNeedsCountryWarning', () => { + it('stays quiet for a number however it is punctuated', () => { + expect( + storedPhoneNumberNeedsCountryWarning('(202) 555 - 0147'), + ).toBeFalse(); + + expect(storedPhoneNumberNeedsCountryWarning('2025550147')).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning('202.555.0147')).toBeFalse(); + expect( + storedPhoneNumberNeedsCountryWarning(' 202 555 0147 '), + ).toBeFalse(); + }); + + it('stays quiet however the country code is dialled', () => { + expect( + storedPhoneNumberNeedsCountryWarning('+1 (202) 555-0147'), + ).toBeFalse(); + + expect(storedPhoneNumberNeedsCountryWarning('1-202-555-0147')).toBeFalse(); + expect( + storedPhoneNumberNeedsCountryWarning('001 202 555 0147'), + ).toBeFalse(); + + expect( + storedPhoneNumberNeedsCountryWarning('011 1 202 555 0147'), + ).toBeFalse(); + }); + + it('stays quiet for a Canadian number', () => { + expect(storedPhoneNumberNeedsCountryWarning('+1 416 555 0147')).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning('604 555 0147')).toBeFalse(); + }); + + it('stays quiet for a US territory, which is a US number', () => { + expect(storedPhoneNumberNeedsCountryWarning('787 555 0147')).toBeFalse(); // Puerto Rico + expect(storedPhoneNumberNeedsCountryWarning('939 555 0147')).toBeFalse(); // Puerto Rico + expect(storedPhoneNumberNeedsCountryWarning('340 555 0147')).toBeFalse(); // US Virgin Islands + expect(storedPhoneNumberNeedsCountryWarning('671 555 0147')).toBeFalse(); // Guam + expect(storedPhoneNumberNeedsCountryWarning('684 555 0147')).toBeFalse(); // American Samoa + expect(storedPhoneNumberNeedsCountryWarning('670 555 0147')).toBeFalse(); // N. Mariana Islands + }); + + it('stays quiet for an empty field, since clearing the number is allowed', () => { + expect(storedPhoneNumberNeedsCountryWarning('')).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning(' ')).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning(null)).toBeFalse(); + expect(storedPhoneNumberNeedsCountryWarning(undefined)).toBeFalse(); + }); + + it('warns about numbers from outside the numbering plan', () => { + expect(storedPhoneNumberNeedsCountryWarning('+44 20 7946 0958')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('+40 721 234 567')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('+49 151 12345678')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('+61 2 9374 4000')).toBeTrue(); + expect( + storedPhoneNumberNeedsCountryWarning('+86 138 0013 8000'), + ).toBeTrue(); + + expect(storedPhoneNumberNeedsCountryWarning('+91 98765 43210')).toBeTrue(); + expect( + storedPhoneNumberNeedsCountryWarning('0044 20 7946 0958'), + ).toBeTrue(); + }); + + it('warns about the other +1 countries, which dial exactly like a US number', () => { + expect(storedPhoneNumberNeedsCountryWarning('+1 876 555 0123')).toBeTrue(); // Jamaica + expect(storedPhoneNumberNeedsCountryWarning('+1 658 555 0123')).toBeTrue(); // Jamaica + expect(storedPhoneNumberNeedsCountryWarning('242 555 0123')).toBeTrue(); // The Bahamas + expect(storedPhoneNumberNeedsCountryWarning('246 555 0123')).toBeTrue(); // Barbados + expect(storedPhoneNumberNeedsCountryWarning('441 555 0123')).toBeTrue(); // Bermuda + expect(storedPhoneNumberNeedsCountryWarning('721 555 0123')).toBeTrue(); // Sint Maarten + expect(storedPhoneNumberNeedsCountryWarning('809 555 0123')).toBeTrue(); // Dominican Rep. + expect(storedPhoneNumberNeedsCountryWarning('829 555 0123')).toBeTrue(); // Dominican Rep. + expect(storedPhoneNumberNeedsCountryWarning('849 555 0123')).toBeTrue(); // Dominican Rep. + expect(storedPhoneNumberNeedsCountryWarning('868 555 0123')).toBeTrue(); // Trinidad & Tobago + }); + + it('warns about a national trunk prefix that is not a country code', () => { + expect(storedPhoneNumberNeedsCountryWarning('0721 234 567')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('020 7946 0958')).toBeTrue(); + }); + + it('warns about the wrong quantity of digits, however it got stored', () => { + expect(storedPhoneNumberNeedsCountryWarning('555 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('202 555 014')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('202 555 01478')).toBeTrue(); + expect( + storedPhoneNumberNeedsCountryWarning('(202) 555-0147 ext 22'), + ).toBeTrue(); + }); + + it('warns about an area code the plan never assigns', () => { + expect(storedPhoneNumberNeedsCountryWarning('102 555 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('002 555 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('911 555 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('411 555 0147')).toBeTrue(); + }); + + it('warns about an exchange code the plan never assigns', () => { + expect(storedPhoneNumberNeedsCountryWarning('202 155 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('202 055 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('202 411 0147')).toBeTrue(); + expect(storedPhoneNumberNeedsCountryWarning('555 123 4567')).toBeTrue(); + }); + + it('warns about text that holds no number at all', () => { + expect(storedPhoneNumberNeedsCountryWarning('call me')).toBeTrue(); + }); +}); + +describe('typedPhoneNumberNeedsCountryWarning', () => { + const everyPrefixOf = (wholeNumber: string): string[] => + Array.from(wholeNumber, (_, index) => wholeNumber.slice(0, index + 1)); + + [ + '(202) 555-0147', + '+1 (202) 555-0147', + '1-202-555-0147', + '001 202 555 0147', + '011 1 202 555 0147', + '416 555 0147', + '787 555 0147', + ].forEach((wholeNumber) => { + it(`stays quiet at every keystroke of ${wholeNumber}`, () => { + everyPrefixOf(wholeNumber).forEach((partial) => { + expect(typedPhoneNumberNeedsCountryWarning(partial)) + .withContext(partial) + .toBeFalse(); + }); + }); + }); + + it('warns as soon as an international number is long enough to judge', () => { + expect(typedPhoneNumberNeedsCountryWarning('+44 20 7946')).toBeFalse(); + expect(typedPhoneNumberNeedsCountryWarning('+44 20 7946 09')).toBeTrue(); + expect(typedPhoneNumberNeedsCountryWarning('+44 20 7946 0958')).toBeTrue(); + expect(typedPhoneNumberNeedsCountryWarning('+40 721 234 567')).toBeTrue(); + }); + + it('warns as soon as a whole-length number names another +1 country', () => { + expect(typedPhoneNumberNeedsCountryWarning('876 555 012')).toBeFalse(); + expect(typedPhoneNumberNeedsCountryWarning('876 555 0123')).toBeTrue(); + }); + + it('warns about a whole-length number with an unusable code', () => { + expect(typedPhoneNumberNeedsCountryWarning('202 155 0147')).toBeTrue(); + expect(typedPhoneNumberNeedsCountryWarning('020 7946 0958')).toBeTrue(); + }); + + it('holds back on ten digits that could still be growing a country code', () => { + expect(typedPhoneNumberNeedsCountryWarning('102 555 0147')).toBeFalse(); + }); + + it('stays quiet for an empty field', () => { + expect(typedPhoneNumberNeedsCountryWarning('')).toBeFalse(); + expect(typedPhoneNumberNeedsCountryWarning(null)).toBeFalse(); + expect(typedPhoneNumberNeedsCountryWarning(undefined)).toBeFalse(); + }); +}); diff --git a/src/app/shared/utilities/phone-number.ts b/src/app/shared/utilities/phone-number.ts new file mode 100644 index 000000000..3fe6f76e6 --- /dev/null +++ b/src/app/shared/utilities/phone-number.ts @@ -0,0 +1,105 @@ +const NON_DIGITS = /\D/g; +const NANP_COUNTRY_CODE = '1'; +const NANP_SUBSCRIBER_DIGITS = 10; +const INTERNATIONAL_CALL_PREFIXES = ['011', '00']; +const NANP_NUMBER = /^([2-9]\d\d)([2-9]\d\d)\d{4}$/; +const SERVICE_CODE = /^\d11$/; + +const AREA_CODES_OUTSIDE_US_AND_CANADA = new Set([ + '242', // The Bahamas + '246', // Barbados + '264', // Anguilla + '268', // Antigua and Barbuda + '284', // British Virgin Islands + '345', // Cayman Islands + '441', // Bermuda + '473', // Grenada + '649', // Turks and Caicos Islands + '658', // Jamaica + '664', // Montserrat + '721', // Sint Maarten + '758', // Saint Lucia + '767', // Dominica + '784', // Saint Vincent and the Grenadines + '809', // Dominican Republic + '829', // Dominican Republic + '849', // Dominican Republic + '868', // Trinidad and Tobago + '869', // Saint Kitts and Nevis + '876', // Jamaica +]); + +export const UNSUPPORTED_PHONE_COUNTRY_NOTICE = + 'Permanent can only send verification codes to US and Canada numbers.'; + +const onlyDigits = (text: string | null | undefined): string => + (text ?? '').replace(NON_DIGITS, ''); + +const internationalCallPrefixOf = (digits: string): string => + INTERNATIONAL_CALL_PREFIXES.find((candidate) => + digits.startsWith(candidate), + ) ?? ''; + +const countryCodeOf = (digits: string): string => + digits.startsWith(NANP_COUNTRY_CODE) ? NANP_COUNTRY_CODE : ''; + +const withoutDiallingPrefixes = (digits: string): string => { + const afterInternationalPrefix = digits.slice( + internationalCallPrefixOf(digits).length, + ); + const hasCountryCode = + afterInternationalPrefix.startsWith(NANP_COUNTRY_CODE) && + afterInternationalPrefix.length === + NANP_SUBSCRIBER_DIGITS + NANP_COUNTRY_CODE.length; + + return hasCountryCode + ? afterInternationalPrefix.slice(NANP_COUNTRY_CODE.length) + : afterInternationalPrefix; +}; + +const digitsNeededForAWholeNumber = (digits: string): number => { + const internationalCallPrefix = internationalCallPrefixOf(digits); + const countryCode = countryCodeOf( + digits.slice(internationalCallPrefix.length), + ); + + return ( + NANP_SUBSCRIBER_DIGITS + internationalCallPrefix.length + countryCode.length + ); +}; + +const namesAUsOrCanadaNumber = (text: string | null | undefined): boolean => { + const wholeNumber = NANP_NUMBER.exec( + withoutDiallingPrefixes(onlyDigits(text)), + ); + + if (!wholeNumber) { + return false; + } + + const [, areaCode, exchangeCode] = wholeNumber; + + return ( + !SERVICE_CODE.test(areaCode) && + !SERVICE_CODE.test(exchangeCode) && + !AREA_CODES_OUTSIDE_US_AND_CANADA.has(areaCode) + ); +}; + +const isBlank = (text: string | null | undefined): boolean => + (text ?? '').trim() === ''; + +export const storedPhoneNumberNeedsCountryWarning = ( + text: string | null | undefined, +): boolean => !isBlank(text) && !namesAUsOrCanadaNumber(text); + +export const typedPhoneNumberNeedsCountryWarning = ( + text: string | null | undefined, +): boolean => { + const digits = onlyDigits(text); + + return ( + digits.length >= digitsNeededForAWholeNumber(digits) && + !namesAUsOrCanadaNumber(text) + ); +}; From 73238f6be8a3ebd60cc74c5577d6b3866e3678be Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 23 Sep 2026 15:14:05 +0300 Subject: [PATCH 2/4] Update the inline edit component so it emits when we are changing a value Up until now we didn't have a way to check the value in the inline edit component while is being typed, so adding a new emitter will enable us to validate before actually saving. Issue: PER-10472 --- .../inline-value-edit.component.html | 3 +- .../inline-value-edit.component.spec.ts | 42 +++++++++++++++++++ .../inline-value-edit.component.ts | 12 ++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/app/shared/components/inline-value-edit/inline-value-edit.component.html b/src/app/shared/components/inline-value-edit/inline-value-edit.component.html index 755116911..74f5496bc 100644 --- a/src/app/shared/components/inline-value-edit/inline-value-edit.component.html +++ b/src/app/shared/components/inline-value-edit/inline-value-edit.component.html @@ -10,7 +10,8 @@ name="text" class="form-control" [class.is-editing]="isEditing" - [(ngModel)]="editValue" + [ngModel]="editValue" + (ngModelChange)="onEditValueChange($event)" #input #model="ngModel" [required]="required" diff --git a/src/app/shared/components/inline-value-edit/inline-value-edit.component.spec.ts b/src/app/shared/components/inline-value-edit/inline-value-edit.component.spec.ts index e0587846d..9b822aaed 100644 --- a/src/app/shared/components/inline-value-edit/inline-value-edit.component.spec.ts +++ b/src/app/shared/components/inline-value-edit/inline-value-edit.component.spec.ts @@ -462,6 +462,48 @@ describe('InlineValueEditComponent', () => { expect(component.isEditing).toBeTrue(); }); + it('emits the edit value as it is typed', () => { + const emitted: unknown[] = []; + component.editValueChange.subscribe((value) => emitted.push(value)); + + component.displayValue = TEST_TEXT; + component.startEdit(); + + expect(emitted[0]).toBe(TEST_TEXT); + + component.onEditValueChange('Partly typed'); + + expect(emitted[emitted.length - 1]).toBe('Partly typed'); + expect(component.editValue).toBe('Partly typed'); + }); + + it('reports when editing starts and when it ends with a save', () => { + const emitted: boolean[] = []; + component.editingChange.subscribe((isEditing) => emitted.push(isEditing)); + + component.displayValue = TEST_TEXT; + component.startEdit(); + + expect(emitted[emitted.length - 1]).toBeTrue(); + + component.onEditValueChange('Something else'); + component.save(); + + expect(emitted[emitted.length - 1]).toBeFalse(); + }); + + it('reports when editing ends with a cancel', () => { + const emitted: boolean[] = []; + component.editingChange.subscribe((isEditing) => emitted.push(isEditing)); + + component.displayValue = TEST_TEXT; + component.startEdit(); + component.onEditValueChange('Something else'); + component.cancel(); + + expect(emitted[emitted.length - 1]).toBeFalse(); + }); + it('should NOT start editing when clicking on a link', () => { component.canEdit = true; component.type = 'textarea'; diff --git a/src/app/shared/components/inline-value-edit/inline-value-edit.component.ts b/src/app/shared/components/inline-value-edit/inline-value-edit.component.ts index c148088f1..8306108bc 100644 --- a/src/app/shared/components/inline-value-edit/inline-value-edit.component.ts +++ b/src/app/shared/components/inline-value-edit/inline-value-edit.component.ts @@ -71,6 +71,9 @@ export class InlineValueEditComponent implements OnInit, OnChanges { @HostBinding('class.always-show') @Input() alwaysShow = false; @Output() doneEditing: EventEmitter = new EventEmitter(); + @Output() editValueChange: EventEmitter = + new EventEmitter(); + @Output() editingChange: EventEmitter = new EventEmitter(); @Output() externalEdit: EventEmitter = new EventEmitter(); @Output() toggledDatePicker: EventEmitter = @@ -149,6 +152,8 @@ export class InlineValueEditComponent implements OnInit, OnChanges { this.editValue = this.displayValue; } this.isEditing = true; + this.editValueChange.emit(this.editValue); + this.editingChange.emit(true); this.focusInput(); if (!this.noScroll) { @@ -178,6 +183,7 @@ export class InlineValueEditComponent implements OnInit, OnChanges { this.doneEditing.emit(this.editValue); } this.isEditing = false; + this.editingChange.emit(false); if (!skipBlur) { this.blurInput(); } @@ -189,6 +195,7 @@ export class InlineValueEditComponent implements OnInit, OnChanges { } this.editValue = this.displayValue; this.isEditing = false; + this.editingChange.emit(false); this.blurInput(); } @@ -280,6 +287,11 @@ export class InlineValueEditComponent implements OnInit, OnChanges { } } + onEditValueChange(value: ValueType) { + this.editValue = value; + this.editValueChange.emit(value); + } + onTextInputBlur() { if (this.saveOnBlur) { this.save(true); From b5b79d327130fe12761e6564dd31c31455f0969c Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 23 Sep 2026 15:22:41 +0300 Subject: [PATCH 3/4] Warning message for unsuported phone number Show a warning message if the phone number that is being typed or even if it's already stored to let the user know the we cannot verify it. Issue: PER-10742 --- .../account-settings.component.html | 13 ++ .../account-settings.component.scss | 27 ++++ .../account-settings.component.spec.ts | 141 ++++++++++++++++++ .../account-settings.component.ts | 33 +++- 4 files changed, 213 insertions(+), 1 deletion(-) diff --git a/src/app/core/components/account-settings/account-settings.component.html b/src/app/core/components/account-settings/account-settings.component.html index 58b19c8dc..72c860016 100644 --- a/src/app/core/components/account-settings/account-settings.component.html +++ b/src/app/core/components/account-settings/account-settings.component.html @@ -36,6 +36,9 @@
+

+ Permanent only supports US and Canada numbers. +

@@ -73,4 +78,12 @@ }
+ @if (phoneNumberWarning) { +
+ + warning + {{ phoneNumberWarning }} + +
+ } diff --git a/src/app/core/components/account-settings/account-settings.component.scss b/src/app/core/components/account-settings/account-settings.component.scss index fafd47474..e5cc617f0 100644 --- a/src/app/core/components/account-settings/account-settings.component.scss +++ b/src/app/core/components/account-settings/account-settings.component.scss @@ -75,3 +75,30 @@ p { form { @include tabbedDialogPanelForm; } + +.settings-item-note { + font-size: $font-size-sm; + padding-bottom: $grid-unit * 0.5; + + @include desktop { + padding-left: 12rem; + } +} + +.settings-group-note { + font-size: $font-size-sm; + color: $gray-dark; + padding: 0 0 $grid-unit * 0.5; + margin: 0; +} + +.phone-number-warning { + color: $PR-yellow-700; + display: flex; + align-items: center; + gap: $grid-unit * 0.25; + + i { + font-size: $mat-icon-sm; + } +} diff --git a/src/app/core/components/account-settings/account-settings.component.spec.ts b/src/app/core/components/account-settings/account-settings.component.spec.ts index 494039f6d..ea11c43f8 100644 --- a/src/app/core/components/account-settings/account-settings.component.spec.ts +++ b/src/app/core/components/account-settings/account-settings.component.spec.ts @@ -120,6 +120,147 @@ describe('AccountSettingsComponent', () => { } }); + it('warns about a number that arrived from the backend, without any editing', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + expect(instance.account).toBe(TestBed.inject(AccountService).getAccount()); + + instance.account.primaryPhone = '+44 20 7946 0958'; + instance.account.phoneStatus = 'status.auth.unverified'; + fixture.detectChanges(); + + expect( + ngMocks.find('.phone-number-warning').nativeElement.textContent, + ).toContain('US and Canada'); + }); + + it('renders no warning for a stored number the provider can reach', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = '+1 (202) 555-0147'; + instance.account.phoneStatus = 'status.auth.unverified'; + fixture.detectChanges(); + + expect(ngMocks.findAll('.phone-number-warning').length).toBe(0); + }); + + it('keeps the standing guidance separate from the warning', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = '416 555 0147'; + instance.account.phoneStatus = 'status.auth.unverified'; + fixture.detectChanges(); + + expect( + ngMocks.find('.settings-group-note').nativeElement.textContent, + ).toContain('Permanent only supports US and Canada numbers.'); + + expect(ngMocks.findAll('.phone-number-warning').length).toBe(0); + }); + + it('warns in the same place once a typed number is long enough to judge', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.phoneStatus = 'status.auth.unverified'; + instance.onPhoneNumberEditingChange(true); + instance.onPhoneNumberTyped('+40 748'); + fixture.detectChanges(); + + expect(ngMocks.findAll('.phone-number-warning').length).toBe(0); + + instance.onPhoneNumberTyped('+40748498404'); + fixture.detectChanges(); + + expect( + ngMocks.find('.phone-number-warning').nativeElement.textContent, + ).toContain('US and Canada'); + }); + + it('stays quiet while a reachable number is being typed', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.phoneStatus = 'status.auth.unverified'; + instance.onPhoneNumberEditingChange(true); + + ['2', '202', '(202) 555', '(202) 555-014', '(202) 555-0147'].forEach( + (partial) => { + instance.onPhoneNumberTyped(partial); + + expect(instance.phoneNumberWarning).withContext(partial).toBeNull(); + }, + ); + }); + + it('falls back to the stored number once editing ends', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = '+44 20 7946 0958'; + instance.account.phoneStatus = 'status.auth.unverified'; + + instance.onPhoneNumberEditingChange(true); + instance.onPhoneNumberTyped('(202) 555-0147'); + + expect(instance.phoneNumberWarning).toBeNull(); + + instance.onPhoneNumberEditingChange(false); + + expect(instance.phoneNumberWarning).toContain('US and Canada'); + }); + + it('does not warn about a number that has already verified', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = '+44 20 7946 0958'; + instance.account.phoneStatus = 'status.auth.verified'; + + expect(instance.phoneNumberWarning).toBeNull(); + + instance.account.phoneStatus = 'status.auth.unverified'; + + expect(instance.phoneNumberWarning).toContain('US and Canada'); + }); + + it('does not warn about a number that was cleared', () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + instance.account.primaryPhone = ''; + + expect(instance.phoneNumberWarning).toBeNull(); + + instance.onPhoneNumberEditingChange(true); + instance.onPhoneNumberTyped(''); + + expect(instance.phoneNumberWarning).toBeNull(); + }); + + it('still saves a number the provider cannot reach', async () => { + const fixture = MockRender(AccountSettingsComponent); + const instance = fixture.point.componentInstance; + + const accountUpdateSpy = spyOn( + TestBed.inject(ApiService).account, + 'update', + ).and.resolveTo(new AccountVO({})); + const errorMessageSpy = spyOn(TestBed.inject(MessageService), 'showError'); + + await instance.onSaveProfileInfo('primaryPhone', '+44 20 7946 0958'); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(accountUpdateSpy).toHaveBeenCalled(); + expect(errorMessageSpy).not.toHaveBeenCalled(); + expect(instance.account.primaryPhone).toBe('+44 20 7946 0958'); + }); + it('should disable "Verify Phone Number" button if primaryPhone is empty', () => { const fixture = MockRender(AccountSettingsComponent); const instance = fixture.point.componentInstance; diff --git a/src/app/core/components/account-settings/account-settings.component.ts b/src/app/core/components/account-settings/account-settings.component.ts index 145d09de9..0fa4974ab 100644 --- a/src/app/core/components/account-settings/account-settings.component.ts +++ b/src/app/core/components/account-settings/account-settings.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, signal } from '@angular/core'; import { AccountService } from '@shared/services/account/account.service'; import { AccountVO } from '@models'; import { ApiService } from '@shared/services/api/api.service'; @@ -8,6 +8,11 @@ import { FormInputSelectOption } from '@shared/components/form-input/form-input. import { Router, ActivatedRoute } from '@angular/router'; import { EventService } from '@shared/services/event/event.service'; import { savePropertyOnAccount } from '@shared/services/account/account.service.helpers'; +import { + UNSUPPORTED_PHONE_COUNTRY_NOTICE, + storedPhoneNumberNeedsCountryWarning, + typedPhoneNumberNeedsCountryWarning, +} from '@shared/utilities/phone-number'; @Component({ selector: 'pr-account-settings', @@ -21,6 +26,8 @@ export class AccountSettingsComponent implements OnInit { public states: FormInputSelectOption[]; public waiting = false; + private readonly phoneNumberBeingEdited = signal(null); + constructor( private accountService: AccountService, private prConstants: PrConstantsService, @@ -50,6 +57,30 @@ export class AccountSettingsComponent implements OnInit { }); } + public get phoneNumberWarning(): string | null { + if (this.account.phoneStatus === 'status.auth.verified') { + return null; + } + + const beingEdited = this.phoneNumberBeingEdited(); + const needsWarning = + beingEdited === null + ? storedPhoneNumberNeedsCountryWarning(this.account.primaryPhone) + : typedPhoneNumberNeedsCountryWarning(beingEdited); + + return needsWarning ? UNSUPPORTED_PHONE_COUNTRY_NOTICE : null; + } + + onPhoneNumberTyped(value: string) { + this.phoneNumberBeingEdited.set(value ?? ''); + } + + onPhoneNumberEditingChange(isEditing: boolean) { + if (!isEditing) { + this.phoneNumberBeingEdited.set(null); + } + } + async onSaveProfileInfo(prop: keyof AccountVO, value: string) { savePropertyOnAccount( this.account, From 00693521c9550ea2bda98cd9f98804fc707a76e1 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 23 Sep 2026 15:43:49 +0300 Subject: [PATCH 4/4] Report a verification code that could not be sent When the user gets to the phone number verification page and introduces a code, up until now there was no error shown in case the code was wrong. Issue: PER-10742 --- .../verify/verify.component.spec.ts | 35 +++++++++++++++++++ .../components/verify/verify.component.ts | 16 ++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/app/auth/components/verify/verify.component.spec.ts b/src/app/auth/components/verify/verify.component.spec.ts index 54bd849a1..81058acca 100644 --- a/src/app/auth/components/verify/verify.component.spec.ts +++ b/src/app/auth/components/verify/verify.component.spec.ts @@ -18,6 +18,7 @@ import { HttpService } from '@shared/services/http/http.service'; import { ApiService } from '@shared/services/api/api.service'; import { ActivatedRoute, Router } from '@angular/router'; import { EventService } from '@shared/services/event/event.service'; +import { MessageService } from '@shared/services/message/message.service'; const defaultAuthData = require('@root/test/responses/auth.verify.unverifiedEmail.success.json'); @@ -169,6 +170,40 @@ describe('VerifyComponent', () => { req.flush(verifyPhoneResponse); }); + it('reports a phone code that fails to send on arrival', async () => { + const unverifiedPhoneData = require('@root/test/responses/auth.verify.unverifiedPhone.success.json'); + await init(unverifiedPhoneData, { sendSms: true }); + + const showErrorSpy = spyOn(TestBed.inject(MessageService), 'showError'); + const request = httpMock.expectOne((candidate) => + candidate.url.includes('resendTextCreatedAccount'), + ); + request.flush({ isSuccessful: false, isSystemUp: true, Results: [] }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(showErrorSpy).toHaveBeenCalled(); + }); + + it('reports a failed send that is not a response object', async () => { + const unverifiedPhoneData = require('@root/test/responses/auth.verify.unverifiedPhone.success.json'); + await init(unverifiedPhoneData, { sendSms: true }); + + const showErrorSpy = spyOn(TestBed.inject(MessageService), 'showError'); + const request = httpMock.expectOne((candidate) => + candidate.url.includes('resendTextCreatedAccount'), + ); + request.error(new ProgressEvent('network down')); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(showErrorSpy.calls.mostRecent().args[0].message).toBe( + 'error.generic.internal', + ); + }); + it('should show CAPTCHA before verifying phone', async () => { const unverifiedPhoneData = require('@root/test/responses/auth.verify.unverifiedPhone.success.json'); await init(unverifiedPhoneData, { sendSms: true }); diff --git a/src/app/auth/components/verify/verify.component.ts b/src/app/auth/components/verify/verify.component.ts index 605c3d822..321d10b1f 100644 --- a/src/app/auth/components/verify/verify.component.ts +++ b/src/app/auth/components/verify/verify.component.ts @@ -86,7 +86,11 @@ export class VerifyComponent implements OnInit { if (this.canSendCodes('phone')) { if (queryParams.sendSms) { - this.accountService.resendPhoneVerification(); + this.accountService + .resendPhoneVerification() + .catch((response: unknown) => { + this.showCodeSendError(response); + }); } } } @@ -233,6 +237,16 @@ export class VerifyComponent implements OnInit { }); } + private showCodeSendError(response: unknown): void { + this.message.showError({ + message: + response instanceof AuthResponse + ? response.getMessage() + : 'error.generic.internal', + translate: true, + }); + } + public resolveCaptcha(response: string): void { this.captchaPassed = true; if (this.canSendCodes('phone')) {