Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
244ba47
PD-6046 PD-6045 add 2FA recovery phone number
cryptalith Aug 27, 2026
4363674
PD-13366 PD-13367 spacing and layout fixes for recovery phone number
cryptalith Sep 7, 2026
a70cade
PD-5850 PD-6044 extract the shared recovery phone form
cryptalith Sep 16, 2026
43f1ecc
PD-6044 add the recovery phone step to 2FA onboarding
cryptalith Sep 16, 2026
d70dcf0
PD-6042 offer the recovery number at sign in
cryptalith Sep 16, 2026
20939e5
PD-6043 offer the recovery number in auth challenges
cryptalith Sep 16, 2026
cfabf7f
PD-5850 add the recovery phone interstitial
cryptalith Sep 16, 2026
95965d6
PD-6044 regenerate the translation catalogue
cryptalith Sep 16, 2026
14a3aea
PD-6042 sign-in 2FA copy and control match the design
cryptalith Sep 17, 2026
e6b7a05
PD-6043 challenge icon and helper match the design
cryptalith Sep 17, 2026
dd3cac5
PD-6046 panel copy, header and rules follow the design
cryptalith Sep 17, 2026
ab7f963
PD-6044 phone field and notice take the design tokens
cryptalith Sep 17, 2026
c010b84
PD-13635 tell the user when the text cap is reached
cryptalith Sep 18, 2026
9e054fa
PD-13635 explain a resend refused with no code shown
cryptalith Sep 18, 2026
7f7b391
PD-13635 PD-6042 PD-6043 regenerate the translation catalogue
cryptalith Sep 18, 2026
2c15e83
PD-13635 stop offering a resend once the cap is reached
cryptalith Sep 21, 2026
754ef83
PD-13638 every surface exits when the elevation ends
cryptalith Sep 21, 2026
64ccdea
PD-13638 let a test shorten the client elevation timer
cryptalith Sep 21, 2026
19c006b
PD-13638 regenerate the translation catalogue
cryptalith Sep 21, 2026
a6e8cf9
PD-13635 keep a way out of the capped challenge
cryptalith Sep 21, 2026
7fac0ab
PD-13638 the interstitial reports one outcome only
cryptalith Sep 21, 2026
ddff2fd
Merge branch 'main' into lmendoa/PD-6046-PD-6045-2fa-recovery-phone
cryptalith Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@
[ngClass]="{ progress: !data }"
></mat-progress-bar>
}
<div
class="icon self-center w-16 h-16 mb-8 content-center ml-auto mr-auto rounded-[50%] text-center"
>
<mat-icon class="material-symbols-outlined">person_check</mat-icon>
<div class="self-center w-16 h-16 mb-8 ml-auto mr-auto text-center">
<img
class="w-16 h-16"
src="./assets/vectors/orcid.logo.icon.svg"
alt=""
aria-hidden="true"
/>
</div>
<h1 class="orc-font-heading-small text-center font-normal" i18n>
<h1
[id]="headingId"
class="orc-font-heading-small text-center font-normal"
i18n
>
Verify your ORCID account
</h1>
<p class="orc-font-body-small text-center mb-8">
Expand Down Expand Up @@ -75,7 +82,126 @@ <h1 class="orc-font-heading-small text-center font-normal" i18n>
</mat-error>
}
</div>
} @if (showTwoFactorField) { @if (!showRecoveryCode) {
} @if (showTwoFactorField) { @if (showRecoveryPhoneCode) {
<!--
The third mode (R5.2): a code the registry has just sent by text. Accepting
it turns 2FA off, so it is deliberately the last option offered.
-->
<div>
<mat-label
class="orc-font-small-print font-bold! block mb-2! text-left"
id="recovery-phone-input-label"
i18n="@@account.recoveryPhoneNumberCode"
[ngClass]="{
error:
twoFactorRecoveryPhoneCodeWasTouched &&
parentForm.get(recoveryPhoneControlName)?.invalid
}"
>
Recovery phone number code
</mat-label>
<mat-form-field
appearance="outline"
[hideRequiredMarker]="true"
class="no-hint w-full"
>
<input
id="twoFactorRecoveryPhoneCode"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
aria-labelledby="recovery-phone-input-label"
[attr.aria-describedby]="
recoveryPhone?.errorCode
? 'twoFactorRecoveryPhoneCode-helper twoFactorRecoveryPhoneCode-send-error'
: 'twoFactorRecoveryPhoneCode-helper'
"
[formControlName]="recoveryPhoneControlName"
#twoFactorRecoveryPhoneCodeInput
[errorStateMatcher]="errorMatcher"
matInput
[ngClass]="{
error:
twoFactorRecoveryPhoneCodeWasTouched &&
parentForm.get(recoveryPhoneControlName)?.invalid
}"
/>
</mat-form-field>
<div class="flex orc-font-small-print">
<div id="twoFactorRecoveryPhoneCode-helper" class="mt-2 grow text-left">
@if (!parentForm.get(recoveryPhoneControlName)?.invalid ||
!twoFactorRecoveryPhoneCodeWasTouched) {
<mat-hint
class="leading-6"
i18n="@@account.enterSixDigitCodeSentToYourDevice"
>
Enter the 6-digit code sent to your device
</mat-hint>
} @if (parentForm.get(recoveryPhoneControlName)?.hasError('invalid')) {
<mat-error>
<ng-container i18n="@@account.badRecoveryPhoneNumberCode">
Invalid recovery phone number code
</ng-container>
</mat-error>
} @if (twoFactorRecoveryPhoneCodeWasTouched &&
parentForm.get(recoveryPhoneControlName)?.hasError('required')) {
<mat-error>
<ng-container i18n="@@account.recoveryPhoneNumberCodeRequired">
A recovery phone number code is required
</ng-container>
</mat-error>
} @if (twoFactorRecoveryPhoneCodeWasTouched &&
(parentForm.get(recoveryPhoneControlName)?.hasError('minlength') ||
parentForm.get(recoveryPhoneControlName)?.hasError('maxlength'))) {
<mat-error>
<ng-container i18n="@@account.recoveryPhoneNumberCodeLength">
Invalid recovery phone number code length
</ng-container>
</mat-error>
}
</div>
<mat-hint
[ngClass]="{
error:
twoFactorRecoveryPhoneCodeWasTouched &&
parentForm.get(recoveryPhoneControlName)?.invalid
}"
class="mt-2 self-center leading-6"
>
{{ parentForm.get(recoveryPhoneControlName)?.value?.length || 0 }}/6
</mat-hint>
</div>
@if (recoveryPhone?.errorCode === 'SEND_LIMIT_REACHED') {
<!--
A branch rather than one message with two meanings: "try again" is wrong
advice for a cap that only lifts tomorrow, and it is the advice a user
would follow first.
-->
<mat-error
id="twoFactorRecoveryPhoneCode-send-error"
role="alert"
class="w-full orc-font-small-print mt-2 leading-6"
i18n="@@account.recoveryPhoneChallengeSendLimitReached"
>
Too many codes have been sent to your recovery phone number today. Please
try again tomorrow, or use your authentication app or a recovery code.
</mat-error>
} @else if (recoveryPhone?.errorCode) {
<!--
This error belongs to the send, not to the field, so it sits outside the
form field and has to announce itself.
-->
<mat-error
id="twoFactorRecoveryPhoneCode-send-error"
role="alert"
class="w-full orc-font-small-print mt-2 leading-6"
i18n="@@account.recoveryPhoneChallengeCodeNotSent"
>
We could not send a code to your recovery phone number. Please try again.
</mat-error>
}
</div>
} @else if (!showRecoveryCode) {
<div>
<mat-label
class="orc-font-small-print font-bold! block mb-2! text-left"
Expand Down Expand Up @@ -112,8 +238,8 @@ <h1 class="orc-font-heading-small text-center font-normal" i18n>
<div class="mt-2 grow text-left">
@if (!parentForm.get(codeControlName)?.invalid ||
!twoFactorCodeWasTouched) {
<mat-hint class="leading-6" i18n="@@account.enterTheCode">
Enter the code from your two-factor authentication app
<mat-hint class="leading-6" i18n="@@account.enterTheSixDigitCode">
Enter the 6-digit code from your authentication app
</mat-hint>
} @if (parentForm.get(codeControlName)?.hasError('invalid')) {
<mat-error>
Expand Down Expand Up @@ -226,7 +352,74 @@ <h1 class="orc-font-heading-small text-center font-normal" i18n>
</mat-hint>
</div>
</div>
} @if (!showRecoveryCode) {
} @if (showRecoveryPhoneCode) {
<div class="text-center orc-font-body-small mt-6">
<!--
The one thing worth announcing, announced once. The countdown below is
deliberately not in here: it changes every second, and a live region
would read the whole sentence over a user trying to type the code. The
region itself is always rendered so that the message lands in it rather
than arriving with it.
-->
<p class="visually-hidden" aria-live="polite">
@if (recoveryPhone?.codeSent && recoveryPhone?.maskedNumber) {
<ng-container i18n="@@account.recoveryPhoneCodeSentTo"
>Verification code sent to</ng-container
>&nbsp;{{ recoveryPhone?.maskedNumber }}
}
</p>
<!--
"Didn't get the code? Resend" is the wrong offer once the record's daily
allowance is spent: the send it offers cannot succeed until tomorrow, and
it sits directly under a message that says so. The question goes with the
button, because it has no answer either - but only those two. What is
below them is the way out of this mode, and hiding that as well left a
user who reached the cap with no route back to their authentication app
or their recovery codes, on a screen whose only input is a code that can
no longer be sent.
-->
@if (recoveryPhone?.errorCode !== 'SEND_LIMIT_REACHED') {
<p i18n="@@account.didntGetTheCode" class="m-0 leading-6">
Didn't get the code?
</p>
@if (recoveryPhone?.resendSeconds) {
<p class="m-0 leading-6" aria-hidden="true">
<ng-container i18n="@@account.youCanResendIn"
>You can resend in</ng-container
>&nbsp;<strong
>{{ recoveryPhone?.resendSeconds }}&nbsp;<ng-container
i18n="@@account.seconds"
>seconds</ng-container
></strong
>
</p>
} @else {
<button
type="button"
id="cy-challenge-resend-recovery-phone-code"
class="link-button"
data-testid="recovery-phone-resend"
[disabled]="recoveryPhone?.sending"
(click)="resendRecoveryPhoneCode($event)"
i18n="@@account.resendCode"
>
Resend code
</button>
} }
<p i18n="@@account.dontHaveYourRecoveryCodes" class="m-0 leading-6 mt-4!">
Don't have your recovery codes?
</p>
<button
type="button"
class="link-button"
data-testid="recovery-phone-back-toggle"
(click)="useAuthenticationApp($event)"
i18n="@@account.useYourAuthenticationAppInstead"
>
Use your authentication app instead
</button>
</div>
} @else { @if (!showRecoveryCode) {
<div class="text-center orc-font-body-small mt-6">
<p i18n="@@ngOrcid.signin.2fa.noDevice1" class="m-0 leading-6">
Don't have your device?
Expand Down Expand Up @@ -256,7 +449,32 @@ <h1 class="orc-font-heading-small text-center font-normal" i18n>
Use your authentication app instead
</a>
</div>
}
} @if (recoveryPhoneAvailable) {
<!--
R5.1: only offered when the account can actually use it - the flag is on,
2FA is active and a number is stored. Otherwise the help centre link that
has always been here is what the user gets.
-->
<div class="text-center orc-font-body-small mt-4!">
<p
i18n="@@account.dontHaveYourDeviceOrYourRecoveryCodes"
class="m-0 leading-6"
>
Don't have your device or your recovery codes?
</p>
<button
type="button"
id="cy-challenge-send-recovery-phone-code"
class="link-button"
data-testid="recovery-phone-toggle"
[disabled]="recoveryPhone?.sending"
(click)="sendRecoveryPhoneCode($event)"
i18n="@@account.sendCodeToRecoveryPhoneNumber"
>
Send a code to your recovery phone number and disable 2FA
</button>
</div>
} @else {
<div class="text-center orc-font-body-small mt-4!">
<p i18n="@@ngOrcid.signin.2fa.noDeviceOrRecovery" class="m-0 leading-6">
Don't have your device or recovery code?
Expand All @@ -271,11 +489,12 @@ <h1 class="orc-font-heading-small text-center font-normal" i18n>
ORCID help centre
</a>
</div>
}
} } }
<hr />
<button
mat-raised-button
type="submit"
id="cy-verify-account-continue"
class="verify-button row orc-font-body mat-elevation-z0 text-center h-10! mb-6 block w-full"
i18n
[disabled]="loading || parentForm.invalid || parentForm.pending"
Expand All @@ -290,7 +509,9 @@ <h1 class="orc-font-heading-small text-center font-normal" i18n>
<button
mat-button
type="button"
id="cy-cancel-account-verification"
(click)="onCancel()"
[disabled]="verifying"
class="text-center orc-font-body-small h-10! w-full block content-center cancel font-normal!"
i18n
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,54 @@ mat-icon {
text-decoration-line: underline;
}

