From 244ba47657de068b69120e314eab3570a0e1074a Mon Sep 17 00:00:00 2001 From: cryptalith Date: Thu, 27 Aug 2026 17:34:04 -0600 Subject: [PATCH 01/21] PD-6046 PD-6045 add 2FA recovery phone number --- .../auth-challenge.component.html | 2 + .../orcid-ui/step-view-page.component.html | 16 + .../orcid-ui/step-view-page.component.ts | 1 + .../step-view/step-view.component.html | 10 + .../step-view/step-view.component.scss | 18 +- .../step-view/step-view.component.ts | 5 + ...gs-security-two-factor-auth.component.html | 104 +++- ...gs-security-two-factor-auth.component.scss | 35 ++ ...security-two-factor-auth.component.spec.ts | 141 ++++- ...ings-security-two-factor-auth.component.ts | 75 ++- src/app/app-routing.module.ts | 8 + src/app/constants.ts | 2 + .../two-factor-authentication.service.ts | 38 ++ .../recovery-phone.component.html | 178 +++++++ .../recovery-phone.component.scss | 249 +++++++++ .../recovery-phone.component.scss-theme.scss | 17 + .../recovery-phone.component.spec.ts | 373 +++++++++++++ .../recovery-phone.component.ts | 457 ++++++++++++++++ ...wo-factor-recovery-phone-routing.module.ts | 16 + .../two-factor-recovery-phone.module.ts | 32 ++ src/app/types/config.endpoint.ts | 1 + src/app/types/two-factor.endpoint.ts | 47 ++ src/locale/messages.lr.xlf | 490 +++++++++++++++--- src/locale/messages.rl.xlf | 490 +++++++++++++++--- src/locale/messages.xlf | 449 +++++++++++++--- src/locale/messages.xx.xlf | 490 +++++++++++++++--- 26 files changed, 3447 insertions(+), 297 deletions(-) create mode 100644 src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html create mode 100644 src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss create mode 100644 src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss-theme.scss create mode 100644 src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.spec.ts create mode 100644 src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts create mode 100644 src/app/two-factor-recovery-phone/two-factor-recovery-phone-routing.module.ts create mode 100644 src/app/two-factor-recovery-phone/two-factor-recovery-phone.module.ts diff --git a/projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html b/projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html index 10f5030c00..f8f4a5e97f 100644 --- a/projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html +++ b/projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html @@ -276,6 +276,7 @@

> {{ primaryLabel }} + diff --git a/projects/orcid-ui/src/lib/components/step-view/step-view.component.scss b/projects/orcid-ui/src/lib/components/step-view/step-view.component.scss index ae993fb231..0d83e1b714 100644 --- a/projects/orcid-ui/src/lib/components/step-view/step-view.component.scss +++ b/projects/orcid-ui/src/lib/components/step-view/step-view.component.scss @@ -62,7 +62,9 @@ .orcid-step-view__actions { display: flex; - gap: var(--orcid-space-4, 16px); + flex-direction: column; + align-items: center; + gap: var(--orcid-space-m, 24px); justify-content: center; } @@ -70,8 +72,8 @@ width: 100%; } -.orcid-step-view__actions--full button { - flex: 1 1 auto; +.orcid-step-view__actions--full .orcid-step-view__primary-action { + width: 100%; } .orcid-step-view__primary-action { @@ -83,6 +85,16 @@ opacity: 0.6; } +.orcid-step-view__secondary-action { + background: none; + border: 0; + padding: 0; + cursor: pointer; + text-decoration: underline; + color: var(--orcid-color-brand-secondary-dark, #085c77); + font: inherit; +} + @media (max-width: 767px) { .orcid-step-view { padding: var(--orcid-space-4, 16px); diff --git a/projects/orcid-ui/src/lib/components/step-view/step-view.component.ts b/projects/orcid-ui/src/lib/components/step-view/step-view.component.ts index 5488fce0c0..b4050c854e 100644 --- a/projects/orcid-ui/src/lib/components/step-view/step-view.component.ts +++ b/projects/orcid-ui/src/lib/components/step-view/step-view.component.ts @@ -31,8 +31,13 @@ export class OrcidStepViewComponent { /** Disables the primary action button when true. */ @Input() primaryDisabled = false + /** Optional text for a secondary action, shown as a link below the primary button. */ + @Input() secondaryLabel = '' + /** Whether footer buttons should fill the container width. */ @Input() fullWidthActions = true @Output() primaryAction = new EventEmitter() + + @Output() secondaryAction = new EventEmitter() } diff --git a/src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html b/src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html index a085f90de2..59ac9207ec 100644 --- a/src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html +++ b/src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html @@ -1,5 +1,25 @@ - @if (success) { + @if (recoveryPhoneOutcome === 'added' || recoveryPhoneOutcome === 'updated') { + +
+ @if (recoveryPhoneOutcome === 'added') { + Your recovery phone number has been added + } @else { + Your recovery phone number has been updated + } +
+
+ } @if (recoveryPhoneOutcome === 'failed') { + +
+ Your recovery phone number was not updated +
+
+ } @if (success) {
Two-factor authentication has been disabled @@ -62,6 +82,9 @@

Sign in with 2FA

Authentication app

+ @if (recoveryPhoneTogglz && !loadingTogglz) { + Enabled: + } {{ twoFactorInfo?.twoFactorCreationDate | monthDayYearDateToString }} @@ -71,7 +94,11 @@

Sign in with 2FA

+ @if (recoveryPhoneTogglz && !loadingTogglz) { +

2FA backup

+ } @else {

Account recovery

+ }

Account recovery Recover access to your ORCID account if you can't use your authentication app.

+ + @if (recoveryPhoneTogglz && !loadingTogglz) { +
+ +
+
+

+ Recovery phone number + @if (hasRecoveryPhone) { + {{ + twoFactorInfo?.maskedRecoveryPhoneNumber + }} + } +

+ @if (hasRecoveryPhone) { + + @if (twoFactorInfo?.recoveryPhoneModified) { + Modified: + } @else { + Enabled: + } + {{ recoveryPhoneDate | monthDayYearDateToString }} + + } +
+ +
+
+ } +
Account recovery 2FA recovery codes

+ @if (recoveryPhoneTogglz && !loadingTogglz) { + Enabled: + } {{ twoFactorInfo?.recoveryCodeCreationDate | monthDayYearDateToString }} @@ -100,10 +184,27 @@

Account recovery

+ @if (recoveryPhoneTogglz && !loadingTogglz) { +

+ Disable two-factor authentication +

+ }

You can disable two-factor authentication at any time. Turning 2FA off will reset any account recovery options you have set up.

+ @if (recoveryPhoneTogglz && !loadingTogglz) { + + Learn more about two-factor authentication + + + } @else { Account recovery Find out more about disabling two-factor authentication + } + @if (resendCountdown > 0) { +

+ You can resend in + {{ resendCountdown }} + seconds +

+ } +
+ +
+ + +
+ Enter the 6-digit verification code sent to your device + +
+ @if (codeErrorMessage) { + {{ + codeErrorMessage + }} + } +
+ + @if (generalErrorMessage) { + +
{{ generalErrorMessage }}
+
+ } + + + } + + diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss new file mode 100644 index 0000000000..53b3f87be9 --- /dev/null +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss @@ -0,0 +1,249 @@ +/* + * The router container is a flex row, so the host has to be told to fill it. + * Left to size itself the page is only as wide as the card and the centring + * below has nothing to centre within. + */ +:host { + display: block; + width: 100%; +} + +.recovery-phone-page { + display: flex; + justify-content: center; + padding: var(--orcid-space-l, 32px) var(--orcid-space-base, 16px); +} + +.recovery-phone-page orcid-step-view { + display: block; + width: 100%; + max-width: 580px; +} + +.recovery-phone-notice { + display: flex; + align-items: flex-start; + gap: var(--orcid-space-base, 16px); + border: 2px solid var(--orcid-color-notice-important, #ff9c00); + border-radius: 4px; + background-color: rgba(255, 236, 171, 0.05); + padding: var(--orcid-space-base, 16px); + margin-bottom: var(--orcid-space-l, 32px); + text-align: left; + + p { + margin: 0; + } + + p + p { + margin-top: var(--orcid-space-base, 16px); + } +} + +.recovery-phone-notice__icon { + color: var(--orcid-color-notice-important, #ff9c00); + flex: 0 0 24px; + width: 24px; + height: 24px; + font-size: 24px; + line-height: 24px; +} + +.recovery-phone-notice__emphasis { + font-style: italic; +} + +.recovery-phone-subheading { + font-weight: bold; + margin: 0 0 var(--orcid-space-base, 16px); + text-align: left; +} + +.recovery-phone-current { + margin: 0 0 var(--orcid-space-base, 16px); + text-align: left; +} + +.recovery-phone-current__value { + font-weight: bold; + margin-left: var(--orcid-space-xs, 4px); +} + +.recovery-phone-help { + margin: 0 0 var(--orcid-space-base, 16px); + text-align: left; +} + +.recovery-phone-form { + display: flex; + flex-direction: column; + gap: var(--orcid-space-m, 24px); + text-align: left; +} + +/* + * The design draws the country selector and the number as one bordered field: + * a grey segment holding the flag and its chevron, then the dial code, then the + * number itself, all inside a single 40px box. The library renders the pieces + * but styles them as a plain input, so the border moves out to the container + * and the inner input gives up its own. + */ +.phone-input-shell { + display: block; + + /* + * The library exposes these, so the flag size and the tint behind it are set + * through its own variables rather than by overriding the elements: it keeps + * the flag sprite sharp and leaves hover and focus to the library. + */ + --iti-flag-width: 28px; + --iti-country-selector-bg: var(--orcid-color-background-lightest, #fafafa); + --iti-border-color: var(--orcid-color-border-subtle, #bdbdbd); + --iti-spacer-horizontal: 8px; + + ::ng-deep .iti { + display: block; + width: 100%; + } + + /* + * Never set padding-left here. The library measures the flag chip and the + * dial code and writes the left padding itself; overriding it puts the + * number on top of the dial code. + */ + ::ng-deep .iti__tel-input, + ::ng-deep input[type='tel'] { + width: 100%; + height: 40px; + box-sizing: border-box; + border: 1px solid var(--orcid-color-border-subtle, #bdbdbd); + border-radius: 2px; + padding-top: 8px; + padding-bottom: 8px; + padding-right: 8px; + font-size: 14px; + line-height: 21px; + letter-spacing: 0.25px; + color: var(--orcid-color-text-dark-high, #000000); + } + + ::ng-deep .iti__tel-input::placeholder { + color: var(--orcid-color-text-placeholder, #707070); + } + + /* + * The flag and its chevron sit on a tinted segment, divided from the number + * by a hairline; the dial code then reads as the start of the number itself. + * The tint goes on the primary block rather than the button, which the + * library paints white on hover and focus. + */ + ::ng-deep .iti__country-container { + padding: 1px; + height: 38px; + } + + ::ng-deep .iti__selected-country { + border-radius: 2px 0 0 2px; + height: 100%; + } + + ::ng-deep .iti__selected-country-primary { + border-right: 1px solid var(--orcid-color-border-subtle, #bdbdbd); + border-radius: 2px 0 0 2px; + height: 100%; + padding: 0 6px 0 8px; + display: flex; + align-items: center; + } + + /* the dial code is set like the rest of the number, not emphasised */ + ::ng-deep .iti__selected-dial-code { + padding-left: 8px; + font-size: 14px; + font-weight: 400; + line-height: 21px; + letter-spacing: 0.25px; + color: var(--orcid-color-text-dark-high, #000000); + } + + /* + * The library writes an inline height on the open list, measured before its + * own stylesheet has capped the list, so the panel comes out as tall as all + * 240-odd countries laid end to end. Letting it size to its content puts it + * back to the search box plus the scrollable list it already renders. + */ + ::ng-deep .iti__country-selector { + height: auto !important; + } +} + +/* a rejected number turns the whole field red, not just the message below it */ +.phone-input-shell--invalid { + ::ng-deep .iti__tel-input, + ::ng-deep input[type='tel'] { + border-color: var(--orcid-color-state-warning-darkest, #b71c1c); + } +} + +.recovery-phone-consent { + margin: 0; +} + +.recovery-phone-consent-links { + margin: var(--orcid-space-xs, 4px) 0 0; +} + +.recovery-phone-error { + display: block; + margin-top: var(--orcid-space-s, 8px); +} + +.send-code { + display: flex; + flex-direction: column; + gap: var(--orcid-space-s, 8px); +} + +.send-code__button { + width: 100%; +} + +.send-code__helper { + margin: 0; + text-align: center; +} + +.verification-field { + display: flex; + flex-direction: column; + gap: var(--orcid-space-s, 8px); +} + +.verification-field__label { + font-weight: bold; +} + +.verification-field__input { + height: 40px; + border: 1px solid var(--orcid-color-border-subtle, #bdbdbd); + border-radius: 2px; + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.verification-field__input:disabled { + background-color: var(--orcid-color-background-light, #eeeeee); +} + +.verification-field__helper { + display: flex; + justify-content: space-between; + gap: var(--orcid-space-s, 8px); +} + +@media (max-width: 767px) { + .recovery-phone-page { + padding: 0; + } +} diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss-theme.scss b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss-theme.scss new file mode 100644 index 0000000000..63b0d82bc3 --- /dev/null +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss-theme.scss @@ -0,0 +1,17 @@ +@use '@angular/material' as mat; +@import 'src/assets/scss/material.orcid-theme.scss'; + +@mixin recovery-phone-theme($theme) { + $primary: map-get($theme, primary); + $config: mat.m2-define-typography-config(); + + .send-code__button { + background-color: var(--orcid-color-brand-secondary-dark, #085c77); + color: #ffffff; + } + + .recovery-phone-error { + color: var(--orcid-color-state-warning-darkest, #b71c1c); + } +} +@include recovery-phone-theme($orcid-app-theme); diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.spec.ts b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.spec.ts new file mode 100644 index 0000000000..6dc6325da8 --- /dev/null +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.spec.ts @@ -0,0 +1,373 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing' +import { ReactiveFormsModule } from '@angular/forms' +import { MatDialog, MatDialogRef } from '@angular/material/dialog' +import { Router } from '@angular/router' +import { RouterTestingModule } from '@angular/router/testing' +import { EventEmitter } from '@angular/core' +import { Subject, of, throwError } from 'rxjs' +import IntlTelInput from '@intl-tel-input/angular' + +import { RecoveryPhoneComponent } from './recovery-phone.component' +import { ApplicationRoutes } from '../../../constants' +import { TogglzService } from '../../../core/togglz/togglz.service' +import { TwoFactorAuthenticationService } from '../../../core/two-factor-authentication/two-factor-authentication.service' +import { Status } from '../../../types/two-factor.endpoint' + +describe('RecoveryPhoneComponent', () => { + let component: RecoveryPhoneComponent + let fixture: ComponentFixture + let twoFactorService: jasmine.SpyObj + let togglzService: jasmine.SpyObj + let dialog: jasmine.SpyObj + let router: Router + + // Stands in for the auth challenge dialog the page opens on load + let submitAttempt: EventEmitter + let cancelAttempt: EventEmitter + let afterClosed: Subject + let dialogInstance: any + let dialogRef: any + + const status = (overrides: Partial = {}): Status => + ({ enabled: true, ...overrides }) as Status + + function build(flagEnabled = true, statusValue: Status = status()) { + togglzService.getStateOf.and.returnValue(of(flagEnabled)) + twoFactorService.checkState.and.returnValue(of(statusValue)) + fixture = TestBed.createComponent(RecoveryPhoneComponent) + component = fixture.componentInstance + fixture.detectChanges() + } + + /** Drives the challenge dialog to a pass, which is what unlocks the form. */ + function passChallenge() { + twoFactorService.verifyRecoveryPhoneChallenge.and.returnValue( + of({ success: true } as any) + ) + submitAttempt.emit() + afterClosed.next(true) + fixture.detectChanges() + } + + beforeEach(async () => { + submitAttempt = new EventEmitter() + cancelAttempt = new EventEmitter() + afterClosed = new Subject() + dialogInstance = { + submitAttempt, + cancelAttempt, + loading: false, + processBackendResponse: jasmine.createSpy('processBackendResponse'), + } + dialogRef = { + componentInstance: dialogInstance, + afterClosed: () => afterClosed.asObservable(), + close: jasmine.createSpy('close').and.callFake((value?: boolean) => { + afterClosed.next(!!value) + }), + } as unknown as MatDialogRef + + twoFactorService = jasmine.createSpyObj('TwoFactorAuthenticationService', [ + 'checkState', + 'verifyRecoveryPhoneChallenge', + 'sendRecoveryPhoneCode', + 'saveRecoveryPhone', + ]) + togglzService = jasmine.createSpyObj('TogglzService', ['getStateOf']) + dialog = jasmine.createSpyObj('MatDialog', ['open']) + dialog.open.and.returnValue(dialogRef) + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, RouterTestingModule, IntlTelInput], + declarations: [RecoveryPhoneComponent], + providers: [ + { provide: TwoFactorAuthenticationService, useValue: twoFactorService }, + { provide: TogglzService, useValue: togglzService }, + { provide: MatDialog, useValue: dialog }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents() + + router = TestBed.inject(Router) + spyOn(router, 'navigate') + }) + + it('sends the user back to account settings when the feature is off', () => { + build(false) + + expect(router.navigate).toHaveBeenCalledWith( + [ApplicationRoutes.account], + jasmine.objectContaining({ fragment: '2FA' }) + ) + expect(dialog.open).not.toHaveBeenCalled() + }) + + it('sends the user back when 2FA is not enabled', () => { + build(true, status({ enabled: false })) + + expect(router.navigate).toHaveBeenCalled() + expect(dialog.open).not.toHaveBeenCalled() + }) + + it('asks for the authentication challenge before anything else', () => { + build() + + expect(dialog.open).toHaveBeenCalled() + expect(component.title).toBe('Add a recovery phone number') + }) + + it('treats an existing number as a change rather than an addition', () => { + build(true, status({ maskedRecoveryPhoneNumber: '***********1234' })) + + expect(component.managingExistingNumber).toBeTrue() + expect(component.title).toBe('Manage your recovery phone number') + expect(component.primaryLabel).toBe('Update recovery phone number') + expect(component.maskedRecoveryPhoneNumber).toBe('***********1234') + }) + + it('leaves the page when the challenge is cancelled', () => { + build() + ;(router.navigate as jasmine.Spy).calls.reset() + + afterClosed.next(false) + + expect(router.navigate).toHaveBeenCalled() + }) + + it('reports a failed challenge back into the dialog', () => { + build() + twoFactorService.verifyRecoveryPhoneChallenge.and.returnValue( + of({ success: false, invalidPassword: true } as any) + ) + + submitAttempt.emit() + + expect(dialogInstance.processBackendResponse).toHaveBeenCalled() + expect(dialogRef.close).not.toHaveBeenCalledWith(true) + }) + + it('enables the code field and starts the countdown once a code is sent', () => { + build() + passChallenge() + twoFactorService.sendRecoveryPhoneCode.and.returnValue( + of({ success: true, resendAfterSeconds: 30 }) + ) + component.form.get('phoneNumber')?.setValue('+441234567890') + + component.sendCode() + + expect(component.codeSent).toBeTrue() + expect(component.verificationCodeControl?.enabled).toBeTrue() + expect(component.resendCountdown).toBe(30) + // the number is locked while a code is outstanding + expect(component.phoneNumberControl?.disabled).toBeTrue() + }) + + it('counts the resend buffer down and frees the number again', fakeAsync(() => { + build() + passChallenge() + twoFactorService.sendRecoveryPhoneCode.and.returnValue( + of({ success: true, resendAfterSeconds: 2 }) + ) + component.form.get('phoneNumber')?.setValue('+441234567890') + component.sendCode() + + tick(2000) + + expect(component.resendCountdown).toBe(0) + expect(component.phoneNumberControl?.enabled).toBeTrue() + component.ngOnDestroy() + })) + + it('restarts the countdown cleanly on a second send', fakeAsync(() => { + build() + passChallenge() + twoFactorService.sendRecoveryPhoneCode.and.returnValue( + of({ success: true, resendAfterSeconds: 3 }) + ) + component.form.get('phoneNumber')?.setValue('+441234567890') + component.sendCode() + tick(3000) + + component.sendCode() + tick(1000) + + // one tick, one second gone: a leftover countdown would double the rate + expect(component.resendCountdown).toBe(2) + component.ngOnDestroy() + })) + + it('names the problem the phone field already found, rather than calling it required', () => { + build() + passChallenge() + const control = component.form.get('phoneNumber') + control?.setValue('+441234') + control?.setErrors({ invalidPhone: 'IS_POSSIBLE_LOCAL_ONLY' }) + + component.sendCode() + + expect(component.phoneErrorMessage).toBe('Phone number is too short') + expect(twoFactorService.sendRecoveryPhoneCode).not.toHaveBeenCalled() + }) + + it('still asks for a number when the field is empty', () => { + build() + passChallenge() + + component.sendCode() + + expect(component.phoneErrorMessage).toBe('Phone number is required') + }) + + it('shows the length problem the server reports for the number', () => { + build() + passChallenge() + twoFactorService.sendRecoveryPhoneCode.and.returnValue( + of({ success: false, errorCode: 'PHONE_TOO_SHORT', resendAfterSeconds: 0 }) + ) + component.form.get('phoneNumber')?.setValue('+441234') + + component.sendCode() + + expect(component.phoneErrorMessage).toBe('Phone number is too short') + expect(component.codeSent).toBeFalse() + }) + + it('asks for a code before saving', () => { + build() + passChallenge() + + component.save() + + expect(component.codeErrorMessage).toBe('A verification code is required') + expect(twoFactorService.saveRecoveryPhone).not.toHaveBeenCalled() + }) + + it('rejects a code that is not six digits', () => { + build() + passChallenge() + component.verificationCodeControl?.enable() + component.verificationCodeControl?.setValue('12345') + + component.save() + + expect(component.codeErrorMessage).toBe('Invalid verification code length') + expect(twoFactorService.saveRecoveryPhone).not.toHaveBeenCalled() + }) + + it('reports a rejected code without leaving the page', () => { + build() + passChallenge() + component.verificationCodeControl?.enable() + component.verificationCodeControl?.setValue('000000') + twoFactorService.saveRecoveryPhone.and.returnValue( + of({ success: false, errorCode: 'INVALID_CODE' }) + ) + ;(router.navigate as jasmine.Spy).calls.reset() + + component.save() + + expect(component.codeErrorMessage).toBe('Invalid verification code') + expect(router.navigate).not.toHaveBeenCalled() + }) + + it('clears the code entry when it has expired', () => { + build() + passChallenge() + component.verificationCodeControl?.enable() + component.verificationCodeControl?.setValue('123456') + component.codeSent = true + twoFactorService.saveRecoveryPhone.and.returnValue( + of({ success: false, errorCode: 'CODE_EXPIRED' }) + ) + + component.save() + + expect(component.codeSent).toBeFalse() + expect(component.verificationCodeControl?.disabled).toBeTrue() + }) + + it('asks for the challenge again when the elevation has run out', () => { + build() + passChallenge() + component.verificationCodeControl?.enable() + component.verificationCodeControl?.setValue('123456') + twoFactorService.saveRecoveryPhone.and.returnValue( + of({ success: false, errorCode: 'CHALLENGE_REQUIRED' }) + ) + dialog.open.calls.reset() + + component.save() + + expect(dialog.open).toHaveBeenCalled() + // what they typed is still there + expect(component.verificationCodeControl?.value).toBe('123456') + }) + + it('returns with the added outcome after a first number is stored', () => { + build() + passChallenge() + component.verificationCodeControl?.enable() + component.verificationCodeControl?.setValue('123456') + twoFactorService.saveRecoveryPhone.and.returnValue( + of({ success: true, maskedRecoveryPhoneNumber: '***********1234' }) + ) + ;(router.navigate as jasmine.Spy).calls.reset() + + component.save() + + expect(router.navigate).toHaveBeenCalledWith( + [ApplicationRoutes.account], + jasmine.objectContaining({ queryParams: { recoveryPhone: 'added' } }) + ) + }) + + it('returns with the updated outcome after a number is changed', () => { + build(true, status({ maskedRecoveryPhoneNumber: '***********1234' })) + passChallenge() + component.verificationCodeControl?.enable() + component.verificationCodeControl?.setValue('123456') + twoFactorService.saveRecoveryPhone.and.returnValue(of({ success: true })) + ;(router.navigate as jasmine.Spy).calls.reset() + + component.save() + + expect(router.navigate).toHaveBeenCalledWith( + [ApplicationRoutes.account], + jasmine.objectContaining({ queryParams: { recoveryPhone: 'updated' } }) + ) + }) + + it('reports a failure when saving breaks', () => { + build() + passChallenge() + component.verificationCodeControl?.enable() + component.verificationCodeControl?.setValue('123456') + twoFactorService.saveRecoveryPhone.and.returnValue( + throwError(() => new Error('boom')) + ) + ;(router.navigate as jasmine.Spy).calls.reset() + + component.save() + + expect(router.navigate).toHaveBeenCalledWith( + [ApplicationRoutes.account], + jasmine.objectContaining({ queryParams: { recoveryPhone: 'failed' } }) + ) + }) + + it('leaves without saving anything when cancelled', () => { + build() + passChallenge() + ;(router.navigate as jasmine.Spy).calls.reset() + + component.cancel() + + expect(twoFactorService.saveRecoveryPhone).not.toHaveBeenCalled() + expect(router.navigate).toHaveBeenCalledWith( + [ApplicationRoutes.account], + jasmine.objectContaining({ queryParams: {} }) + ) + }) +}) diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts new file mode 100644 index 0000000000..2906d07a76 --- /dev/null +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts @@ -0,0 +1,457 @@ +import { Component, Inject, LOCALE_ID, OnDestroy, OnInit } from '@angular/core' +import { + UntypedFormBuilder, + UntypedFormGroup, + Validators, +} from '@angular/forms' +import { MatDialog, MatDialogRef } from '@angular/material/dialog' +import { Router } from '@angular/router' +import { AuthChallengeComponent } from '@orcid/registry-ui' +import { Subject, Subscription, interval } from 'rxjs' +import { first, takeUntil } from 'rxjs/operators' + +import { ApplicationRoutes } from '../../../constants' +import { TogglzService } from '../../../core/togglz/togglz.service' +import { TwoFactorAuthenticationService } from '../../../core/two-factor-authentication/two-factor-authentication.service' +import { AuthChallenge, AuthChallengeFormData } from '../../../types/common.endpoint' +import { TogglzFlag } from '../../../types/config.endpoint' +import { + RecoveryPhoneErrorCode, + Status, +} from '../../../types/two-factor.endpoint' + +@Component({ + selector: 'app-recovery-phone', + templateUrl: './recovery-phone.component.html', + styleUrls: [ + './recovery-phone.component.scss', + './recovery-phone.component.scss-theme.scss', + ], + standalone: false, +}) +export class RecoveryPhoneComponent implements OnInit, OnDestroy { + private readonly $destroy = new Subject() + + loadUtils = () => import('intl-tel-input/utils') + + form: UntypedFormGroup + challengeForm: UntypedFormGroup + + loadingState = true + /** Set once the user has a number already, which turns this into a change. */ + managingExistingNumber = false + maskedRecoveryPhoneNumber: string | undefined + + codeSent = false + resendCountdown = 0 + sending = false + saving = false + + phoneErrorMessage: string | null = null + codeErrorMessage: string | null = null + generalErrorMessage: string | null = null + + cancelLabel = $localize`:@@account.cancel:Cancel` + + /** Preselected country, taken from the region the browser reports. */ + initialCountry = 'us' + + /** Country names in the language the registry is being displayed in. */ + countryNameLocale = 'en' + + /** The phone field's own labels and search box, in the same language. */ + uiTranslations: Record | undefined + + /** + * The field reads its translations once, when it initialises, so the form + * waits for them rather than rendering an English field and updating it. + */ + translationsReady = false + + private challengeDialog: MatDialogRef | undefined + private challengePassed = false + private countdownSubscription: Subscription | undefined + + constructor( + private _router: Router, + private _dialog: MatDialog, + private _fb: UntypedFormBuilder, + private _togglz: TogglzService, + private _twoFactorAuthenticationService: TwoFactorAuthenticationService, + @Inject(LOCALE_ID) private _locale: string + ) {} + + ngOnInit(): void { + this.initialCountry = this.resolveInitialCountry() + this.loadPhoneFieldTranslations() + this.form = this._fb.group({ + phoneNumber: ['', Validators.required], + verificationCode: [ + { value: '', disabled: true }, + [Validators.required, Validators.minLength(6), Validators.maxLength(6)], + ], + }) + this.challengeForm = this._fb.group({ + password: [null, Validators.required], + twoFactorCode: [null, [Validators.minLength(6), Validators.maxLength(6)]], + twoFactorRecoveryCode: [ + null, + [Validators.minLength(10), Validators.maxLength(10)], + ], + }) + + this._togglz + .getStateOf(TogglzFlag.TWO_FACTOR_RECOVERY_PHONE) + .pipe(first(), takeUntil(this.$destroy)) + .subscribe((enabled) => { + if (!enabled) { + this.returnToAccountSettings() + return + } + this.loadStatus() + }) + } + + ngOnDestroy(): void { + this.$destroy.next() + this.$destroy.complete() + } + + get verificationCodeControl() { + return this.form.get('verificationCode') + } + + get phoneNumberControl() { + return this.form.get('phoneNumber') + } + + get title(): string { + return this.managingExistingNumber + ? $localize`:@@account.manageRecoveryPhoneTitle:Manage your recovery phone number` + : $localize`:@@account.addRecoveryPhoneTitle:Add a recovery phone number` + } + + get primaryLabel(): string { + return this.managingExistingNumber + ? $localize`:@@account.updateRecoveryPhoneNumber:Update recovery phone number` + : $localize`:@@account.addRecoveryPhoneNumber:Add recovery phone number` + } + + private get challengeDescription(): string { + return this.managingExistingNumber + ? $localize`:@@account.manageRecoveryPhoneChallenge:to manage your recovery phone number` + : $localize`:@@account.addRecoveryPhoneChallenge:to add a recovery phone number` + } + + /** + * Picks the country from the region in the browser's locale, e.g. en-GB + * gives gb. Falls back to the US when the locale carries no region. + */ + private resolveInitialCountry(): string { + const locale = navigator?.language || this._locale || '' + const region = locale.split(/[-_]/)[1] + return region && region.length === 2 ? region.toLowerCase() : 'us' + } + + /** + * The phone field ships its own translations, keyed by language rather than + * by the locale ids the registry uses, so Chinese needs mapping and anything + * it does not carry falls back to English rather than rendering blank. + */ + private loadPhoneFieldTranslations(): void { + const language = this.resolvePhoneFieldLanguage() + this.countryNameLocale = language + if (language === 'en') { + this.translationsReady = true + return + } + // The whole set is imported and one language picked out of it: a template + // path cannot be resolved through the package's export map at build time + import('intl-tel-input/locale') + .then((locales) => { + const translations = (locales as Record)[language] + if (translations) { + this.uiTranslations = translations as Record + } else { + this.countryNameLocale = 'en' + } + this.translationsReady = true + }) + .catch(() => { + // English is already the field's default, so nothing to undo + this.countryNameLocale = 'en' + this.translationsReady = true + }) + } + + private resolvePhoneFieldLanguage(): string { + const locale = (this._locale || 'en').toLowerCase() + if (locale.startsWith('zh')) { + // The field carries simplified Chinese as zh and traditional as zh-hk + return locale === 'zh-tw' || locale === 'zh-hant' ? 'zh-hk' : 'zh' + } + return locale.split('-')[0] + } + + private loadStatus(): void { + this._twoFactorAuthenticationService + .checkState() + .pipe(first(), takeUntil(this.$destroy)) + .subscribe({ + next: (status: Status) => { + if (!status?.enabled) { + // Nothing to back up when 2FA is off + this.returnToAccountSettings() + return + } + this.managingExistingNumber = !!status.maskedRecoveryPhoneNumber + this.maskedRecoveryPhoneNumber = status.maskedRecoveryPhoneNumber + this.loadingState = false + this.openAuthChallenge() + }, + error: () => this.returnToAccountSettings(), + }) + } + + /** + * The page is unusable until the challenge passes. The server enforces this + * too, so a deep link cannot get past it. + */ + private openAuthChallenge(): void { + this.challengeDialog = this._dialog.open( + AuthChallengeComponent, + { + disableClose: true, + data: { + parentForm: this.challengeForm, + actionDescription: this.challengeDescription, + } as AuthChallengeFormData, + } + ) + + this.challengeDialog.componentInstance.submitAttempt + .pipe(takeUntil(this.challengeDialog.afterClosed())) + .subscribe(() => this.submitAuthChallenge()) + + this.challengeDialog.componentInstance.cancelAttempt + .pipe(takeUntil(this.challengeDialog.afterClosed())) + .subscribe(() => this.challengeDialog?.close(false)) + + this.challengeDialog.afterClosed().subscribe((passed) => { + this.challengeDialog = undefined + if (passed) { + this.challengePassed = true + } else if (!this.challengePassed) { + this.returnToAccountSettings() + } + }) + } + + private submitAuthChallenge(): void { + const dialogRef = this.challengeDialog + if (!dialogRef) { + return + } + this._twoFactorAuthenticationService + .verifyRecoveryPhoneChallenge(this.challengeForm.value) + .pipe(first()) + .subscribe({ + next: (response: AuthChallenge) => { + if (response.success) { + dialogRef.close(true) + } else { + dialogRef.componentInstance.loading = false + dialogRef.componentInstance.processBackendResponse(response) + } + }, + error: () => { + dialogRef.componentInstance.loading = false + }, + }) + } + + sendCode(): void { + this.clearErrors() + if (this.phoneNumberControl?.invalid) { + this.phoneNumberControl.markAsTouched() + this.phoneErrorMessage = this.localPhoneErrorMessage() + return + } + + this.sending = true + this._twoFactorAuthenticationService + .sendRecoveryPhoneCode({ + phoneNumber: this.phoneNumberControl?.value, + locale: this._locale, + }) + .pipe(first()) + .subscribe({ + next: (response) => { + this.sending = false + if (response.success) { + this.codeSent = true + this.verificationCodeControl?.enable() + this.startResendCountdown(response.resendAfterSeconds) + } else { + this.handleErrorCode(response.errorCode, response.resendAfterSeconds) + } + }, + error: () => { + this.sending = false + this.generalErrorMessage = $localize`:@@account.recoveryPhoneSendFailed:We could not send a verification code. Please try again.` + }, + }) + } + + save(): void { + this.clearErrors() + // A disabled control counts as valid, so check the value we actually have + const code: string = this.verificationCodeControl?.value || '' + if (!code) { + this.verificationCodeControl?.markAsTouched() + this.codeErrorMessage = $localize`:@@account.verificationCodeRequired:A verification code is required` + return + } + if (code.length !== 6) { + this.verificationCodeControl?.markAsTouched() + this.codeErrorMessage = $localize`:@@account.invalidVerificationCodeLength:Invalid verification code length` + return + } + + this.saving = true + this._twoFactorAuthenticationService + .saveRecoveryPhone({ + phoneNumber: this.phoneNumberControl?.value, + verificationCode: code, + }) + .pipe(first()) + .subscribe({ + next: (response) => { + this.saving = false + if (response.success) { + this.returnToAccountSettings( + this.managingExistingNumber ? 'updated' : 'added' + ) + } else { + this.handleErrorCode(response.errorCode) + } + }, + error: () => { + this.saving = false + this.returnToAccountSettings('failed') + }, + }) + } + + cancel(): void { + this.returnToAccountSettings() + } + + /** + * Counts down from the server's own resend buffer, so the button re-enables + * at the same moment the backend starts accepting another send. + */ + private startResendCountdown(seconds: number): void { + // Drop any countdown still running, or two of them would race and the + // button would come back before the server accepts another send + this.countdownSubscription?.unsubscribe() + this.resendCountdown = seconds + if (seconds <= 0) { + return + } + this.phoneNumberControl?.disable() + this.countdownSubscription = interval(1000) + .pipe(takeUntil(this.$destroy)) + .subscribe(() => { + this.resendCountdown-- + if (this.resendCountdown <= 0) { + this.resendCountdown = 0 + this.countdownSubscription?.unsubscribe() + this.phoneNumberControl?.enable() + } + }) + } + + /** + * The phone field validates as you type and knows why a number is wrong, so + * say which problem it is rather than sending everything to the server and + * calling it all "required". + */ + private localPhoneErrorMessage(): string { + const control = this.phoneNumberControl + if (!control?.value) { + return $localize`:@@account.recoveryPhoneRequired:Phone number is required` + } + const reason = String(control.errors?.['invalidPhone'] ?? '') + if (reason.includes('TOO_SHORT') || reason.includes('LOCAL_ONLY') || reason.includes('INVALID_LENGTH')) { + return $localize`:@@account.recoveryPhoneTooShort:Phone number is too short` + } + if (reason.includes('TOO_LONG')) { + return $localize`:@@account.recoveryPhoneTooLong:Phone number is too long` + } + return $localize`:@@account.recoveryPhoneInvalid:Phone number is invalid` + } + + private handleErrorCode( + errorCode: RecoveryPhoneErrorCode | undefined, + resendAfterSeconds = 0 + ): void { + switch (errorCode) { + case 'PHONE_TOO_SHORT': + this.phoneErrorMessage = $localize`:@@account.recoveryPhoneTooShort:Phone number is too short` + break + case 'PHONE_TOO_LONG': + this.phoneErrorMessage = $localize`:@@account.recoveryPhoneTooLong:Phone number is too long` + break + case 'INVALID_PHONE_NUMBER': + this.phoneErrorMessage = $localize`:@@account.recoveryPhoneInvalid:Phone number is invalid` + break + case 'PHONE_MISMATCH': + this.codeErrorMessage = $localize`:@@account.recoveryPhoneMismatch:This code was sent to a different number. Send a new code to this number.` + break + case 'RESEND_TOO_SOON': + this.startResendCountdown(resendAfterSeconds) + break + case 'INVALID_CODE': + this.codeErrorMessage = $localize`:@@account.invalidVerificationCode:Invalid verification code` + break + case 'CODE_EXPIRED': + case 'TOO_MANY_ATTEMPTS': + this.resetCodeEntry() + this.codeErrorMessage = $localize`:@@account.verificationCodeExpired:That code is no longer valid. Send a new code.` + break + case 'CHALLENGE_REQUIRED': + // The elevation ran out mid form; ask again and keep what they typed + this.challengePassed = false + this.openAuthChallenge() + break + case '2FA_DISABLED': + case 'FEATURE_DISABLED': + this.returnToAccountSettings('failed') + break + default: + this.generalErrorMessage = $localize`:@@account.recoveryPhoneGenericError:Something went wrong. Please try again.` + } + } + + private resetCodeEntry(): void { + this.codeSent = false + this.resendCountdown = 0 + this.verificationCodeControl?.reset('') + this.verificationCodeControl?.disable() + this.phoneNumberControl?.enable() + } + + private clearErrors(): void { + this.phoneErrorMessage = null + this.codeErrorMessage = null + this.generalErrorMessage = null + } + + private returnToAccountSettings(outcome?: 'added' | 'updated' | 'failed'): void { + this._router.navigate([ApplicationRoutes.account], { + queryParams: outcome ? { recoveryPhone: outcome } : {}, + fragment: '2FA', + }) + } +} diff --git a/src/app/two-factor-recovery-phone/two-factor-recovery-phone-routing.module.ts b/src/app/two-factor-recovery-phone/two-factor-recovery-phone-routing.module.ts new file mode 100644 index 0000000000..bf8cbdae1b --- /dev/null +++ b/src/app/two-factor-recovery-phone/two-factor-recovery-phone-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { RecoveryPhoneComponent } from './pages/recovery-phone/recovery-phone.component' + +const routes: Routes = [ + { + path: '', + component: RecoveryPhoneComponent, + }, +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class TwoFactorRecoveryPhoneRoutingModule {} diff --git a/src/app/two-factor-recovery-phone/two-factor-recovery-phone.module.ts b/src/app/two-factor-recovery-phone/two-factor-recovery-phone.module.ts new file mode 100644 index 0000000000..f509852fce --- /dev/null +++ b/src/app/two-factor-recovery-phone/two-factor-recovery-phone.module.ts @@ -0,0 +1,32 @@ +import { CommonModule } from '@angular/common' +import { NgModule } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatDialogModule } from '@angular/material/dialog' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import IntlTelInput from '@intl-tel-input/angular' +import { AlertMessageComponent, OrcidStepViewComponent } from '@orcid/ui' + +import { RecoveryPhoneComponent } from './pages/recovery-phone/recovery-phone.component' +import { TwoFactorRecoveryPhoneRoutingModule } from './two-factor-recovery-phone-routing.module' + +@NgModule({ + declarations: [RecoveryPhoneComponent], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + TwoFactorRecoveryPhoneRoutingModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + IntlTelInput, + AlertMessageComponent, + OrcidStepViewComponent, + ], +}) +export class TwoFactorRecoveryPhoneModule {} diff --git a/src/app/types/config.endpoint.ts b/src/app/types/config.endpoint.ts index 8c2549b5e5..2eb67d3d6a 100644 --- a/src/app/types/config.endpoint.ts +++ b/src/app/types/config.endpoint.ts @@ -17,6 +17,7 @@ export const TogglzFlag = { PERMISSION_NOTIFICATIONS: 'PERMISSION_NOTIFICATIONS', SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS: 'SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS', + TWO_FACTOR_RECOVERY_PHONE: 'TWO_FACTOR_RECOVERY_PHONE', /** Real User Monitoring / session recording (supports percentage 0–100 via FEATURE_PERCENTAGE). */ RUM: 'RUM', } as const diff --git a/src/app/types/two-factor.endpoint.ts b/src/app/types/two-factor.endpoint.ts index 6266007f2a..407a724201 100644 --- a/src/app/types/two-factor.endpoint.ts +++ b/src/app/types/two-factor.endpoint.ts @@ -22,4 +22,51 @@ export interface Status extends AuthChallenge { enabled: boolean twoFactorCreationDate: ExtendedDate recoveryCodeCreationDate: ExtendedDate + // Only ever the last four digits: the registry cannot read the number back + maskedRecoveryPhoneNumber?: string + recoveryPhoneCreationDate?: ExtendedDate + recoveryPhoneLastModifiedDate?: ExtendedDate + recoveryPhoneModified?: boolean +} + +export type RecoveryPhoneErrorCode = + | 'INVALID_PHONE_NUMBER' + | 'PHONE_TOO_SHORT' + | 'PHONE_TOO_LONG' + | 'RESEND_TOO_SOON' + | 'SMS_SEND_FAILED' + | 'SMS_RECIPIENT_NOT_ALLOWED' + | 'SMS_PROVIDER_NOT_CONFIGURED' + | 'CODE_STORAGE_UNAVAILABLE' + | 'INVALID_CODE' + | 'CODE_EXPIRED' + | 'TOO_MANY_ATTEMPTS' + | 'PHONE_MISMATCH' + | 'CHALLENGE_REQUIRED' + | '2FA_DISABLED' + | 'FEATURE_DISABLED' + +export interface RecoveryPhoneSendCodeRequest { + phoneNumber: string + locale?: string +} + +export interface RecoveryPhoneSendCodeResponse { + success: boolean + errorCode?: RecoveryPhoneErrorCode + resendAfterSeconds: number +} + +export interface RecoveryPhoneSaveRequest { + phoneNumber: string + verificationCode: string +} + +export interface RecoveryPhoneSaveResponse { + success: boolean + errorCode?: RecoveryPhoneErrorCode + maskedRecoveryPhoneNumber?: string + recoveryPhoneCreationDate?: ExtendedDate + recoveryPhoneLastModifiedDate?: ExtendedDate + recoveryPhoneModified?: boolean } diff --git a/src/locale/messages.lr.xlf b/src/locale/messages.lr.xlf index 7a43809835..aa7e57caa0 100644 --- a/src/locale/messages.lr.xlf +++ b/src/locale/messages.lr.xlf @@ -333,7 +333,7 @@ Verify my account and continue projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html - 286,288 + 287,289 LR @@ -341,7 +341,7 @@ Cancel account verification projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html - 296,298 + 298,300 LR @@ -1554,11 +1554,35 @@ LR + + Your recovery phone number has been added + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 7 + + LR + + + Your recovery phone number has been updated + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 11 + + LR + + + Your recovery phone number was not updated + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 18,20 + + LR + Two-factor authentication has been disabled src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 4,6 + 24,26 LR @@ -1566,7 +1590,7 @@ Two-factor authentication was not disabled src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 10,12 + 30,32 LR @@ -1574,7 +1598,7 @@ Add extra security to your ORCID account by enabling two-factor authentication (2FA). src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 16,19 + 36,39 LR @@ -1586,7 +1610,11 @@ src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 26,28 + 46,48 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 203,205 src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.html @@ -1598,7 +1626,7 @@ Enable two-factor authentication src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 37,39 + 57,59 src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.html @@ -1618,7 +1646,7 @@ Sign in with 2FA src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 44 + 64 LR @@ -1626,7 +1654,7 @@ Use your authentication app to generate a verification code whenever you sign in to ORCID. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 48,51 + 68,71 LR @@ -1634,7 +1662,31 @@ Authentication app src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 61,63 + 81,83 + + LR + + + Enabled: + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 86 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 137 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 177 + + LR + + + 2FA backup + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 98 LR @@ -1642,7 +1694,7 @@ Account recovery src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 74 + 100 LR @@ -1650,7 +1702,43 @@ Recover access to your ORCID account if you can't use your authentication app. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 78,81 + 105,108 + + LR + + + Recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 124 + + LR + + + Modified: + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 135 + + LR + + + Manage recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 151 + + LR + + + Add recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 155 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 137 LR @@ -1658,7 +1746,19 @@ 2FA recovery codes src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 91,93 + 172,174 + + LR + + + Disable two-factor authentication + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 188,190 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 225,227 LR @@ -1666,7 +1766,7 @@ You can disable two-factor authentication at any time. Turning 2FA off will reset any account recovery options you have set up. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 103,106 + 192,195 LR @@ -1678,15 +1778,7 @@ src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 113,115 - - LR - - - Disable two-factor authentication - - src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 123,125 + 214,216 LR @@ -1694,7 +1786,7 @@ to disable 2FA src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.ts - 32 + 44 LR @@ -6431,7 +6523,7 @@ ORCID src/app/constants.ts - 123 + 124 LR @@ -6439,11 +6531,15 @@ 2FA - ORCID src/app/constants.ts - 124 + 125 src/app/constants.ts - 125 + 126 + + + src/app/constants.ts + 127 LR @@ -6451,7 +6547,7 @@ Institutional linking - ORCID src/app/constants.ts - 126 + 128 LR @@ -6459,7 +6555,7 @@ Social linking - ORCID src/app/constants.ts - 127 + 129 LR @@ -6467,7 +6563,7 @@ Institutional sign in - ORCID src/app/constants.ts - 128 + 130 LR @@ -6475,7 +6571,7 @@ Notifications inbox - ORCID src/app/constants.ts - 129 + 131 LR @@ -6483,15 +6579,15 @@ Sign in - ORCID src/app/constants.ts - 130 + 132 src/app/constants.ts - 131 + 133 src/app/constants.ts - 137 + 139 LR @@ -6499,7 +6595,7 @@ Oauth - ORCID src/app/constants.ts - 132 + 134 LR @@ -6507,7 +6603,7 @@ Search - ORCID src/app/constants.ts - 133 + 135 LR @@ -6515,7 +6611,7 @@ Account reactivation - ORCID src/app/constants.ts - 134 + 136 LR @@ -6523,7 +6619,7 @@ Reset password - ORCID src/app/constants.ts - 135 + 137 LR @@ -6531,7 +6627,7 @@ Register - ORCID src/app/constants.ts - 136 + 138 LR @@ -6539,7 +6635,7 @@ Account settings - ORCID src/app/constants.ts - 138 + 140 LR @@ -6547,7 +6643,7 @@ Deactivate account - ORCID src/app/constants.ts - 139 + 141 LR @@ -6555,7 +6651,7 @@ Trusted parties - ORCID src/app/constants.ts - 140 + 142 LR @@ -6563,7 +6659,7 @@ Reset password - ORCID src/app/constants.ts - 141 + 143 LR @@ -6571,7 +6667,7 @@ Self Service - ORCID src/app/constants.ts - 142 + 144 LR @@ -6579,7 +6675,7 @@ Developer tools - ORCID src/app/constants.ts - 143 + 145 LR @@ -6587,11 +6683,11 @@ Record corrections - ORCID src/app/constants.ts - 145 + 147 src/app/constants.ts - 146 + 148 LR @@ -6599,7 +6695,7 @@ - ORCID src/app/constants.ts - 150 + 152 LR @@ -6607,7 +6703,7 @@ - My ORCID src/app/constants.ts - 151 + 153 LR @@ -6615,7 +6711,7 @@ Manage employment dialog src/app/constants.ts - 376 + 378 LR @@ -6623,7 +6719,7 @@ Manage education dialog src/app/constants.ts - 378 + 380 LR @@ -6631,7 +6727,7 @@ Manage qualification dialog src/app/constants.ts - 380 + 382 LR @@ -6639,7 +6735,7 @@ Manage distinction dialog src/app/constants.ts - 382 + 384 LR @@ -6647,7 +6743,7 @@ Manage invited position dialog src/app/constants.ts - 384 + 386 LR @@ -6655,7 +6751,7 @@ Manage membership dialog src/app/constants.ts - 386 + 388 LR @@ -6663,7 +6759,7 @@ Manage service dialog src/app/constants.ts - 388 + 390 LR @@ -6671,7 +6767,7 @@ Manage editorial service dialog src/app/constants.ts - 390 + 392 LR @@ -6679,7 +6775,7 @@ Manage bibtex dialog src/app/constants.ts - 393 + 395 LR @@ -6687,7 +6783,7 @@ Manage external identifier dialog src/app/constants.ts - 395 + 397 LR @@ -6695,7 +6791,7 @@ Manage your names dialog src/app/constants.ts - 397 + 399 LR @@ -6703,7 +6799,7 @@ Manage your biography dialog src/app/constants.ts - 399 + 401 LR @@ -6711,7 +6807,7 @@ Manage your emails dialog src/app/constants.ts - 401 + 403 LR @@ -6719,7 +6815,7 @@ Manage your countries dialog src/app/constants.ts - 403 + 405 LR @@ -6727,7 +6823,7 @@ Manage your keywords dialog src/app/constants.ts - 405 + 407 LR @@ -6735,7 +6831,7 @@ Manage your other IDs dialog src/app/constants.ts - 407 + 409 LR @@ -6743,7 +6839,7 @@ Manage your websites & social links dialog src/app/constants.ts - 409 + 411 LR @@ -6751,7 +6847,7 @@ Manage funding dialog src/app/constants.ts - 411 + 413 LR @@ -6759,7 +6855,7 @@ Manage funding search dialog src/app/constants.ts - 413 + 415 LR @@ -6767,7 +6863,7 @@ Manage work dialog src/app/constants.ts - 415 + 417 LR @@ -6775,7 +6871,7 @@ Manage work search dialog src/app/constants.ts - 417 + 419 LR @@ -6783,7 +6879,7 @@ Manage peer review dialog src/app/constants.ts - 419 + 421 LR @@ -19415,6 +19511,258 @@ LR + + Your recovery phone number can be used to access your ORCID account if you don't have your authentication app or recovery codes. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 20,23 + + LR + + + Your recovery phone number can only be used once. Using it will disable two-factor authentication and reset your 2FA backup options. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 27,31 + + LR + + + Your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 38,40 + + LR + + + Your current recovery phone number is + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 45 + + LR + + + Select a country or location and enter your phone number. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 56,58 + + LR + + + By entering my phone number and clicking 'Send verification code', I consent to receive an automated one-time verification code from ORCID at the number provided. Message and data rates may apply. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 90,94 + + LR + + + Terms of use + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 101 + + LR + + + Privacy policy + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 109 + + LR + + + Send verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 122,124 + + LR + + + You can resend in + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 128 + + LR + + + seconds + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 131 + + LR + + + Verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 141 + + LR + + + Enter the 6-digit verification code sent to your device + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 156 + + LR + + + Cancel + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 54 + + LR + + + Manage your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 130 + + LR + + + Add a recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 131 + + LR + + + Update recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 136 + + LR + + + to manage your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 142 + + LR + + + to add a recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 143 + + LR + + + We could not send a verification code. Please try again. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 301 + + LR + + + A verification code is required + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 312 + + LR + + + Invalid verification code length + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 317 + + LR + + + Phone number is required + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 383 + + LR + + + Phone number is too short + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 387 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 401 + + LR + + + Phone number is too long + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 390 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 404 + + LR + + + Phone number is invalid + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 392 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 407 + + LR + + + This code was sent to a different number. Send a new code to this number. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 410 + + LR + + + Invalid verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 416 + + LR + + + That code is no longer valid. Send a new code. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 421 + + LR + + + Something went wrong. Please try again. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 433 + + LR + Step 1 of 2 - Authentication app diff --git a/src/locale/messages.rl.xlf b/src/locale/messages.rl.xlf index ea204007a3..670a40378e 100644 --- a/src/locale/messages.rl.xlf +++ b/src/locale/messages.rl.xlf @@ -333,7 +333,7 @@ Verify my account and continue projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html - 286,288 + 287,289 RL @@ -341,7 +341,7 @@ Cancel account verification projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html - 296,298 + 298,300 RL @@ -1554,11 +1554,35 @@ RL + + Your recovery phone number has been added + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 7 + + RL + + + Your recovery phone number has been updated + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 11 + + RL + + + Your recovery phone number was not updated + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 18,20 + + RL + Two-factor authentication has been disabled src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 4,6 + 24,26 RL @@ -1566,7 +1590,7 @@ Two-factor authentication was not disabled src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 10,12 + 30,32 RL @@ -1574,7 +1598,7 @@ Add extra security to your ORCID account by enabling two-factor authentication (2FA). src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 16,19 + 36,39 RL @@ -1586,7 +1610,11 @@ src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 26,28 + 46,48 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 203,205 src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.html @@ -1598,7 +1626,7 @@ Enable two-factor authentication src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 37,39 + 57,59 src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.html @@ -1618,7 +1646,7 @@ Sign in with 2FA src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 44 + 64 RL @@ -1626,7 +1654,7 @@ Use your authentication app to generate a verification code whenever you sign in to ORCID. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 48,51 + 68,71 RL @@ -1634,7 +1662,31 @@ Authentication app src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 61,63 + 81,83 + + RL + + + Enabled: + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 86 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 137 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 177 + + RL + + + 2FA backup + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 98 RL @@ -1642,7 +1694,7 @@ Account recovery src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 74 + 100 RL @@ -1650,7 +1702,43 @@ Recover access to your ORCID account if you can't use your authentication app. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 78,81 + 105,108 + + RL + + + Recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 124 + + RL + + + Modified: + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 135 + + RL + + + Manage recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 151 + + RL + + + Add recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 155 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 137 RL @@ -1658,7 +1746,19 @@ 2FA recovery codes src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 91,93 + 172,174 + + RL + + + Disable two-factor authentication + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 188,190 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 225,227 RL @@ -1666,7 +1766,7 @@ You can disable two-factor authentication at any time. Turning 2FA off will reset any account recovery options you have set up. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 103,106 + 192,195 RL @@ -1678,15 +1778,7 @@ src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 113,115 - - RL - - - Disable two-factor authentication - - src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 123,125 + 214,216 RL @@ -1694,7 +1786,7 @@ to disable 2FA src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.ts - 32 + 44 RL @@ -6431,7 +6523,7 @@ ORCID src/app/constants.ts - 123 + 124 RL @@ -6439,11 +6531,15 @@ 2FA - ORCID src/app/constants.ts - 124 + 125 src/app/constants.ts - 125 + 126 + + + src/app/constants.ts + 127 RL @@ -6451,7 +6547,7 @@ Institutional linking - ORCID src/app/constants.ts - 126 + 128 RL @@ -6459,7 +6555,7 @@ Social linking - ORCID src/app/constants.ts - 127 + 129 RL @@ -6467,7 +6563,7 @@ Institutional sign in - ORCID src/app/constants.ts - 128 + 130 RL @@ -6475,7 +6571,7 @@ Notifications inbox - ORCID src/app/constants.ts - 129 + 131 RL @@ -6483,15 +6579,15 @@ Sign in - ORCID src/app/constants.ts - 130 + 132 src/app/constants.ts - 131 + 133 src/app/constants.ts - 137 + 139 RL @@ -6499,7 +6595,7 @@ Oauth - ORCID src/app/constants.ts - 132 + 134 RL @@ -6507,7 +6603,7 @@ Search - ORCID src/app/constants.ts - 133 + 135 RL @@ -6515,7 +6611,7 @@ Account reactivation - ORCID src/app/constants.ts - 134 + 136 RL @@ -6523,7 +6619,7 @@ Reset password - ORCID src/app/constants.ts - 135 + 137 RL @@ -6531,7 +6627,7 @@ Register - ORCID src/app/constants.ts - 136 + 138 RL @@ -6539,7 +6635,7 @@ Account settings - ORCID src/app/constants.ts - 138 + 140 RL @@ -6547,7 +6643,7 @@ Deactivate account - ORCID src/app/constants.ts - 139 + 141 RL @@ -6555,7 +6651,7 @@ Trusted parties - ORCID src/app/constants.ts - 140 + 142 RL @@ -6563,7 +6659,7 @@ Reset password - ORCID src/app/constants.ts - 141 + 143 RL @@ -6571,7 +6667,7 @@ Self Service - ORCID src/app/constants.ts - 142 + 144 RL @@ -6579,7 +6675,7 @@ Developer tools - ORCID src/app/constants.ts - 143 + 145 RL @@ -6587,11 +6683,11 @@ Record corrections - ORCID src/app/constants.ts - 145 + 147 src/app/constants.ts - 146 + 148 RL @@ -6599,7 +6695,7 @@ - ORCID src/app/constants.ts - 150 + 152 RL @@ -6607,7 +6703,7 @@ - My ORCID src/app/constants.ts - 151 + 153 RL @@ -6615,7 +6711,7 @@ Manage employment dialog src/app/constants.ts - 376 + 378 RL @@ -6623,7 +6719,7 @@ Manage education dialog src/app/constants.ts - 378 + 380 RL @@ -6631,7 +6727,7 @@ Manage qualification dialog src/app/constants.ts - 380 + 382 RL @@ -6639,7 +6735,7 @@ Manage distinction dialog src/app/constants.ts - 382 + 384 RL @@ -6647,7 +6743,7 @@ Manage invited position dialog src/app/constants.ts - 384 + 386 RL @@ -6655,7 +6751,7 @@ Manage membership dialog src/app/constants.ts - 386 + 388 RL @@ -6663,7 +6759,7 @@ Manage service dialog src/app/constants.ts - 388 + 390 RL @@ -6671,7 +6767,7 @@ Manage editorial service dialog src/app/constants.ts - 390 + 392 RL @@ -6679,7 +6775,7 @@ Manage bibtex dialog src/app/constants.ts - 393 + 395 RL @@ -6687,7 +6783,7 @@ Manage external identifier dialog src/app/constants.ts - 395 + 397 RL @@ -6695,7 +6791,7 @@ Manage your names dialog src/app/constants.ts - 397 + 399 RL @@ -6703,7 +6799,7 @@ Manage your biography dialog src/app/constants.ts - 399 + 401 RL @@ -6711,7 +6807,7 @@ Manage your emails dialog src/app/constants.ts - 401 + 403 RL @@ -6719,7 +6815,7 @@ Manage your countries dialog src/app/constants.ts - 403 + 405 RL @@ -6727,7 +6823,7 @@ Manage your keywords dialog src/app/constants.ts - 405 + 407 RL @@ -6735,7 +6831,7 @@ Manage your other IDs dialog src/app/constants.ts - 407 + 409 RL @@ -6743,7 +6839,7 @@ Manage your websites & social links dialog src/app/constants.ts - 409 + 411 RL @@ -6751,7 +6847,7 @@ Manage funding dialog src/app/constants.ts - 411 + 413 RL @@ -6759,7 +6855,7 @@ Manage funding search dialog src/app/constants.ts - 413 + 415 RL @@ -6767,7 +6863,7 @@ Manage work dialog src/app/constants.ts - 415 + 417 RL @@ -6775,7 +6871,7 @@ Manage work search dialog src/app/constants.ts - 417 + 419 RL @@ -6783,7 +6879,7 @@ Manage peer review dialog src/app/constants.ts - 419 + 421 RL @@ -19415,6 +19511,258 @@ RL + + Your recovery phone number can be used to access your ORCID account if you don't have your authentication app or recovery codes. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 20,23 + + RL + + + Your recovery phone number can only be used once. Using it will disable two-factor authentication and reset your 2FA backup options. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 27,31 + + RL + + + Your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 38,40 + + RL + + + Your current recovery phone number is + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 45 + + RL + + + Select a country or location and enter your phone number. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 56,58 + + RL + + + By entering my phone number and clicking 'Send verification code', I consent to receive an automated one-time verification code from ORCID at the number provided. Message and data rates may apply. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 90,94 + + RL + + + Terms of use + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 101 + + RL + + + Privacy policy + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 109 + + RL + + + Send verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 122,124 + + RL + + + You can resend in + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 128 + + RL + + + seconds + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 131 + + RL + + + Verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 141 + + RL + + + Enter the 6-digit verification code sent to your device + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 156 + + RL + + + Cancel + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 54 + + RL + + + Manage your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 130 + + RL + + + Add a recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 131 + + RL + + + Update recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 136 + + RL + + + to manage your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 142 + + RL + + + to add a recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 143 + + RL + + + We could not send a verification code. Please try again. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 301 + + RL + + + A verification code is required + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 312 + + RL + + + Invalid verification code length + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 317 + + RL + + + Phone number is required + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 383 + + RL + + + Phone number is too short + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 387 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 401 + + RL + + + Phone number is too long + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 390 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 404 + + RL + + + Phone number is invalid + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 392 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 407 + + RL + + + This code was sent to a different number. Send a new code to this number. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 410 + + RL + + + Invalid verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 416 + + RL + + + That code is no longer valid. Send a new code. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 421 + + RL + + + Something went wrong. Please try again. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 433 + + RL + Step 1 of 2 - Authentication app diff --git a/src/locale/messages.xlf b/src/locale/messages.xlf index 2844f2434b..9c3eabe8c8 100644 --- a/src/locale/messages.xlf +++ b/src/locale/messages.xlf @@ -309,14 +309,14 @@ Verify my account and continue projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html - 286,288 + 287,289 Cancel account verification projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html - 296,298 + 298,300 @@ -1419,25 +1419,46 @@ 51 + + Your recovery phone number has been added + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 7 + + + + Your recovery phone number has been updated + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 11 + + + + Your recovery phone number was not updated + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 18,20 + + Two-factor authentication has been disabled src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 4,6 + 24,26 Two-factor authentication was not disabled src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 10,12 + 30,32 Add extra security to your ORCID account by enabling two-factor authentication (2FA). src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 16,19 + 36,39 @@ -1448,7 +1469,11 @@ src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 26,28 + 46,48 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 203,205 src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.html @@ -1459,7 +1484,7 @@ Enable two-factor authentication src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 37,39 + 57,59 src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.html @@ -1478,49 +1503,114 @@ Sign in with 2FA src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 44 + 64 Use your authentication app to generate a verification code whenever you sign in to ORCID. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 48,51 + 68,71 Authentication app src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 61,63 + 81,83 + + + + Enabled: + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 86 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 137 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 177 + + + + 2FA backup + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 98 Account recovery src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 74 + 100 Recover access to your ORCID account if you can't use your authentication app. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 78,81 + 105,108 + + + + Recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 124 + + + + Modified: + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 135 + + + + Manage recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 151 + + + + Add recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 155 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 137 2FA recovery codes src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 91,93 + 172,174 + + + + Disable two-factor authentication + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 188,190 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 225,227 You can disable two-factor authentication at any time. Turning 2FA off will reset any account recovery options you have set up. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 103,106 + 192,195 @@ -1531,21 +1621,14 @@ src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 113,115 - - - - Disable two-factor authentication - - src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 123,125 + 214,216 to disable 2FA src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.ts - 32 + 44 @@ -5833,317 +5916,321 @@ ORCID src/app/constants.ts - 123 + 124 2FA - ORCID src/app/constants.ts - 124 + 125 src/app/constants.ts - 125 + 126 + + + src/app/constants.ts + 127 Institutional linking - ORCID src/app/constants.ts - 126 + 128 Social linking - ORCID src/app/constants.ts - 127 + 129 Institutional sign in - ORCID src/app/constants.ts - 128 + 130 Notifications inbox - ORCID src/app/constants.ts - 129 + 131 Sign in - ORCID src/app/constants.ts - 130 + 132 src/app/constants.ts - 131 + 133 src/app/constants.ts - 137 + 139 Oauth - ORCID src/app/constants.ts - 132 + 134 Search - ORCID src/app/constants.ts - 133 + 135 Account reactivation - ORCID src/app/constants.ts - 134 + 136 Reset password - ORCID src/app/constants.ts - 135 + 137 Register - ORCID src/app/constants.ts - 136 + 138 Account settings - ORCID src/app/constants.ts - 138 + 140 Deactivate account - ORCID src/app/constants.ts - 139 + 141 Trusted parties - ORCID src/app/constants.ts - 140 + 142 Reset password - ORCID src/app/constants.ts - 141 + 143 Self Service - ORCID src/app/constants.ts - 142 + 144 Developer tools - ORCID src/app/constants.ts - 143 + 145 Record corrections - ORCID src/app/constants.ts - 145 + 147 src/app/constants.ts - 146 + 148 - ORCID src/app/constants.ts - 150 + 152 - My ORCID src/app/constants.ts - 151 + 153 Manage employment dialog src/app/constants.ts - 376 + 378 Manage education dialog src/app/constants.ts - 378 + 380 Manage qualification dialog src/app/constants.ts - 380 + 382 Manage distinction dialog src/app/constants.ts - 382 + 384 Manage invited position dialog src/app/constants.ts - 384 + 386 Manage membership dialog src/app/constants.ts - 386 + 388 Manage service dialog src/app/constants.ts - 388 + 390 Manage editorial service dialog src/app/constants.ts - 390 + 392 Manage bibtex dialog src/app/constants.ts - 393 + 395 Manage external identifier dialog src/app/constants.ts - 395 + 397 Manage your names dialog src/app/constants.ts - 397 + 399 Manage your biography dialog src/app/constants.ts - 399 + 401 Manage your emails dialog src/app/constants.ts - 401 + 403 Manage your countries dialog src/app/constants.ts - 403 + 405 Manage your keywords dialog src/app/constants.ts - 405 + 407 Manage your other IDs dialog src/app/constants.ts - 407 + 409 Manage your websites & social links dialog src/app/constants.ts - 409 + 411 Manage funding dialog src/app/constants.ts - 411 + 413 Manage funding search dialog src/app/constants.ts - 413 + 415 Manage work dialog src/app/constants.ts - 415 + 417 Manage work search dialog src/app/constants.ts - 417 + 419 Manage peer review dialog src/app/constants.ts - 419 + 421 @@ -17398,6 +17485,228 @@ 54 + + Your recovery phone number can be used to access your ORCID account if you don't have your authentication app or recovery codes. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 20,23 + + + + Your recovery phone number can only be used once. Using it will disable two-factor authentication and reset your 2FA backup options. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 27,31 + + + + Your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 38,40 + + + + Your current recovery phone number is + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 45 + + + + Select a country or location and enter your phone number. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 56,58 + + + + By entering my phone number and clicking 'Send verification code', I consent to receive an automated one-time verification code from ORCID at the number provided. Message and data rates may apply. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 90,94 + + + + Terms of use + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 101 + + + + Privacy policy + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 109 + + + + Send verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 122,124 + + + + You can resend in + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 128 + + + + seconds + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 131 + + + + Verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 141 + + + + Enter the 6-digit verification code sent to your device + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 156 + + + + Cancel + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 54 + + + + Manage your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 130 + + + + Add a recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 131 + + + + Update recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 136 + + + + to manage your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 142 + + + + to add a recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 143 + + + + We could not send a verification code. Please try again. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 301 + + + + A verification code is required + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 312 + + + + Invalid verification code length + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 317 + + + + Phone number is required + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 383 + + + + Phone number is too short + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 387 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 401 + + + + Phone number is too long + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 390 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 404 + + + + Phone number is invalid + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 392 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 407 + + + + This code was sent to a different number. Send a new code to this number. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 410 + + + + Invalid verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 416 + + + + That code is no longer valid. Send a new code. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 421 + + + + Something went wrong. Please try again. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 433 + + Step 1 of 2 - Authentication app diff --git a/src/locale/messages.xx.xlf b/src/locale/messages.xx.xlf index f7fd69e6fb..0afeb1085d 100644 --- a/src/locale/messages.xx.xlf +++ b/src/locale/messages.xx.xlf @@ -333,7 +333,7 @@ Verify my account and continue projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html - 286,288 + 287,289 X @@ -341,7 +341,7 @@ Cancel account verification projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html - 296,298 + 298,300 X @@ -1554,11 +1554,35 @@ X + + Your recovery phone number has been added + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 7 + + X + + + Your recovery phone number has been updated + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 11 + + X + + + Your recovery phone number was not updated + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 18,20 + + X + Two-factor authentication has been disabled src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 4,6 + 24,26 X @@ -1566,7 +1590,7 @@ Two-factor authentication was not disabled src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 10,12 + 30,32 X @@ -1574,7 +1598,7 @@ Add extra security to your ORCID account by enabling two-factor authentication (2FA). src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 16,19 + 36,39 X @@ -1586,7 +1610,11 @@ src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 26,28 + 46,48 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 203,205 src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.html @@ -1598,7 +1626,7 @@ Enable two-factor authentication src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 37,39 + 57,59 src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.html @@ -1618,7 +1646,7 @@ Sign in with 2FA src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 44 + 64 X @@ -1626,7 +1654,7 @@ Use your authentication app to generate a verification code whenever you sign in to ORCID. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 48,51 + 68,71 X @@ -1634,7 +1662,31 @@ Authentication app src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 61,63 + 81,83 + + X + + + Enabled: + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 86 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 137 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 177 + + X + + + 2FA backup + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 98 X @@ -1642,7 +1694,7 @@ Account recovery src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 74 + 100 X @@ -1650,7 +1702,43 @@ Recover access to your ORCID account if you can't use your authentication app. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 78,81 + 105,108 + + X + + + Recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 124 + + X + + + Modified: + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 135 + + X + + + Manage recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 151 + + X + + + Add recovery phone number + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 155 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 137 X @@ -1658,7 +1746,19 @@ 2FA recovery codes src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 91,93 + 172,174 + + X + + + Disable two-factor authentication + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 188,190 + + + src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html + 225,227 X @@ -1666,7 +1766,7 @@ You can disable two-factor authentication at any time. Turning 2FA off will reset any account recovery options you have set up. src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 103,106 + 192,195 X @@ -1678,15 +1778,7 @@ src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 113,115 - - X - - - Disable two-factor authentication - - src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.html - 123,125 + 214,216 X @@ -1694,7 +1786,7 @@ to disable 2FA src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.ts - 32 + 44 X @@ -6431,7 +6523,7 @@ ORCID src/app/constants.ts - 123 + 124 X @@ -6439,11 +6531,15 @@ 2FA - ORCID src/app/constants.ts - 124 + 125 src/app/constants.ts - 125 + 126 + + + src/app/constants.ts + 127 X @@ -6451,7 +6547,7 @@ Institutional linking - ORCID src/app/constants.ts - 126 + 128 X @@ -6459,7 +6555,7 @@ Social linking - ORCID src/app/constants.ts - 127 + 129 X @@ -6467,7 +6563,7 @@ Institutional sign in - ORCID src/app/constants.ts - 128 + 130 X @@ -6475,7 +6571,7 @@ Notifications inbox - ORCID src/app/constants.ts - 129 + 131 X @@ -6483,15 +6579,15 @@ Sign in - ORCID src/app/constants.ts - 130 + 132 src/app/constants.ts - 131 + 133 src/app/constants.ts - 137 + 139 X @@ -6499,7 +6595,7 @@ Oauth - ORCID src/app/constants.ts - 132 + 134 X @@ -6507,7 +6603,7 @@ Search - ORCID src/app/constants.ts - 133 + 135 X @@ -6515,7 +6611,7 @@ Account reactivation - ORCID src/app/constants.ts - 134 + 136 X @@ -6523,7 +6619,7 @@ Reset password - ORCID src/app/constants.ts - 135 + 137 X @@ -6531,7 +6627,7 @@ Register - ORCID src/app/constants.ts - 136 + 138 X @@ -6539,7 +6635,7 @@ Account settings - ORCID src/app/constants.ts - 138 + 140 X @@ -6547,7 +6643,7 @@ Deactivate account - ORCID src/app/constants.ts - 139 + 141 X @@ -6555,7 +6651,7 @@ Trusted parties - ORCID src/app/constants.ts - 140 + 142 X @@ -6563,7 +6659,7 @@ Reset password - ORCID src/app/constants.ts - 141 + 143 X @@ -6571,7 +6667,7 @@ Self Service - ORCID src/app/constants.ts - 142 + 144 X @@ -6579,7 +6675,7 @@ Developer tools - ORCID src/app/constants.ts - 143 + 145 X @@ -6587,11 +6683,11 @@ Record corrections - ORCID src/app/constants.ts - 145 + 147 src/app/constants.ts - 146 + 148 X @@ -6599,7 +6695,7 @@ - ORCID src/app/constants.ts - 150 + 152 X @@ -6607,7 +6703,7 @@ - My ORCID src/app/constants.ts - 151 + 153 X @@ -6615,7 +6711,7 @@ Manage employment dialog src/app/constants.ts - 376 + 378 X @@ -6623,7 +6719,7 @@ Manage education dialog src/app/constants.ts - 378 + 380 X @@ -6631,7 +6727,7 @@ Manage qualification dialog src/app/constants.ts - 380 + 382 X @@ -6639,7 +6735,7 @@ Manage distinction dialog src/app/constants.ts - 382 + 384 X @@ -6647,7 +6743,7 @@ Manage invited position dialog src/app/constants.ts - 384 + 386 X @@ -6655,7 +6751,7 @@ Manage membership dialog src/app/constants.ts - 386 + 388 X @@ -6663,7 +6759,7 @@ Manage service dialog src/app/constants.ts - 388 + 390 X @@ -6671,7 +6767,7 @@ Manage editorial service dialog src/app/constants.ts - 390 + 392 X @@ -6679,7 +6775,7 @@ Manage bibtex dialog src/app/constants.ts - 393 + 395 X @@ -6687,7 +6783,7 @@ Manage external identifier dialog src/app/constants.ts - 395 + 397 X @@ -6695,7 +6791,7 @@ Manage your names dialog src/app/constants.ts - 397 + 399 X @@ -6703,7 +6799,7 @@ Manage your biography dialog src/app/constants.ts - 399 + 401 X @@ -6711,7 +6807,7 @@ Manage your emails dialog src/app/constants.ts - 401 + 403 X @@ -6719,7 +6815,7 @@ Manage your countries dialog src/app/constants.ts - 403 + 405 X @@ -6727,7 +6823,7 @@ Manage your keywords dialog src/app/constants.ts - 405 + 407 X @@ -6735,7 +6831,7 @@ Manage your other IDs dialog src/app/constants.ts - 407 + 409 X @@ -6743,7 +6839,7 @@ Manage your websites & social links dialog src/app/constants.ts - 409 + 411 X @@ -6751,7 +6847,7 @@ Manage funding dialog src/app/constants.ts - 411 + 413 X @@ -6759,7 +6855,7 @@ Manage funding search dialog src/app/constants.ts - 413 + 415 X @@ -6767,7 +6863,7 @@ Manage work dialog src/app/constants.ts - 415 + 417 X @@ -6775,7 +6871,7 @@ Manage work search dialog src/app/constants.ts - 417 + 419 X @@ -6783,7 +6879,7 @@ Manage peer review dialog src/app/constants.ts - 419 + 421 X @@ -19415,6 +19511,258 @@ X + + Your recovery phone number can be used to access your ORCID account if you don't have your authentication app or recovery codes. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 20,23 + + X + + + Your recovery phone number can only be used once. Using it will disable two-factor authentication and reset your 2FA backup options. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 27,31 + + X + + + Your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 38,40 + + X + + + Your current recovery phone number is + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 45 + + X + + + Select a country or location and enter your phone number. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 56,58 + + X + + + By entering my phone number and clicking 'Send verification code', I consent to receive an automated one-time verification code from ORCID at the number provided. Message and data rates may apply. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 90,94 + + X + + + Terms of use + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 101 + + X + + + Privacy policy + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 109 + + X + + + Send verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 122,124 + + X + + + You can resend in + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 128 + + X + + + seconds + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 131 + + X + + + Verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 141 + + X + + + Enter the 6-digit verification code sent to your device + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html + 156 + + X + + + Cancel + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 54 + + X + + + Manage your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 130 + + X + + + Add a recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 131 + + X + + + Update recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 136 + + X + + + to manage your recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 142 + + X + + + to add a recovery phone number + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 143 + + X + + + We could not send a verification code. Please try again. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 301 + + X + + + A verification code is required + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 312 + + X + + + Invalid verification code length + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 317 + + X + + + Phone number is required + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 383 + + X + + + Phone number is too short + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 387 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 401 + + X + + + Phone number is too long + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 390 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 404 + + X + + + Phone number is invalid + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 392 + + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 407 + + X + + + This code was sent to a different number. Send a new code to this number. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 410 + + X + + + Invalid verification code + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 416 + + X + + + That code is no longer valid. Send a new code. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 421 + + X + + + Something went wrong. Please try again. + + src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts + 433 + + X + Step 1 of 2 - Authentication app From 4363674be4f92a06c94a73adbad7de63689b9181 Mon Sep 17 00:00:00 2001 From: cryptalith Date: Mon, 7 Sep 2026 16:27:29 -0600 Subject: [PATCH 02/21] PD-13366 PD-13367 spacing and layout fixes for recovery phone number --- ...gs-security-two-factor-auth.component.scss | 11 +++ .../recovery-phone.component.html | 90 +++++++++++-------- .../recovery-phone.component.scss | 21 +++-- .../recovery-phone.component.scss-theme.scss | 4 +- .../recovery-phone.component.ts | 31 ++++++- src/locale/messages.lr.xlf | 76 +++++++++------- src/locale/messages.rl.xlf | 76 +++++++++------- src/locale/messages.xlf | 75 +++++++++------- src/locale/messages.xx.xlf | 76 +++++++++------- 9 files changed, 276 insertions(+), 184 deletions(-) diff --git a/src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.scss b/src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.scss index 98b8f70356..1057b05e5b 100644 --- a/src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.scss +++ b/src/app/account-settings/components/settings-security-two-factor-auth/settings-security-two-factor-auth.component.scss @@ -61,6 +61,17 @@ padding-top: 16px; } +/* + * A row holds its content 16px below the rule it draws. The space underneath + * comes from whatever follows: the section's own padding for the last row, and + * for any other row only the section's 8px gap, which left the rule between + * two rows sitting closer to the one above it than the one below. The other + * 8px goes here, so a rule has 16px on both sides wherever rows are stacked. + */ +.two-factor-panel__requirement + .two-factor-panel__requirement { + margin-top: 8px; +} + .two-factor-panel__requirement-title { margin: 0; font-weight: 700; diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html index 266f576b8d..cda2cdcbdb 100644 --- a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.html @@ -26,8 +26,7 @@ i18n="@@account.recoveryPhoneNoticeOnce" > Your recovery phone number can only be used once. Using it will - disable two-factor authentication and reset your 2FA backup - options. + disable two-factor authentication and reset your 2FA backup options.

@@ -84,31 +83,30 @@ } - - +
- @if (resendCountdown > 0) { + @if (codeSent) {

+ Verification code sent to + {{ sentToNumber }}. @if (resendCountdown > 0) { You can resend inResend in {{ resendCountdown }} seconds - {{ resendCountdown }} - seconds + }

}
-
+
diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss index a68088d2bb..61639df097 100644 --- a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss @@ -42,6 +42,7 @@ .recovery-phone-notice__icon { color: var(--orcid-color-notice-important, #ff9c00); + /* a direct flex child, so it has to be told not to be squeezed to a sliver */ flex: 0 0 24px; width: 24px; height: 24px; @@ -53,206 +54,6 @@ font-style: italic; } -.recovery-phone-subheading { - font-weight: bold; - margin: 0 0 var(--orcid-space-base, 16px); - text-align: left; -} - -.recovery-phone-current { - margin: 0 0 var(--orcid-space-base, 16px); - text-align: left; -} - -.recovery-phone-current__value { - font-weight: bold; - margin-left: var(--orcid-space-xs, 4px); -} - -.recovery-phone-help { - margin: 0 0 var(--orcid-space-base, 16px); - text-align: left; -} - -.recovery-phone-form { - display: flex; - flex-direction: column; - gap: var(--orcid-space-m, 24px); - text-align: left; -} - -/* - * The design draws the country selector and the number as one bordered field: - * a grey segment holding the flag and its chevron, then the dial code, then the - * number itself, all inside a single 40px box. The library renders the pieces - * but styles them as a plain input, so the border moves out to the container - * and the inner input gives up its own. - */ -.phone-input-shell { - display: block; - - /* - * The library exposes these, so the flag size and the tint behind it are set - * through its own variables rather than by overriding the elements: it keeps - * the flag sprite sharp and leaves hover and focus to the library. - */ - --iti-flag-width: 28px; - --iti-country-selector-bg: var(--orcid-color-background-lightest, #fafafa); - --iti-border-color: var(--orcid-color-border-subtle, #bdbdbd); - --iti-spacer-horizontal: 8px; - - ::ng-deep .iti { - display: block; - width: 100%; - } - - /* - * Never set padding-left here. The library measures the flag chip and the - * dial code and writes the left padding itself; overriding it puts the - * number on top of the dial code. - */ - ::ng-deep .iti__tel-input, - ::ng-deep input[type='tel'] { - width: 100%; - height: 40px; - box-sizing: border-box; - border: 1px solid var(--orcid-color-border-subtle, #bdbdbd); - border-radius: 2px; - padding-top: 8px; - padding-bottom: 8px; - padding-right: 8px; - font-size: 14px; - line-height: 21px; - letter-spacing: 0.25px; - color: var(--orcid-color-text-dark-high, #000000); - } - - ::ng-deep .iti__tel-input::placeholder { - color: var(--orcid-color-text-placeholder, #707070); - } - - /* - * The flag and its chevron sit on a tinted segment, divided from the number - * by a hairline; the dial code then reads as the start of the number itself. - * The tint goes on the primary block rather than the button, which the - * library paints white on hover and focus. - */ - ::ng-deep .iti__country-container { - padding: 1px; - height: 38px; - } - - ::ng-deep .iti__selected-country { - border-radius: 2px 0 0 2px; - height: 100%; - } - - ::ng-deep .iti__selected-country-primary { - border-right: 1px solid var(--orcid-color-border-subtle, #bdbdbd); - border-radius: 2px 0 0 2px; - height: 100%; - padding: 0 6px 0 8px; - display: flex; - align-items: center; - } - - /* the dial code is set like the rest of the number, not emphasised */ - ::ng-deep .iti__selected-dial-code { - padding-left: 8px; - font-size: 14px; - font-weight: 400; - line-height: 21px; - letter-spacing: 0.25px; - color: var(--orcid-color-text-dark-high, #000000); - } - - /* - * The library writes an inline height on the open list, measured before its - * own stylesheet has capped the list, so the panel comes out as tall as all - * 240-odd countries laid end to end. Letting it size to its content puts it - * back to the search box plus the scrollable list it already renders. - */ - ::ng-deep .iti__country-selector { - height: auto !important; - } -} - -/* a rejected number turns the whole field red, not just the message below it */ -.phone-input-shell--invalid { - ::ng-deep .iti__tel-input, - ::ng-deep input[type='tel'] { - border-color: var(--orcid-color-state-warning-darkest, #b71c1c); - } -} - -/* - * The consent copy and the two links it ends with are one run of text in the - * design, so the links follow on the next line with nothing but the line - * height between them: as separate children of the form they would be pushed - * apart by its 24px gap. - */ -.recovery-phone-consent p { - margin: 0; -} - -.recovery-phone-error { - display: block; - margin-top: var(--orcid-space-s, 8px); -} - -.send-code { - display: flex; - flex-direction: column; - gap: var(--orcid-space-s, 8px); -} - -.send-code__button { - width: 100%; -} - -.send-code__helper { - margin: 0; - text-align: center; -} - -.verification-field { - display: flex; - flex-direction: column; - gap: var(--orcid-space-s, 8px); -} - -.verification-field__label { - font-weight: bold; -} - -.verification-field__input { - height: 40px; - border: 1px solid var(--orcid-color-border-subtle, #bdbdbd); - border-radius: 2px; - padding: 8px; - width: 100%; - box-sizing: border-box; -} - -.verification-field__input:disabled { - background-color: var(--orcid-color-background-light, #eeeeee); -} - -/* - * A rejected code turns the whole field red and the message takes the - * microcopy's place below it rather than stacking under it, which is how the - * code field on the sign-in page behaves. - */ -.verification-field--invalid .verification-field__input { - border-color: var(--orcid-color-state-warning-darkest, #b71c1c); -} - -.verification-field__helper { - display: flex; - justify-content: space-between; - gap: var(--orcid-space-s, 8px); -} - @media (max-width: 767px) { .recovery-phone-page { padding: 0; diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss-theme.scss b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss-theme.scss index 2422656161..8ad4099701 100644 --- a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss-theme.scss +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.scss-theme.scss @@ -1,19 +1,12 @@ @use '@angular/material' as mat; @import 'src/assets/scss/material.orcid-theme.scss'; +/* + * Everything this file used to theme - the send button and the error colours - + * went with the form to src/app/cdk/recovery-phone-form. The page keeps the + * hook so a page-level rule has somewhere to go. + */ @mixin recovery-phone-theme($theme) { $primary: map-get($theme, primary); - $config: mat.m2-define-typography-config(); - - .send-code__button { - background-color: var(--orcid-color-brand-secondary-dark, #085c77); - color: #ffffff; - } - - .recovery-phone-error, - .verification-field--invalid .verification-field__label, - .verification-field--invalid .verification-field__helper { - color: var(--orcid-color-state-warning-darkest, #b71c1c); - } } @include recovery-phone-theme($orcid-app-theme); diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.spec.ts b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.spec.ts index 6dc6325da8..80e6d898cd 100644 --- a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.spec.ts +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.spec.ts @@ -1,14 +1,16 @@ -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing' +import { CUSTOM_ELEMENTS_SCHEMA, EventEmitter } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' import { ReactiveFormsModule } from '@angular/forms' import { MatDialog, MatDialogRef } from '@angular/material/dialog' +import { MatIconModule } from '@angular/material/icon' +import { By } from '@angular/platform-browser' import { Router } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' -import { EventEmitter } from '@angular/core' -import { Subject, of, throwError } from 'rxjs' -import IntlTelInput from '@intl-tel-input/angular' +import { OrcidStepViewComponent } from '@orcid/ui' +import { Subject, of } from 'rxjs' import { RecoveryPhoneComponent } from './recovery-phone.component' +import { RecoveryPhoneFormComponent } from '../../../cdk/recovery-phone-form/recovery-phone-form.component' import { ApplicationRoutes } from '../../../constants' import { TogglzService } from '../../../core/togglz/togglz.service' import { TwoFactorAuthenticationService } from '../../../core/two-factor-authentication/two-factor-authentication.service' @@ -30,7 +32,7 @@ describe('RecoveryPhoneComponent', () => { let dialogRef: any const status = (overrides: Partial = {}): Status => - ({ enabled: true, ...overrides }) as Status + ({ enabled: true, ...overrides } as Status) function build(flagEnabled = true, statusValue: Status = status()) { togglzService.getStateOf.and.returnValue(of(flagEnabled)) @@ -50,6 +52,13 @@ describe('RecoveryPhoneComponent', () => { fixture.detectChanges() } + /** The form the page hosts, once the status call has let it render. */ + function hostedForm(): RecoveryPhoneFormComponent { + const form = component.recoveryPhoneForm + expect(form).withContext('the form should be rendered').toBeTruthy() + return form as RecoveryPhoneFormComponent + } + beforeEach(async () => { submitAttempt = new EventEmitter() cancelAttempt = new EventEmitter() @@ -79,7 +88,13 @@ describe('RecoveryPhoneComponent', () => { dialog.open.and.returnValue(dialogRef) await TestBed.configureTestingModule({ - imports: [ReactiveFormsModule, RouterTestingModule, IntlTelInput], + imports: [ + ReactiveFormsModule, + RouterTestingModule, + MatIconModule, + OrcidStepViewComponent, + RecoveryPhoneFormComponent, + ], declarations: [RecoveryPhoneComponent], providers: [ { provide: TwoFactorAuthenticationService, useValue: twoFactorService }, @@ -117,6 +132,15 @@ describe('RecoveryPhoneComponent', () => { expect(component.title).toBe('Add a recovery phone number') }) + it('names the challenge dialog and marks it modal', () => { + build() + + const config = dialog.open.calls.mostRecent().args[1] as any + expect(config.ariaModal).toBeTrue() + expect(config.ariaLabel).toBe('Add a recovery phone number') + expect(config.disableClose).toBeTrue() + }) + it('treats an existing number as a change rather than an addition', () => { build(true, status({ maskedRecoveryPhoneNumber: '***********1234' })) @@ -126,6 +150,14 @@ describe('RecoveryPhoneComponent', () => { expect(component.maskedRecoveryPhoneNumber).toBe('***********1234') }) + it('tells the form it is running in account settings', () => { + build(true, status({ maskedRecoveryPhoneNumber: '***********1234' })) + + expect(hostedForm().context).toBe('SETTINGS') + expect(hostedForm().managingExistingNumber).toBeTrue() + expect(hostedForm().maskedRecoveryPhoneNumber).toBe('***********1234') + }) + it('leaves the page when the challenge is cancelled', () => { build() ;(router.navigate as jasmine.Spy).calls.reset() @@ -147,175 +179,52 @@ describe('RecoveryPhoneComponent', () => { expect(dialogRef.close).not.toHaveBeenCalledWith(true) }) - it('enables the code field and starts the countdown once a code is sent', () => { - build() - passChallenge() - twoFactorService.sendRecoveryPhoneCode.and.returnValue( - of({ success: true, resendAfterSeconds: 30 }) - ) - component.form.get('phoneNumber')?.setValue('+441234567890') - - component.sendCode() - - expect(component.codeSent).toBeTrue() - expect(component.verificationCodeControl?.enabled).toBeTrue() - expect(component.resendCountdown).toBe(30) - // the number is locked while a code is outstanding - expect(component.phoneNumberControl?.disabled).toBeTrue() - }) - - it('counts the resend buffer down and frees the number again', fakeAsync(() => { + it('keeps the primary button dead until a code has been sent', () => { build() passChallenge() - twoFactorService.sendRecoveryPhoneCode.and.returnValue( - of({ success: true, resendAfterSeconds: 2 }) - ) - component.form.get('phoneNumber')?.setValue('+441234567890') - component.sendCode() - - tick(2000) - - expect(component.resendCountdown).toBe(0) - expect(component.phoneNumberControl?.enabled).toBeTrue() - component.ngOnDestroy() - })) - it('restarts the countdown cleanly on a second send', fakeAsync(() => { - build() - passChallenge() - twoFactorService.sendRecoveryPhoneCode.and.returnValue( - of({ success: true, resendAfterSeconds: 3 }) + const primary = fixture.debugElement.query( + By.css('#cy-step-view-primary-action') ) - component.form.get('phoneNumber')?.setValue('+441234567890') - component.sendCode() - tick(3000) - - component.sendCode() - tick(1000) - - // one tick, one second gone: a leftover countdown would double the rate - expect(component.resendCountdown).toBe(2) - component.ngOnDestroy() - })) - - it('names the problem the phone field already found, rather than calling it required', () => { - build() - passChallenge() - const control = component.form.get('phoneNumber') - control?.setValue('+441234') - control?.setErrors({ invalidPhone: 'IS_POSSIBLE_LOCAL_ONLY' }) - - component.sendCode() - - expect(component.phoneErrorMessage).toBe('Phone number is too short') - expect(twoFactorService.sendRecoveryPhoneCode).not.toHaveBeenCalled() - }) - - it('still asks for a number when the field is empty', () => { - build() - passChallenge() - - component.sendCode() - - expect(component.phoneErrorMessage).toBe('Phone number is required') + expect(primary.nativeElement.disabled).toBeTrue() }) - it('shows the length problem the server reports for the number', () => { + it('hands its primary button to the form', () => { build() passChallenge() - twoFactorService.sendRecoveryPhoneCode.and.returnValue( - of({ success: false, errorCode: 'PHONE_TOO_SHORT', resendAfterSeconds: 0 }) - ) - component.form.get('phoneNumber')?.setValue('+441234') - - component.sendCode() - - expect(component.phoneErrorMessage).toBe('Phone number is too short') - expect(component.codeSent).toBeFalse() - }) - - it('asks for a code before saving', () => { - build() - passChallenge() - - component.save() - - expect(component.codeErrorMessage).toBe('A verification code is required') - expect(twoFactorService.saveRecoveryPhone).not.toHaveBeenCalled() - }) - - it('rejects a code that is not six digits', () => { - build() - passChallenge() - component.verificationCodeControl?.enable() - component.verificationCodeControl?.setValue('12345') - - component.save() - - expect(component.codeErrorMessage).toBe('Invalid verification code length') - expect(twoFactorService.saveRecoveryPhone).not.toHaveBeenCalled() - }) - - it('reports a rejected code without leaving the page', () => { - build() - passChallenge() - component.verificationCodeControl?.enable() - component.verificationCodeControl?.setValue('000000') - twoFactorService.saveRecoveryPhone.and.returnValue( - of({ success: false, errorCode: 'INVALID_CODE' }) - ) - ;(router.navigate as jasmine.Spy).calls.reset() - - component.save() - - expect(component.codeErrorMessage).toBe('Invalid verification code') - expect(router.navigate).not.toHaveBeenCalled() - }) - - it('clears the code entry when it has expired', () => { - build() - passChallenge() - component.verificationCodeControl?.enable() - component.verificationCodeControl?.setValue('123456') + const form = hostedForm() + spyOn(form, 'save') component.codeSent = true - twoFactorService.saveRecoveryPhone.and.returnValue( - of({ success: false, errorCode: 'CODE_EXPIRED' }) - ) + fixture.detectChanges() - component.save() + const primary = fixture.debugElement.query( + By.css('#cy-step-view-primary-action') + ) + expect(primary.nativeElement.disabled).toBeFalse() + primary.nativeElement.click() - expect(component.codeSent).toBeFalse() - expect(component.verificationCodeControl?.disabled).toBeTrue() + expect(form.save).toHaveBeenCalled() }) - it('asks for the challenge again when the elevation has run out', () => { + it('re-opens the challenge when the form says the elevation ran out', () => { build() passChallenge() - component.verificationCodeControl?.enable() - component.verificationCodeControl?.setValue('123456') - twoFactorService.saveRecoveryPhone.and.returnValue( - of({ success: false, errorCode: 'CHALLENGE_REQUIRED' }) - ) dialog.open.calls.reset() - component.save() + hostedForm().challengeRequired.emit() expect(dialog.open).toHaveBeenCalled() - // what they typed is still there - expect(component.verificationCodeControl?.value).toBe('123456') }) it('returns with the added outcome after a first number is stored', () => { build() passChallenge() - component.verificationCodeControl?.enable() - component.verificationCodeControl?.setValue('123456') - twoFactorService.saveRecoveryPhone.and.returnValue( - of({ success: true, maskedRecoveryPhoneNumber: '***********1234' }) - ) ;(router.navigate as jasmine.Spy).calls.reset() - component.save() + hostedForm().saved.emit({ + success: true, + maskedRecoveryPhoneNumber: '***********1234', + }) expect(router.navigate).toHaveBeenCalledWith( [ApplicationRoutes.account], @@ -326,12 +235,9 @@ describe('RecoveryPhoneComponent', () => { it('returns with the updated outcome after a number is changed', () => { build(true, status({ maskedRecoveryPhoneNumber: '***********1234' })) passChallenge() - component.verificationCodeControl?.enable() - component.verificationCodeControl?.setValue('123456') - twoFactorService.saveRecoveryPhone.and.returnValue(of({ success: true })) ;(router.navigate as jasmine.Spy).calls.reset() - component.save() + hostedForm().saved.emit({ success: true }) expect(router.navigate).toHaveBeenCalledWith( [ApplicationRoutes.account], @@ -339,17 +245,12 @@ describe('RecoveryPhoneComponent', () => { ) }) - it('reports a failure when saving breaks', () => { + it('returns with the failed outcome when the form gives up', () => { build() passChallenge() - component.verificationCodeControl?.enable() - component.verificationCodeControl?.setValue('123456') - twoFactorService.saveRecoveryPhone.and.returnValue( - throwError(() => new Error('boom')) - ) ;(router.navigate as jasmine.Spy).calls.reset() - component.save() + hostedForm().failed.emit('HTTP') expect(router.navigate).toHaveBeenCalledWith( [ApplicationRoutes.account], diff --git a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts index 4a93686d49..bc4f025f8c 100644 --- a/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts +++ b/src/app/two-factor-recovery-phone/pages/recovery-phone/recovery-phone.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, LOCALE_ID, OnDestroy, OnInit } from '@angular/core' +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' import { UntypedFormBuilder, UntypedFormGroup, @@ -6,23 +6,31 @@ import { } from '@angular/forms' import { MatDialog, MatDialogRef } from '@angular/material/dialog' import { Router } from '@angular/router' -import { AuthChallengeComponent } from '@orcid/registry-ui' -import { Subject, Subscription, interval } from 'rxjs' +import { + AuthChallengeComponent, + AuthChallengeRecoveryPhone, +} from '@orcid/registry-ui' +import { Subject } from 'rxjs' import { first, takeUntil } from 'rxjs/operators' +import { RecoveryPhoneFormComponent } from '../../../cdk/recovery-phone-form/recovery-phone-form.component' import { ApplicationRoutes } from '../../../constants' import { TogglzService } from '../../../core/togglz/togglz.service' +import { RecoveryPhoneChallengeService } from '../../../core/two-factor-authentication/recovery-phone-challenge.service' import { TwoFactorAuthenticationService } from '../../../core/two-factor-authentication/two-factor-authentication.service' import { AuthChallenge, AuthChallengeFormData, } from '../../../types/common.endpoint' import { TogglzFlag } from '../../../types/config.endpoint' -import { - RecoveryPhoneErrorCode, - Status, -} from '../../../types/two-factor.endpoint' - +import { Status } from '../../../types/two-factor.endpoint' + +/** + * Account settings' add-and-manage page. The form itself lives in + * `app-recovery-phone-form`; everything here is the page around it: the feature + * gate, the password challenge that guards this surface, and the way back to + * account settings. + */ @Component({ selector: 'app-recovery-phone', templateUrl: './recovery-phone.component.html', @@ -35,9 +43,9 @@ import { export class RecoveryPhoneComponent implements OnInit, OnDestroy { private readonly $destroy = new Subject() - loadUtils = () => import('intl-tel-input/utils') + @ViewChild(RecoveryPhoneFormComponent) + recoveryPhoneForm: RecoveryPhoneFormComponent | undefined - form: UntypedFormGroup challengeForm: UntypedFormGroup loadingState = true @@ -45,44 +53,14 @@ export class RecoveryPhoneComponent implements OnInit, OnDestroy { managingExistingNumber = false maskedRecoveryPhoneNumber: string | undefined + /** Mirrored from the form: the primary action is dead until a code is out. */ codeSent = false - resendCountdown = 0 - sending = false - saving = false - - /** - * The number the outstanding code was sent to, held from the moment it was - * sent: the field can be edited again once the resend delay is over, and the - * confirmation still has to name the number the code actually went to. It is - * shown in full - it is what the user just typed into the field above, not - * anything the registry read back. - */ - sentToNumber: string | undefined - - phoneErrorMessage: string | null = null - codeErrorMessage: string | null = null - generalErrorMessage: string | null = null cancelLabel = $localize`:@@account.cancel:Cancel` - /** Preselected country, taken from the region the browser reports. */ - initialCountry = 'us' - - /** Country names in the language the registry is being displayed in. */ - countryNameLocale = 'en' - - /** The phone field's own labels and search box, in the same language. */ - uiTranslations: Record | undefined - - /** - * The field reads its translations once, when it initialises, so the form - * waits for them rather than rendering an English field and updating it. - */ - translationsReady = false - private challengeDialog: MatDialogRef | undefined private challengePassed = false - private countdownSubscription: Subscription | undefined + private recoveryPhoneChallenge: AuthChallengeRecoveryPhone | undefined constructor( private _router: Router, @@ -90,19 +68,10 @@ export class RecoveryPhoneComponent implements OnInit, OnDestroy { private _fb: UntypedFormBuilder, private _togglz: TogglzService, private _twoFactorAuthenticationService: TwoFactorAuthenticationService, - @Inject(LOCALE_ID) private _locale: string + private _recoveryPhoneChallenge: RecoveryPhoneChallengeService ) {} ngOnInit(): void { - this.initialCountry = this.resolveInitialCountry() - this.loadPhoneFieldTranslations() - this.form = this._fb.group({ - phoneNumber: ['', Validators.required], - verificationCode: [ - { value: '', disabled: true }, - [Validators.required, Validators.minLength(6), Validators.maxLength(6)], - ], - }) this.challengeForm = this._fb.group({ password: [null, Validators.required], twoFactorCode: [null, [Validators.minLength(6), Validators.maxLength(6)]], @@ -110,6 +79,10 @@ export class RecoveryPhoneComponent implements OnInit, OnDestroy { null, [Validators.minLength(10), Validators.maxLength(10)], ], + twoFactorRecoveryPhoneCode: [ + null, + [Validators.minLength(6), Validators.maxLength(6)], + ], }) this._togglz @@ -129,14 +102,6 @@ export class RecoveryPhoneComponent implements OnInit, OnDestroy { this.$destroy.complete() } - get verificationCodeControl() { - return this.form.get('verificationCode') - } - - get phoneNumberControl() { - return this.form.get('phoneNumber') - } - get title(): string { return this.managingExistingNumber ? $localize`:@@account.manageRecoveryPhoneTitle:Manage your recovery phone number` @@ -155,56 +120,6 @@ export class RecoveryPhoneComponent implements OnInit, OnDestroy { : $localize`:@@account.addRecoveryPhoneChallenge:to add a recovery phone number` } - /** - * Picks the country from the region in the browser's locale, e.g. en-GB - * gives gb. Falls back to the US when the locale carries no region. - */ - private resolveInitialCountry(): string { - const locale = navigator?.language || this._locale || '' - const region = locale.split(/[-_]/)[1] - return region && region.length === 2 ? region.toLowerCase() : 'us' - } - - /** - * The phone field ships its own translations, keyed by language rather than - * by the locale ids the registry uses, so Chinese needs mapping and anything - * it does not carry falls back to English rather than rendering blank. - */ - private loadPhoneFieldTranslations(): void { - const language = this.resolvePhoneFieldLanguage() - this.countryNameLocale = language - if (language === 'en') { - this.translationsReady = true - return - } - // The whole set is imported and one language picked out of it: a template - // path cannot be resolved through the package's export map at build time - import('intl-tel-input/locale') - .then((locales) => { - const translations = (locales as Record)[language] - if (translations) { - this.uiTranslations = translations as Record - } else { - this.countryNameLocale = 'en' - } - this.translationsReady = true - }) - .catch(() => { - // English is already the field's default, so nothing to undo - this.countryNameLocale = 'en' - this.translationsReady = true - }) - } - - private resolvePhoneFieldLanguage(): string { - const locale = (this._locale || 'en').toLowerCase() - if (locale.startsWith('zh')) { - // The field carries simplified Chinese as zh and traditional as zh-hk - return locale === 'zh-tw' || locale === 'zh-hant' ? 'zh-hk' : 'zh' - } - return locale.split('-')[0] - } - private loadStatus(): void { this._twoFactorAuthenticationService .checkState() @@ -230,14 +145,20 @@ export class RecoveryPhoneComponent implements OnInit, OnDestroy { * too, so a deep link cannot get past it. */ private openAuthChallenge(): void { + this.recoveryPhoneChallenge = this._recoveryPhoneChallenge.create() this.challengeDialog = this._dialog.open( AuthChallengeComponent, { disableClose: true, + ariaModal: true, + ariaLabel: this.title, data: { - parentForm: this.challengeForm, - actionDescription: this.challengeDescription, - } as AuthChallengeFormData, + ...({ + parentForm: this.challengeForm, + actionDescription: this.challengeDescription, + } as AuthChallengeFormData), + recoveryPhone: this.recoveryPhoneChallenge, + }, } ) @@ -264,6 +185,17 @@ export class RecoveryPhoneComponent implements OnInit, OnDestroy { if (!dialogRef) { return } + + if (this.recoveryPhoneChallenge?.used) { + // The only way past this challenge was the very number this page exists + // to manage, and using it deleted that number and turned 2FA off (R5.3). + // There is nothing left here to add to or change, so go back to account + // settings, which now shows 2FA off and no recovery number. + this.challengePassed = false + dialogRef.close(false) + return + } + this._twoFactorAuthenticationService .verifyRecoveryPhoneChallenge(this.challengeForm.value) .pipe(first()) @@ -282,191 +214,29 @@ export class RecoveryPhoneComponent implements OnInit, OnDestroy { }) } - sendCode(): void { - this.clearErrors() - if (this.phoneNumberControl?.invalid) { - this.phoneNumberControl.markAsTouched() - this.phoneErrorMessage = this.localPhoneErrorMessage() - return - } - - this.sending = true - this._twoFactorAuthenticationService - .sendRecoveryPhoneCode({ - phoneNumber: this.phoneNumberControl?.value, - locale: this._locale, - }) - .pipe(first()) - .subscribe({ - next: (response) => { - this.sending = false - if (response.success) { - this.codeSent = true - this.sentToNumber = this.phoneNumberControl?.value - this.verificationCodeControl?.enable() - this.startResendCountdown(response.resendAfterSeconds) - } else { - this.handleErrorCode( - response.errorCode, - response.resendAfterSeconds - ) - } - }, - error: () => { - this.sending = false - this.generalErrorMessage = $localize`:@@account.recoveryPhoneSendFailed:We could not send a verification code. Please try again.` - }, - }) - } - - save(): void { - this.clearErrors() - // A disabled control counts as valid, so check the value we actually have - const code: string = this.verificationCodeControl?.value || '' - if (!code) { - this.verificationCodeControl?.markAsTouched() - this.codeErrorMessage = $localize`:@@account.verificationCodeRequired:A verification code is required` - return - } - if (code.length !== 6) { - this.verificationCodeControl?.markAsTouched() - this.codeErrorMessage = $localize`:@@account.invalidVerificationCodeLength:Invalid verification code length` - return - } - - this.saving = true - this._twoFactorAuthenticationService - .saveRecoveryPhone({ - phoneNumber: this.phoneNumberControl?.value, - verificationCode: code, - }) - .pipe(first()) - .subscribe({ - next: (response) => { - this.saving = false - if (response.success) { - this.returnToAccountSettings( - this.managingExistingNumber ? 'updated' : 'added' - ) - } else { - this.handleErrorCode(response.errorCode) - } - }, - error: () => { - this.saving = false - this.returnToAccountSettings('failed') - }, - }) - } - - cancel(): void { - this.returnToAccountSettings() - } - - /** - * Counts down from the server's own resend buffer, so the button re-enables - * at the same moment the backend starts accepting another send. - */ - private startResendCountdown(seconds: number): void { - // Drop any countdown still running, or two of them would race and the - // button would come back before the server accepts another send - this.countdownSubscription?.unsubscribe() - this.resendCountdown = seconds - if (seconds <= 0) { - return - } - this.phoneNumberControl?.disable() - this.countdownSubscription = interval(1000) - .pipe(takeUntil(this.$destroy)) - .subscribe(() => { - this.resendCountdown-- - if (this.resendCountdown <= 0) { - this.resendCountdown = 0 - this.countdownSubscription?.unsubscribe() - this.phoneNumberControl?.enable() - } - }) + /** The page owns the primary button; the form owns what it posts. */ + onPrimaryAction(): void { + this.recoveryPhoneForm?.save() } - /** - * The phone field validates as you type and knows why a number is wrong, so - * say which problem it is rather than sending everything to the server and - * calling it all "required". - */ - private localPhoneErrorMessage(): string { - const control = this.phoneNumberControl - if (!control?.value) { - return $localize`:@@account.recoveryPhoneRequired:Phone number is required` - } - const reason = String(control.errors?.['invalidPhone'] ?? '') - if ( - reason.includes('TOO_SHORT') || - reason.includes('LOCAL_ONLY') || - reason.includes('INVALID_LENGTH') - ) { - return $localize`:@@account.recoveryPhoneTooShort:Phone number is too short` - } - if (reason.includes('TOO_LONG')) { - return $localize`:@@account.recoveryPhoneTooLong:Phone number is too long` - } - return $localize`:@@account.recoveryPhoneInvalid:Phone number is invalid` + /** The elevation ran out mid form: ask again, and keep what they typed. */ + onChallengeRequired(): void { + this.challengePassed = false + this.openAuthChallenge() } - private handleErrorCode( - errorCode: RecoveryPhoneErrorCode | undefined, - resendAfterSeconds = 0 - ): void { - switch (errorCode) { - case 'PHONE_TOO_SHORT': - this.phoneErrorMessage = $localize`:@@account.recoveryPhoneTooShort:Phone number is too short` - break - case 'PHONE_TOO_LONG': - this.phoneErrorMessage = $localize`:@@account.recoveryPhoneTooLong:Phone number is too long` - break - case 'INVALID_PHONE_NUMBER': - this.phoneErrorMessage = $localize`:@@account.recoveryPhoneInvalid:Phone number is invalid` - break - case 'PHONE_MISMATCH': - this.codeErrorMessage = $localize`:@@account.recoveryPhoneMismatch:This code was sent to a different number. Send a new code to this number.` - break - case 'RESEND_TOO_SOON': - this.startResendCountdown(resendAfterSeconds) - break - case 'INVALID_CODE': - this.codeErrorMessage = $localize`:@@account.invalidVerificationCode:Invalid verification code` - break - case 'CODE_EXPIRED': - case 'TOO_MANY_ATTEMPTS': - this.resetCodeEntry() - this.codeErrorMessage = $localize`:@@account.verificationCodeExpired:That code is no longer valid. Send a new code.` - break - case 'CHALLENGE_REQUIRED': - // The elevation ran out mid form; ask again and keep what they typed - this.challengePassed = false - this.openAuthChallenge() - break - case '2FA_DISABLED': - case 'FEATURE_DISABLED': - this.returnToAccountSettings('failed') - break - default: - this.generalErrorMessage = $localize`:@@account.recoveryPhoneGenericError:Something went wrong. Please try again.` - } + onSaved(): void { + this.returnToAccountSettings( + this.managingExistingNumber ? 'updated' : 'added' + ) } - private resetCodeEntry(): void { - this.codeSent = false - this.sentToNumber = undefined - this.resendCountdown = 0 - this.verificationCodeControl?.reset('') - this.verificationCodeControl?.disable() - this.phoneNumberControl?.enable() + onFailed(): void { + this.returnToAccountSettings('failed') } - private clearErrors(): void { - this.phoneErrorMessage = null - this.codeErrorMessage = null - this.generalErrorMessage = null + cancel(): void { + this.returnToAccountSettings() } private returnToAccountSettings( diff --git a/src/app/two-factor-recovery-phone/two-factor-recovery-phone.module.ts b/src/app/two-factor-recovery-phone/two-factor-recovery-phone.module.ts index f509852fce..10ea430b82 100644 --- a/src/app/two-factor-recovery-phone/two-factor-recovery-phone.module.ts +++ b/src/app/two-factor-recovery-phone/two-factor-recovery-phone.module.ts @@ -3,12 +3,10 @@ import { NgModule } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' import { MatDialogModule } from '@angular/material/dialog' -import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' -import { MatInputModule } from '@angular/material/input' -import IntlTelInput from '@intl-tel-input/angular' -import { AlertMessageComponent, OrcidStepViewComponent } from '@orcid/ui' +import { OrcidStepViewComponent } from '@orcid/ui' +import { RecoveryPhoneFormComponent } from '../cdk/recovery-phone-form/recovery-phone-form.component' import { RecoveryPhoneComponent } from './pages/recovery-phone/recovery-phone.component' import { TwoFactorRecoveryPhoneRoutingModule } from './two-factor-recovery-phone-routing.module' @@ -21,12 +19,10 @@ import { TwoFactorRecoveryPhoneRoutingModule } from './two-factor-recovery-phone TwoFactorRecoveryPhoneRoutingModule, MatButtonModule, MatDialogModule, - MatFormFieldModule, MatIconModule, - MatInputModule, - IntlTelInput, - AlertMessageComponent, + // Standalone components OrcidStepViewComponent, + RecoveryPhoneFormComponent, ], }) export class TwoFactorRecoveryPhoneModule {} diff --git a/src/app/types/config.endpoint.ts b/src/app/types/config.endpoint.ts index 2eb67d3d6a..424f78d7b7 100644 --- a/src/app/types/config.endpoint.ts +++ b/src/app/types/config.endpoint.ts @@ -18,6 +18,12 @@ export const TogglzFlag = { SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS: 'SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS', TWO_FACTOR_RECOVERY_PHONE: 'TWO_FACTOR_RECOVERY_PHONE', + /** + * The add-a-recovery-number interstitial. There is deliberately no OAUTH_ + * counterpart: the interstitial managers resolve their flag by prefix, so + * leaving it out is what keeps this one to the standard sign-in flow. + */ + LOGIN_RECOVERY_PHONE_INTERSTITIAL: 'LOGIN_RECOVERY_PHONE_INTERSTITIAL', /** Real User Monitoring / session recording (supports percentage 0–100 via FEATURE_PERCENTAGE). */ RUM: 'RUM', } as const diff --git a/src/app/types/two-factor.endpoint.ts b/src/app/types/two-factor.endpoint.ts index 407a724201..fb757ac46e 100644 --- a/src/app/types/two-factor.endpoint.ts +++ b/src/app/types/two-factor.endpoint.ts @@ -45,10 +45,21 @@ export type RecoveryPhoneErrorCode = | 'CHALLENGE_REQUIRED' | '2FA_DISABLED' | 'FEATURE_DISABLED' + | 'BAD_CREDENTIALS' + | 'NO_RECOVERY_PHONE' + +/** + * Which flow asked for the number. The registry reads it to decide what it + * demands before it will send or save: a password challenge in account + * settings, a 2FA registration that has just completed during onboarding, or a + * fresh login behind the sign-in interstitial. Absent means SETTINGS. + */ +export type RecoveryPhoneContext = 'SETTINGS' | 'ONBOARDING' | 'INTERSTITIAL' export interface RecoveryPhoneSendCodeRequest { phoneNumber: string locale?: string + context?: RecoveryPhoneContext } export interface RecoveryPhoneSendCodeResponse { @@ -60,6 +71,7 @@ export interface RecoveryPhoneSendCodeResponse { export interface RecoveryPhoneSaveRequest { phoneNumber: string verificationCode: string + context?: RecoveryPhoneContext } export interface RecoveryPhoneSaveResponse { From 43f1ecc93a876b8051d0a900a0ea24882a4aeac2 Mon Sep 17 00:00:00 2001 From: cryptalith Date: Wed, 16 Sep 2026 14:20:33 -0600 Subject: [PATCH 04/21] PD-6044 add the recovery phone step to 2FA onboarding --- .../two-factor-enable.component.html | 3 +- .../two-factor-enable.component.ts | 10 +- .../two-factor-recovery-codes.component.html | 3 +- .../two-factor-recovery-codes.component.ts | 9 + .../two-factor-recovery-phone.component.html | 50 ++++ .../two-factor-recovery-phone.component.scss | 60 ++++ ...r-recovery-phone.component.scss-theme.scss | 27 ++ ...wo-factor-recovery-phone.component.spec.ts | 258 ++++++++++++++++++ .../two-factor-recovery-phone.component.ts | 184 +++++++++++++ .../two-factor-setup.component.html | 15 +- .../two-factor-setup.component.spec.ts | 235 +++++++++++++++- .../two-factor/two-factor-setup.component.ts | 81 +++++- .../two-factor-setup.module.ts | 8 +- 13 files changed, 922 insertions(+), 21 deletions(-) create mode 100644 src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.html create mode 100644 src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.scss create mode 100644 src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.scss-theme.scss create mode 100644 src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.spec.ts create mode 100644 src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.ts diff --git a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.html b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.html index a38292e638..7031c30aca 100644 --- a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.html +++ b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.html @@ -1,11 +1,10 @@
diff --git a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.ts b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.ts index 64dd7cf933..5a42c17c1d 100644 --- a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.ts +++ b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, OnInit, Output } from '@angular/core' +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' import { first } from 'rxjs/operators' import { UntypedFormControl, @@ -37,6 +37,14 @@ export const clipboardTooltip: MatTooltipDefaultOptions = { standalone: false, }) export class TwoFactorEnableComponent implements OnInit { + /** + * Supplied by the page, which is the only thing that knows how long the flow + * is. The default keeps the two step wording, so a caller that passes nothing + * - and the flag-off flow is exactly that - is unchanged. + */ + @Input() + subtitle = $localize`:@@account.step1AuthenticationApp:Step 1 of 2 - Authentication app` + @Output() twoFactorEnabled = new EventEmitter<{ backupCodes?: string backupCodesClipboard?: string diff --git a/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.html b/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.html index 3f2e7fb1cf..dcbfa7fc40 100644 --- a/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.html +++ b/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.html @@ -1,11 +1,10 @@ diff --git a/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.ts b/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.ts index 67c467fb5d..02438c4b78 100644 --- a/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.ts +++ b/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.ts @@ -24,6 +24,15 @@ declare const $localize: any export class TwoFactorRecoveryCodesComponent implements OnInit { @Input() backupCodes: string @Input() backupCodesClipboard: string + + /** + * Supplied by the page, which is the only thing that knows how long the flow + * is. The default keeps the two step wording, so a caller that passes nothing + * - and the flag-off flow is exactly that - is unchanged. + */ + @Input() + subtitle = $localize`:@@account.step2RecoveryCodes:Step 2 of 2 - 2FA recovery codes` + applicationRoutes = ApplicationRoutes twoFactorForm: UntypedFormGroup hasDownloadedOrCopied = false diff --git a/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.html b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.html new file mode 100644 index 0000000000..a21445e1e3 --- /dev/null +++ b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.html @@ -0,0 +1,50 @@ + +
+ +
+

+ We strongly recommend adding a phone number to ORCID as an emergency 2FA + backup option. Your recovery phone number can be used to access your + ORCID account if you don't have your authentication app or recovery + codes. +

+
+
+ + + + + @if (stepErrorMessage) { + + } +
diff --git a/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.scss b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.scss new file mode 100644 index 0000000000..c4167e50e1 --- /dev/null +++ b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.scss @@ -0,0 +1,60 @@ +@import 'src/assets/scss/orcid.spacing.scss'; + +/* + * The page positions the other two steps through their element selectors, and + * this one owns its own spacing so it lines up with them. + */ +:host { + display: block; + margin-top: var(--orcid-space-l, #{$spacing-large}); + + @media (max-width: 767px) { + margin-top: 0; + } +} + +/* + * The registration wizard's yellow notice panel, repeated here rather than + * reached for through a global: the colours come from the state-notice tokens + * in the theme file next to this one. + */ +.recovery-phone-onboarding-notice { + display: flex; + padding: $spacing-base; + margin-bottom: $spacing-large; + border: solid 2px; + border-radius: 4px; + text-align: left; + + p { + margin: 0; + // .orc-font-* only carries font-size and letter-spacing + line-height: 21px; + font-weight: 400; + } +} + +.recovery-phone-onboarding-notice__icon { + margin-right: $spacing-base; + // A direct flex child, so without this the icon is squeezed below its 24px + // glyph box and renders as a clipped sliver. + flex-shrink: 0; + width: 24px; + height: 24px; + font-size: 24px; + line-height: 24px; + + [dir='rtl'] :host & { + margin-right: 0; + margin-left: $spacing-base; + } +} + +/* + * The alert sits under the form, between the last field and the step's own + * actions, so it needs the gap on both sides that the form's fields have. + */ +.recovery-phone-step-error { + margin-top: $spacing-base; + text-align: left; +} diff --git a/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.scss-theme.scss b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.scss-theme.scss new file mode 100644 index 0000000000..d071345c46 --- /dev/null +++ b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.scss-theme.scss @@ -0,0 +1,27 @@ +@use '@angular/material' as mat; +@import 'src/assets/scss/material.orcid-theme.scss'; + +/* + * The same tokens the registration wizard's `.info` panel uses, so the two + * notices are the same orange rather than two near misses. + */ +@mixin two-factor-recovery-phone-theme($theme) { + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + .recovery-phone-onboarding-notice { + background-color: mat.m2-get-color-from-palette( + $background, + state-notice-lightest + ); + border-color: mat.m2-get-color-from-palette( + $foreground, + 'state-notice-dark' + ); + + mat-icon { + color: mat.m2-get-color-from-palette($foreground, 'state-notice-dark'); + } + } +} +@include two-factor-recovery-phone-theme($orcid-app-theme); diff --git a/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.spec.ts b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.spec.ts new file mode 100644 index 0000000000..58159fd761 --- /dev/null +++ b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.spec.ts @@ -0,0 +1,258 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { MatIconModule } from '@angular/material/icon' +import { By } from '@angular/platform-browser' +import { NoopAnimationsModule } from '@angular/platform-browser/animations' +import { AlertMessageComponent, OrcidStepViewComponent } from '@orcid/ui' + +import { RecoveryPhoneFormComponent } from '../../../cdk/recovery-phone-form/recovery-phone-form.component' +import { TwoFactorAuthenticationService } from '../../../core/two-factor-authentication/two-factor-authentication.service' +import { AppEventName } from '../../../rum/app-event-names' +import { RumJourneyEventService } from '../../../rum/service/customEvent.service' +import { RecoveryPhoneSaveResponse } from '../../../types/two-factor.endpoint' +import { TwoFactorRecoveryPhoneComponent } from './two-factor-recovery-phone.component' + +describe('TwoFactorRecoveryPhoneComponent', () => { + let component: TwoFactorRecoveryPhoneComponent + let fixture: ComponentFixture + let fakeObservability: jasmine.SpyObj + let fakeTwoFactorAuthenticationService: jasmine.SpyObj + + /** The real form is rendered, so the step is tested through what it hosts. */ + function form(): RecoveryPhoneFormComponent { + return component.recoveryPhoneForm as RecoveryPhoneFormComponent + } + + function primaryAction(): HTMLButtonElement { + return fixture.debugElement.query(By.css('#cy-step-view-primary-action')) + .nativeElement + } + + function secondaryAction(): HTMLButtonElement { + return fixture.debugElement.query(By.css('#cy-step-view-secondary-action')) + .nativeElement + } + + /** The step's own message: the one it shows when the form has none. */ + function stepError(): HTMLElement | null { + return fixture.nativeElement.querySelector('.recovery-phone-step-error') + } + + beforeEach(async () => { + fakeObservability = jasmine.createSpyObj( + 'RumJourneyEventService', + ['recordSimpleEvent'] + ) + fakeTwoFactorAuthenticationService = jasmine.createSpyObj( + 'TwoFactorAuthenticationService', + ['sendRecoveryPhoneCode', 'saveRecoveryPhone'] + ) + + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + MatIconModule, + AlertMessageComponent, + OrcidStepViewComponent, + RecoveryPhoneFormComponent, + ], + declarations: [TwoFactorRecoveryPhoneComponent], + providers: [ + { + provide: RumJourneyEventService, + useValue: fakeObservability, + }, + { + provide: TwoFactorAuthenticationService, + useValue: fakeTwoFactorAuthenticationService, + }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents() + + fixture = TestBed.createComponent(TwoFactorRecoveryPhoneComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + afterEach(() => { + // the form owns a live resend interval; it dies with the component + component.recoveryPhoneForm?.ngOnDestroy() + }) + + it('records that the step was reached', () => { + expect(component).toBeTruthy() + expect(fakeObservability.recordSimpleEvent).toHaveBeenCalledWith( + AppEventName.TwoFactorSetupRecoveryPhoneLoaded + ) + }) + + it('puts the warning notice ahead of the form (R2.2)', () => { + const notice: HTMLElement = fixture.nativeElement.querySelector( + '.recovery-phone-onboarding-notice' + ) + const phoneForm: HTMLElement = fixture.nativeElement.querySelector( + 'app-recovery-phone-form' + ) + + expect(notice).toBeTruthy() + expect(phoneForm).toBeTruthy() + expect(notice.textContent).toContain( + 'We strongly recommend adding a phone number to ORCID' + ) + expect( + notice.compareDocumentPosition(phoneForm) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) + + it('tells the form which flow asked for the number (R2.6)', () => { + expect(form().context).toBe('ONBOARDING') + }) + + it('offers the step subtitle and the skip link', () => { + const text = fixture.nativeElement.textContent + expect(text).toContain('Step 2 of 3 - Recovery phone number') + expect(text).toContain("Skip this step - Don't add a recovery phone number") + expect(primaryAction().textContent).toContain( + 'Next step - 2FA recovery codes' + ) + }) + + it('keeps the primary action dead until a code has been sent (R2.2)', () => { + expect(primaryAction().disabled).toBeTrue() + + form().codeSentChange.emit(true) + fixture.detectChanges() + + expect(component.codeSent).toBeTrue() + expect(primaryAction().disabled).toBeFalse() + }) + + it('keeps the primary action dead while a save is in flight', () => { + form().codeSentChange.emit(true) + form().saving = true + fixture.detectChanges() + + expect(component.saving).toBeTrue() + expect(primaryAction().disabled).toBeTrue() + }) + + it('saves through the form when the primary action is pressed (R2.4)', () => { + const save = spyOn(form(), 'save') + form().codeSentChange.emit(true) + fixture.detectChanges() + + primaryAction().click() + + expect(save).toHaveBeenCalled() + }) + + it('reports the step complete and records the save (R2.4)', () => { + const completed = jasmine.createSpy('completed') + component.completed.subscribe(completed) + + form().saved.emit({ success: true } as RecoveryPhoneSaveResponse) + + expect(completed).toHaveBeenCalled() + expect(fakeObservability.recordSimpleEvent).toHaveBeenCalledWith( + AppEventName.TwoFactorSetupRecoveryPhoneSaved + ) + }) + + it('advances without saving when the step is skipped (R2.5)', () => { + const completed = jasmine.createSpy('completed') + component.completed.subscribe(completed) + const save = spyOn(form(), 'save') + + secondaryAction().click() + + expect(completed).toHaveBeenCalled() + expect(save).not.toHaveBeenCalled() + expect( + fakeTwoFactorAuthenticationService.saveRecoveryPhone + ).not.toHaveBeenCalled() + expect(fakeObservability.recordSimpleEvent).toHaveBeenCalledWith( + AppEventName.TwoFactorSetupRecoveryPhoneSkipped + ) + }) + + it('passes a refusal that ends the step up rather than swallowing it', () => { + const failed = jasmine.createSpy('failed') + component.failed.subscribe(failed) + + form().failed.emit('2FA_DISABLED') + form().failed.emit('FEATURE_DISABLED') + + expect(failed).toHaveBeenCalledWith('2FA_DISABLED') + expect(failed).toHaveBeenCalledWith('FEATURE_DISABLED') + }) + + it('keeps the user on the step when the save never landed (R2.4)', () => { + const failed = jasmine.createSpy('failed') + const completed = jasmine.createSpy('completed') + component.failed.subscribe(failed) + component.completed.subscribe(completed) + form().codeSentChange.emit(true) + + form().failed.emit('HTTP') + fixture.detectChanges() + + // nothing was stored, so nothing may report the step over + expect(failed).not.toHaveBeenCalled() + expect(completed).not.toHaveBeenCalled() + expect(stepError()?.textContent).toContain( + 'We could not save your recovery phone number' + ) + // and the retry is one press away rather than a trip to account settings + expect(primaryAction().disabled).toBeFalse() + }) + + it('says the number was not saved when the elevation ran out (R2.6)', () => { + const failed = jasmine.createSpy('failed') + const completed = jasmine.createSpy('completed') + component.failed.subscribe(failed) + component.completed.subscribe(completed) + form().codeSentChange.emit(true) + + form().challengeRequired.emit() + fixture.detectChanges() + + expect(failed).not.toHaveBeenCalled() + expect(completed).not.toHaveBeenCalled() + expect(stepError()?.getAttribute('role')).toBe('alert') + expect(stepError()?.textContent).toContain( + 'Your recovery phone number was not saved' + ) + expect(primaryAction().disabled).toBeFalse() + }) + + it('leaves the message to the form when the form has one (R2.4)', () => { + const failed = jasmine.createSpy('failed') + component.failed.subscribe(failed) + form().codeSentChange.emit(true) + form().generalErrorMessage = 'Something went wrong. Please try again.' + + form().failed.emit('HTTP') + fixture.detectChanges() + + // still on the step, but not arguing with the form about why + expect(failed).not.toHaveBeenCalled() + expect(stepError()).toBeNull() + expect(primaryAction().disabled).toBeFalse() + }) + + it('drops its own message when the save is tried again', () => { + const save = spyOn(form(), 'save') + form().codeSentChange.emit(true) + form().failed.emit('HTTP') + fixture.detectChanges() + expect(stepError()).toBeTruthy() + + primaryAction().click() + fixture.detectChanges() + + expect(save).toHaveBeenCalled() + expect(stepError()).toBeNull() + }) +}) diff --git a/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.ts b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.ts new file mode 100644 index 0000000000..1dc1d97c88 --- /dev/null +++ b/src/app/two-factor-setup/components/two-factor-recovery-phone/two-factor-recovery-phone.component.ts @@ -0,0 +1,184 @@ +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, +} from '@angular/core' + +import { RecoveryPhoneFormComponent } from '../../../cdk/recovery-phone-form/recovery-phone-form.component' +import { AppEventName } from '../../../rum/app-event-names' +import { RumJourneyEventService } from '../../../rum/service/customEvent.service' + +declare const $localize: any + +/** + * The refusals that end this step: the feature was switched off under the user, + * or 2FA itself is no longer on. Neither leaves anything for the step to do, so + * the flow is allowed to carry on to the recovery codes without a number. + */ +export type TwoFactorRecoveryPhoneRefusal = '2FA_DISABLED' | 'FEATURE_DISABLED' + +const STEP_ENDING_REFUSALS: readonly string[] = [ + '2FA_DISABLED', + 'FEATURE_DISABLED', +] + +/** + * The one question the page and the step have to agree on: does this code end + * the step, or is it something the user can still act on? Anything not listed + * keeps them where they are. + */ +export function endsRecoveryPhoneStep( + code: string +): code is TwoFactorRecoveryPhoneRefusal { + return STEP_ENDING_REFUSALS.includes(code) +} + +/** + * The optional recovery phone number step of 2FA onboarding. + * + * The form itself lives in `app-recovery-phone-form`; this component is the + * step around it: the warning notice, the step chrome, and the two ways out, + * saving a number or skipping it. Both exits report `completed`, because the + * page behind it only cares that the step is over. + * + * A save that fails is not a third way out. Only a refusal that ends the step + * is reported upwards; everything else keeps the user here with a message and a + * live primary action, because a step that walks on after a failed save tells + * the user a number was stored when none was. + * + * Nothing here asks for a password: completing step 1 posted a live 2FA code to + * `2FA/register.json`, which elevates the session for fifteen minutes, so the + * `ONBOARDING` context is what the backend reads instead of a challenge. + */ +@Component({ + selector: 'app-two-factor-recovery-phone', + templateUrl: './two-factor-recovery-phone.component.html', + styleUrls: [ + './two-factor-recovery-phone.component.scss', + './two-factor-recovery-phone.component.scss-theme.scss', + ], + preserveWhitespaces: true, + standalone: false, +}) +export class TwoFactorRecoveryPhoneComponent implements OnInit { + /** + * Supplied by the page, which is the only thing that knows whether the flow + * is two or three steps long. The default is the three step wording because + * this step exists at all only in that flow. + */ + @Input() + subtitle = $localize`:@@account.step2Of3RecoveryPhone:Step 2 of 3 - Recovery phone number` + + /** Fired on both exits: a saved number and a skipped step. */ + @Output() completed = new EventEmitter() + + /** + * Only the refusals that end the step. A save that merely did not land is + * handled here rather than passed on, so the page never advances on one. + */ + @Output() failed = new EventEmitter() + + @ViewChild(RecoveryPhoneFormComponent) + recoveryPhoneForm: RecoveryPhoneFormComponent | undefined + + /** Mirrored from the form: the primary action is dead until a code is out. */ + codeSent = false + + /** + * A failure the step has to explain itself, because the form reported it + * instead of rendering a message for it. It is shown under the form, so the + * form's own field errors stay exactly where they are. + */ + stepErrorMessage: string | null = null + + constructor(private _observability: RumJourneyEventService) {} + + ngOnInit(): void { + this._observability.recordSimpleEvent( + AppEventName.TwoFactorSetupRecoveryPhoneLoaded + ) + } + + /** + * Read straight off the form rather than mirrored, so a save that never + * leaves the browser - an empty or short code - cannot leave the primary + * button stuck in its disabled state. + */ + get saving(): boolean { + return this.recoveryPhoneForm?.saving ?? false + } + + /** + * True when the form is already saying something about what just happened. + * It clears its own errors at the start of every send and every save, so + * anything set by the time a refusal reaches the step belongs to that + * refusal and is the message the user should be reading. + */ + private get formIsExplainingItself(): boolean { + const form = this.recoveryPhoneForm + return !!( + form?.phoneErrorMessage || + form?.codeErrorMessage || + form?.generalErrorMessage + ) + } + + /** The step owns the primary button; the form owns what it posts. */ + onPrimaryAction(): void { + // A retry starts clean, the same way the form clears its own errors + this.stepErrorMessage = null + this.recoveryPhoneForm?.save() + } + + onSaved(): void { + this._observability.recordSimpleEvent( + AppEventName.TwoFactorSetupRecoveryPhoneSaved + ) + this.completed.emit() + } + + onSkipped(): void { + this._observability.recordSimpleEvent( + AppEventName.TwoFactorSetupRecoveryPhoneSkipped + ) + this.completed.emit() + } + + /** + * A refusal from the form. "Failed" is not "step over": only a code that + * leaves this step with nothing left to do may carry the user past it. The + * rest - '2FA_DISABLED' and 'FEATURE_DISABLED' aside, that is 'HTTP' - is + * something a second press can still fix, and stepping over it would hand + * the user a finished setup they believe holds a recovery number. + */ + onFailed(error: string): void { + if (endsRecoveryPhoneStep(error)) { + this.failed.emit(error) + return + } + if (this.formIsExplainingItself) { + // The form has its own message for this one; a second one next to it + // would only argue with it. + return + } + // 'HTTP': the save never reached the registry, so the form has no error + // code to build a message from and this is the only place one can appear. + this.stepErrorMessage = $localize`:@@account.recoveryPhoneOnboardingSaveFailed:We could not save your recovery phone number. Please try again, or skip this step and add your number later from your account settings.` + } + + /** + * The fifteen minute elevation from step 1 ran out while the user was typing + * a number, waiting for a text, or away from the tab. Account settings + * answers this by re-opening its password challenge, but that surface already + * owns one; growing a password dialog here would ask for a password one + * screen after a live 2FA code, which is the trade R2.6 exists to refuse. So + * the step says what happened and leaves both ways out live: press again, or + * skip and add the number from account settings, which challenges properly. + */ + onChallengeRequired(): void { + this.stepErrorMessage = $localize`:@@account.recoveryPhoneOnboardingChallengeExpired:Your recovery phone number was not saved because this step was left open for too long. Please try again, or skip this step and add your number later from your account settings.` + } +} diff --git a/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.html b/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.html index 46ded42781..59e7892b00 100644 --- a/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.html +++ b/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.html @@ -1,5 +1,7 @@
+
+ + diff --git a/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.spec.ts b/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.spec.ts index 5d50f94032..37a4d7cd4e 100644 --- a/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.spec.ts +++ b/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.spec.ts @@ -1,19 +1,21 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { TwoFactorSetupComponent } from './two-factor-setup.component' import { WINDOW_PROVIDERS } from '../../../cdk/window' +import { TogglzService } from '../../../core/togglz/togglz.service' import { TwoFactorAuthenticationService } from '../../../core/two-factor-authentication/two-factor-authentication.service' -import { - HttpClientTestingModule, - HttpTestingController, -} from '@angular/common/http/testing' +import { HttpClientTestingModule } from '@angular/common/http/testing' import { ErrorHandlerService } from '../../../core/error-handler/error-handler.service' import { PlatformInfoService } from '../../../cdk/platform-info' +import { Router } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' import { SnackbarService } from '../../../cdk/snackbar/snackbar.service' import { MatSnackBar } from '@angular/material/snack-bar' import { Overlay } from '@angular/cdk/overlay' -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core' +import { DebugElement } from '@angular/core' import { By } from '@angular/platform-browser' +import { Observable, Subject, isObservable, of, throwError } from 'rxjs' +import { TogglzFlag } from '../../../types/config.endpoint' +import { Status } from '../../../types/two-factor.endpoint' import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' @@ -21,14 +23,73 @@ describe('Component: TwoFactorSetupComponent', () => { let component: TwoFactorSetupComponent let compiled: any let fixture: ComponentFixture + let fakeTogglzService: jasmine.SpyObj + let fakeTwoFactorAuthenticationService: jasmine.SpyObj + let router: Router + + /** + * The flag is read on init, so it has to be set before the fixture exists; + * every test that wants the three step flow builds its own. An observable is + * accepted too, for the tests that care about the moment it answers. + */ + function createComponent( + recoveryPhoneFlag: boolean | Observable = false + ) { + fakeTogglzService.getStateOf.and.returnValue( + isObservable(recoveryPhoneFlag) + ? recoveryPhoneFlag + : of(recoveryPhoneFlag) + ) + fixture = TestBed.createComponent(TwoFactorSetupComponent) + component = fixture.componentInstance + compiled = fixture.debugElement.nativeElement + fixture.detectChanges() + } + + /** + * The step components are not declared here, so a binding to one of them + * lands on the element as a DOM property. That is what the page is + * responsible for; what each step does with it is its own spec. + */ + function boundProperty(selector: string, property: string): any { + return findComponent(fixture, selector)?.nativeElement[property] + } + + /** Walks step 1 out of the way with the backup codes it hands over. */ + function completeStepOne() { + component.twoFactorEnabled({ + backupCodes: 'code1\ncode2', + backupCodesClipboard: 'code1 code2', + }) + fixture.detectChanges() + } beforeEach(() => { + fakeTogglzService = jasmine.createSpyObj('TogglzService', [ + 'getStateOf', + ]) + fakeTogglzService.getStateOf.and.returnValue(of(false)) + fakeTwoFactorAuthenticationService = jasmine.createSpyObj( + 'TwoFactorAuthenticationService', + ['checkState'] + ) + fakeTwoFactorAuthenticationService.checkState.and.returnValue( + of({ enabled: false } as Status) + ) + TestBed.configureTestingModule({ imports: [RouterTestingModule, HttpClientTestingModule], declarations: [TwoFactorSetupComponent], providers: [ WINDOW_PROVIDERS, - TwoFactorAuthenticationService, + { + provide: TwoFactorAuthenticationService, + useValue: fakeTwoFactorAuthenticationService, + }, + { + provide: TogglzService, + useValue: fakeTogglzService, + }, PlatformInfoService, ErrorHandlerService, SnackbarService, @@ -37,13 +98,13 @@ describe('Component: TwoFactorSetupComponent', () => { ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents() + + router = TestBed.inject(Router) + spyOn(router, 'navigate').and.resolveTo(true) }) beforeEach(() => { - fixture = TestBed.createComponent(TwoFactorSetupComponent) - component = fixture.componentInstance - compiled = fixture.debugElement.nativeElement - fixture.detectChanges() + createComponent() }) it('should create the component', () => { @@ -56,11 +117,163 @@ describe('Component: TwoFactorSetupComponent', () => { }) it('should render TwoFactorRecoveryCodesComponent once the 2FA is enabled', () => { - component.showBackupCodes = true + component.step = 'recoveryCodes' fixture.detectChanges() const counter = findComponent(fixture, 'app-two-factor-recovery-codes') expect(counter).toBeTruthy() }) + + it('should send a user who already has 2FA on back to account settings', () => { + expect(router.navigate).toHaveBeenCalledTimes(0) + + fakeTwoFactorAuthenticationService.checkState.and.returnValue( + of({ enabled: true } as Status) + ) + fixture.destroy() + createComponent() + + expect(router.navigate).toHaveBeenCalled() + }) + + it('should render nothing at all until the flag has answered (R2.1)', () => { + const flag = new Subject() + fixture.destroy() + createComponent(flag) + + // the subtitle field still says "of 2" at this point, so rendering step 1 + // here is what makes the user watch it renumber itself + expect(findComponent(fixture, 'app-two-factor-enable')).toBeNull() + expect(compiled.querySelector('.container')).toBeNull() + + flag.next(true) + fixture.detectChanges() + + expect(findComponent(fixture, 'app-two-factor-enable')).toBeTruthy() + expect(boundProperty('app-two-factor-enable', 'subtitle')).toBe( + 'Step 1 of 3 - Authentication app' + ) + }) + + it('should fall back to the two step flow if the flag cannot be read', () => { + fixture.destroy() + createComponent(throwError(() => new Error('togglz is down'))) + + expect(findComponent(fixture, 'app-two-factor-enable')).toBeTruthy() + expect(boundProperty('app-two-factor-enable', 'subtitle')).toBe( + 'Step 1 of 2 - Authentication app' + ) + }) + + it('should read the recovery phone flag exactly once', () => { + expect(fakeTogglzService.getStateOf).toHaveBeenCalledWith( + TogglzFlag.TWO_FACTOR_RECOVERY_PHONE + ) + expect(fakeTogglzService.getStateOf).toHaveBeenCalledTimes(1) + + completeStepOne() + + expect(fakeTogglzService.getStateOf).toHaveBeenCalledTimes(1) + }) + + describe('with the recovery phone flag off', () => { + it('should keep the two step subtitles (R2.1)', () => { + expect(boundProperty('app-two-factor-enable', 'subtitle')).toBe( + 'Step 1 of 2 - Authentication app' + ) + + completeStepOne() + + expect(boundProperty('app-two-factor-recovery-codes', 'subtitle')).toBe( + 'Step 2 of 2 - 2FA recovery codes' + ) + }) + + it('should go straight from the app step to the recovery codes (R2.1)', () => { + completeStepOne() + + expect(component.step).toBe('recoveryCodes') + expect(findComponent(fixture, 'app-two-factor-recovery-phone')).toBeNull() + expect( + findComponent(fixture, 'app-two-factor-recovery-codes') + ).toBeTruthy() + }) + }) + + describe('with the recovery phone flag on', () => { + beforeEach(() => { + fixture.destroy() + createComponent(true) + }) + + it('should renumber the steps as three (R2.1)', () => { + expect(boundProperty('app-two-factor-enable', 'subtitle')).toBe( + 'Step 1 of 3 - Authentication app' + ) + + completeStepOne() + + expect(boundProperty('app-two-factor-recovery-phone', 'subtitle')).toBe( + 'Step 2 of 3 - Recovery phone number' + ) + + component.recoveryPhoneCompleted() + fixture.detectChanges() + + expect(boundProperty('app-two-factor-recovery-codes', 'subtitle')).toBe( + 'Step 3 of 3 - 2FA recovery codes' + ) + }) + + it('should show the recovery phone step after the app step (R2.1)', () => { + completeStepOne() + + expect(component.step).toBe('recoveryPhone') + expect( + findComponent(fixture, 'app-two-factor-recovery-phone') + ).toBeTruthy() + expect(findComponent(fixture, 'app-two-factor-recovery-codes')).toBeNull() + }) + + it('should advance to the recovery codes when the step completes (R2.4, R2.5)', () => { + completeStepOne() + + component.recoveryPhoneCompleted() + fixture.detectChanges() + + expect(component.step).toBe('recoveryCodes') + expect(findComponent(fixture, 'app-two-factor-recovery-phone')).toBeNull() + expect( + findComponent(fixture, 'app-two-factor-recovery-codes') + ).toBeTruthy() + }) + + it('should still hand over the recovery codes when the step can no longer run', () => { + completeStepOne() + + component.recoveryPhoneFailed('2FA_DISABLED') + fixture.detectChanges() + + expect(component.step).toBe('recoveryCodes') + expect( + boundProperty('app-two-factor-recovery-codes', 'backupCodes') + ).toBe('code1\ncode2') + }) + + it('should stay on the recovery phone step when a save fails (R2.4)', () => { + completeStepOne() + + component.recoveryPhoneFailed('HTTP') + fixture.detectChanges() + + // no number was stored, so the codes step would be telling the user the + // step behind them succeeded + expect(component.step).toBe('recoveryPhone') + expect( + findComponent(fixture, 'app-two-factor-recovery-phone') + ).toBeTruthy() + expect(findComponent(fixture, 'app-two-factor-recovery-codes')).toBeNull() + }) + }) }) export function findComponent( diff --git a/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.ts b/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.ts index 8b852675a5..10b2ff05bb 100644 --- a/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.ts +++ b/src/app/two-factor-setup/pages/two-factor/two-factor-setup.component.ts @@ -3,9 +3,17 @@ import { WINDOW } from '../../../cdk/window' import { PlatformInfo, PlatformInfoService } from '../../../cdk/platform-info' import { first, takeUntil } from 'rxjs/operators' import { Subject } from 'rxjs' +import { TogglzService } from '../../../core/togglz/togglz.service' import { TwoFactorAuthenticationService } from '../../../core/two-factor-authentication/two-factor-authentication.service' import { ApplicationRoutes } from '../../../constants' +import { TogglzFlag } from '../../../types/config.endpoint' import { Router } from '@angular/router' +import { endsRecoveryPhoneStep } from '../../components/two-factor-recovery-phone/two-factor-recovery-phone.component' + +declare const $localize: any + +/** The three steps the flow can be on; the middle one only ever with the flag on. */ +export type TwoFactorSetupStep = 'enable' | 'recoveryPhone' | 'recoveryCodes' @Component({ selector: 'app-two-factor-setup-module', @@ -23,13 +31,39 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { platform: PlatformInfo qrCodeUrl: string applicationRoutes = ApplicationRoutes - showBackupCodes = false + step: TwoFactorSetupStep = 'enable' backupCodes: string backupCodesClipboard: string + /** + * Resolved once, on init rather than on the way out of step 1, because step + * 1 already has to say whether it is one of two steps or one of three. + */ + recoveryPhoneEnabled = false + + /** + * Nothing renders until the flag has been read. The subtitles below are + * written for the two step flow, and on a cold load the flag arrives after + * the first render, so without this the user watches "Step 1 of 2" renumber + * itself to "Step 1 of 3" under them. A frame with nothing in it is cheaper + * than a step that changes what it says. + */ + flagResolved = false + + /** + * The step subtitles live here because only the page knows how long the flow + * is. Both wordings keep their own id: the two step strings are the ones the + * registry has already been translated with, and they are what the flag-off + * flow keeps showing. + */ + enableSubtitle = $localize`:@@account.step1AuthenticationApp:Step 1 of 2 - Authentication app` + recoveryPhoneSubtitle = $localize`:@@account.step2Of3RecoveryPhone:Step 2 of 3 - Recovery phone number` + recoveryCodesSubtitle = $localize`:@@account.step2RecoveryCodes:Step 2 of 2 - 2FA recovery codes` + constructor( @Inject(WINDOW) private window: Window, private router: Router, + private _togglz: TogglzService, private _twoFactorService: TwoFactorAuthenticationService, private _platformInfo: PlatformInfoService ) {} @@ -44,6 +78,26 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { } }) + this._togglz + .getStateOf(TogglzFlag.TWO_FACTOR_RECOVERY_PHONE) + .pipe(first(), takeUntil(this.$destroy)) + .subscribe({ + next: (enabled) => { + this.recoveryPhoneEnabled = enabled + if (enabled) { + this.enableSubtitle = $localize`:@@account.step1Of3AuthenticationApp:Step 1 of 3 - Authentication app` + this.recoveryCodesSubtitle = $localize`:@@account.step3Of3RecoveryCodes:Step 3 of 3 - 2FA recovery codes` + } + this.flagResolved = true + }, + error: () => { + // A flag that cannot be read is a flag that is off: the two step flow + // is the one that works without it, and either is better than a page + // that never renders at all. + this.flagResolved = true + }, + }) + this._platformInfo .get() .pipe(takeUntil(this.$destroy)) @@ -56,9 +110,32 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { backupCodes?: string backupCodesClipboard?: string }) { - this.showBackupCodes = true this.backupCodes = $event.backupCodes this.backupCodesClipboard = $event.backupCodesClipboard + this.step = this.recoveryPhoneEnabled ? 'recoveryPhone' : 'recoveryCodes' + } + + /** Saved or skipped: either way the recovery phone step is behind us. */ + recoveryPhoneCompleted(): void { + this.step = 'recoveryCodes' + } + + /** + * The registry refused the step in a way it cannot come back from: the + * feature went off, or 2FA itself did. The codes are the part of setup the + * user cannot be allowed to lose, so the flow moves on rather than stopping + * on a step that can no longer do anything; the number can be added later + * from Account settings. + * + * A save that merely did not land never arrives here - the step keeps the + * user on it and offers the retry - and the guard below is what makes that + * true of this page too, whatever a later caller decides to report. + */ + recoveryPhoneFailed(reason: string): void { + if (!endsRecoveryPhoneStep(reason)) { + return + } + this.step = 'recoveryCodes' } ngOnDestroy(): void { diff --git a/src/app/two-factor-setup/two-factor-setup.module.ts b/src/app/two-factor-setup/two-factor-setup.module.ts index 8313997315..56e0927ba2 100644 --- a/src/app/two-factor-setup/two-factor-setup.module.ts +++ b/src/app/two-factor-setup/two-factor-setup.module.ts @@ -18,8 +18,10 @@ import { ClipboardModule } from '@angular/cdk/clipboard' import { MatTooltipModule } from '@angular/material/tooltip' import { TwoFactorRecoveryCodesComponent } from './components/two-factor-recovery-codes/two-factor-recovery-codes.component' import { TwoFactorEnableComponent } from './components/two-factor-enable/two-factor-enable.component' +import { TwoFactorRecoveryPhoneComponent } from './components/two-factor-recovery-phone/two-factor-recovery-phone.component' +import { RecoveryPhoneFormComponent } from '../cdk/recovery-phone-form/recovery-phone-form.component' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' -import { OrcidStepViewComponent } from '@orcid/ui' +import { AlertMessageComponent, OrcidStepViewComponent } from '@orcid/ui' import { MatCheckboxModule } from '@angular/material/checkbox' @NgModule({ @@ -27,6 +29,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox' TwoFactorSetupComponent, TwoFactorRecoveryCodesComponent, TwoFactorEnableComponent, + TwoFactorRecoveryPhoneComponent, ], imports: [ CommonModule, @@ -47,7 +50,10 @@ import { MatCheckboxModule } from '@angular/material/checkbox' MatTooltipModule, MatProgressSpinnerModule, MatCheckboxModule, + // Standalone components + AlertMessageComponent, OrcidStepViewComponent, + RecoveryPhoneFormComponent, ], }) export class TwoFactorSetupModule {} From d70dcf0b75c596dc2313b4dd3f9af72e8f7cb357 Mon Sep 17 00:00:00 2001 From: cryptalith Date: Wed, 16 Sep 2026 14:20:34 -0600 Subject: [PATCH 05/21] PD-6042 offer the recovery number at sign in --- ...-factor-authentication-form.component.html | 254 +++++++++++--- ...-factor-authentication-form.component.scss | 72 ++++ ...hentication-form.component.scss-theme.scss | 9 + ...ctor-authentication-form.component.spec.ts | 317 +++++++++++++++++- ...wo-factor-authentication-form.component.ts | 231 ++++++++++++- src/app/core/sign-in/sign-in.service.spec.ts | 91 ++++- src/app/core/sign-in/sign-in.service.ts | 77 ++++- .../link-account/link-account.component.html | 7 + .../form-sign-in/form-sign-in.component.html | 3 + .../form-sign-in.component.spec.ts | 192 ++++++++++- .../form-sign-in/form-sign-in.component.ts | 213 +++++++++++- .../two-factor-disabled.component.html | 44 +++ .../two-factor-disabled.component.scss | 58 ++++ .../two-factor-disabled.component.spec.ts | 91 +++++ .../two-factor-disabled.component.ts | 89 +++++ .../pages/sign-in/sign-in.component.html | 21 ++ .../pages/sign-in/sign-in.component.spec.ts | 89 +++++ .../pages/sign-in/sign-in.component.ts | 71 ++++ src/app/sign-in/sign-in.module.ts | 2 + src/app/types/sign-in.endpoint.ts | 23 ++ src/app/types/sign-in.local.ts | 6 + 21 files changed, 1892 insertions(+), 68 deletions(-) create mode 100644 src/app/sign-in/components/two-factor-disabled/two-factor-disabled.component.html create mode 100644 src/app/sign-in/components/two-factor-disabled/two-factor-disabled.component.scss create mode 100644 src/app/sign-in/components/two-factor-disabled/two-factor-disabled.component.spec.ts create mode 100644 src/app/sign-in/components/two-factor-disabled/two-factor-disabled.component.ts diff --git a/src/app/cdk/two-factor-authentication-form/two-factor/two-factor-authentication-form.component.html b/src/app/cdk/two-factor-authentication-form/two-factor/two-factor-authentication-form.component.html index 94c4d7a7c8..52cdd757e4 100644 --- a/src/app/cdk/two-factor-authentication-form/two-factor/two-factor-authentication-form.component.html +++ b/src/app/cdk/two-factor-authentication-form/two-factor/two-factor-authentication-form.component.html @@ -7,13 +7,13 @@ [ngClass]="{ error: isVerificationCodeInvalid }" - *ngIf="!recoveryCode" + *ngIf="authenticationCodeMode" >Two-factor authentication code - +
-

- Enter a code - - - from your two-factor authentication app - -

-

- - Don't have your device? - - Enter a recovery code - -

-

- - Don't have your device or recovery code? - - ORCID Help Center - -

+ +

+ Enter a code + + + from your two-factor authentication app + +

+

+ + Don't have your device? + + + Enter a recovery code + + +

+

+ + Don't have your device or recovery code? + + ORCID Help Center + +

+

+ + Don't have your device or your recovery codes? + + +

+
+ +
+ + Recovery number code + + + + +
+

+ Enter the 6-digit verification code sent to your device +

+ +
+ +
+ +

+ + Don't have your recovery codes? + + +

+

+ + Didn't get the code? + + + You can resend in {{ recoveryPhoneResendSeconds }} seconds + + +

+
+