/*
For text that belongs in the accessibility tree and nowhere else - the live
region that says the code went out. Off the screen rather than display:none,
which would take it out of the tree as well.
*/
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

/*
The toggles that are anchors elsewhere in this panel are buttons here: they
run an action rather than going anywhere, and an anchor with no href is not
reachable by keyboard. Material ships no focus ring, so the house one is
written out below.
*/
.link-button {
background: none;
border: 0;
padding: 0;
font-family: inherit;
font-size: var(--orcid-font-size-body-small, 14px);
line-height: 21px;
font-weight: 400;
letter-spacing: 0.25px;
text-decoration-line: underline;
cursor: pointer;

&:focus-visible {
box-shadow: inset 0 0 0 2px var(--orcid-color-ui-focus, #1098ff);
outline: none;
}

&[disabled] {
cursor: default;
opacity: 50%;
text-decoration-line: none;
}
}

mat-progress-bar {
margin-top: -64px;
margin-left: -64px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
background-color: white !important;
color: $brand-secondary-dark !important;
}
// The blanket button rule above paints every button as a solid block, which
// is wrong for a control that has to read as a link.
.link-button {
background: none !important;
color: $brand-secondary-dark !important;
}
}

@include auth-challenge-theme($orcid-app-theme);
Loading
Loading