diff --git a/projects/orcid-ui-docs/src/app/app.routes.ts b/projects/orcid-ui-docs/src/app/app.routes.ts index 626ed507ac..081114c591 100644 --- a/projects/orcid-ui-docs/src/app/app.routes.ts +++ b/projects/orcid-ui-docs/src/app/app.routes.ts @@ -93,6 +93,13 @@ export const routes: Routes = [ (m) => m.SkeletonPlaceholderPageComponent ), }, + { + path: 'step-view', + loadComponent: () => + import('./pages/orcid-ui/step-view-page.component').then( + (m) => m.StepViewPageComponent + ), + }, { path: 'auth-challenge', loadComponent: () => diff --git a/projects/orcid-ui-docs/src/app/docs-shell.component.html b/projects/orcid-ui-docs/src/app/docs-shell.component.html index df953dd98e..7a68d14b48 100644 --- a/projects/orcid-ui-docs/src/app/docs-shell.component.html +++ b/projects/orcid-ui-docs/src/app/docs-shell.component.html @@ -34,6 +34,7 @@

Orcid UI

Skeleton Placeholder + Step View Material Buttons Directives diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.html new file mode 100644 index 0000000000..07164c3a6e --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.html @@ -0,0 +1,103 @@ + +
+

Change these values to preview `orcid-step-view` behavior:

+
+ + Title + + + + + Subtitle + + + + + Primary action label + + + + + Disable primary action + + + + Full width actions + +
+
+ +
+
+ +

+ Use this projected body region for form controls and explanatory text. +

+

+ The parent flow controls step transitions and handles output events. +

+ + +
+
+
+ +
+
<orcid-step-view
+  [title]="title"
+  [subtitle]="subtitle"
+  [primaryLabel]="primaryLabel"
+  [primaryDisabled]="isPrimaryDisabled"
+  (primaryAction)="onPrimary()"
+>
+  <div>Step content goes here</div>
+
+  <div orcidStepViewFooter>
+    <button mat-button type="button">Extra footer control</button>
+  </div>
+</orcid-step-view>
+
+ +
+

Inputs

+ + +

Outputs

+ + +

Slots

+ +
+
diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.scss new file mode 100644 index 0000000000..269813dc3a --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.scss @@ -0,0 +1,19 @@ +.controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.example-container { + max-width: 680px; +} + +.body-copy { + margin: 0; +} + +.preview-footer { + display: flex; + justify-content: flex-end; +} diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.ts new file mode 100644 index 0000000000..88a9f2abeb --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/step-view-page.component.ts @@ -0,0 +1,35 @@ +import { CommonModule } from '@angular/common' +import { Component } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatCheckboxModule } from '@angular/material/checkbox' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import { OrcidStepViewComponent } from '@orcid/ui' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' + +@Component({ + selector: 'orcid-step-view-page', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + OrcidStepViewComponent, + DocumentationPageComponent, + ], + templateUrl: './step-view-page.component.html', + styleUrls: ['./step-view-page.component.scss'], +}) +export class StepViewPageComponent { + config = { + title: 'Enable two-factor authentication', + subtitle: 'Step 1 of 2 - Authentication app', + primaryLabel: 'Next step - 2FA recovery codes', + primaryDisabled: false, + fullWidthActions: true, + } +} diff --git a/projects/orcid-ui/src/lib/components/step-view/step-view.component.html b/projects/orcid-ui/src/lib/components/step-view/step-view.component.html new file mode 100644 index 0000000000..0aefcc819a --- /dev/null +++ b/projects/orcid-ui/src/lib/components/step-view/step-view.component.html @@ -0,0 +1,36 @@ +
+
+ +

{{ title }}

+

{{ subtitle }}

+
+ +
+ +
+ + +
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 new file mode 100644 index 0000000000..ae993fb231 --- /dev/null +++ b/projects/orcid-ui/src/lib/components/step-view/step-view.component.scss @@ -0,0 +1,105 @@ +:host { + display: block; +} + +.orcid-step-view { + background: var(--orcid-surface, #ffffff); + border: 1px solid var(--orcid-ui-background, #bdbdbd); + border-radius: var(--orcid-space-2, 8px); + padding: var(--orcid-space-xl, 64px); + display: flex; + flex-direction: column; + gap: var(--orcid-space-8, 32px); +} + +.orcid-step-view__header { + display: flex; + flex-direction: column; + gap: var(--orcid-space-2, 8px); + text-align: center; +} + +.orcid-step-view__icon { + width: 64px; + height: 64px; + margin: 0 auto; + margin-bottom: var(--orcid-space-m, 24px); +} + +.orcid-step-view__title { + margin: 0; + color: var(--orcid-color-text-dark-high, #000000); + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-heading-small, 24px); + font-weight: var(--orcid-font-weight-medium, 500); + line-height: 1.5; +} + +.orcid-step-view__subtitle { + margin: 0; + color: rgba(0, 0, 0, 0.6); + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-body-small, 14px); + font-weight: var(--orcid-font-weight-regular, 400); + line-height: 1.5; +} + +.orcid-step-view__body { + display: flex; + flex-direction: column; +} + +.orcid-step-view__footer { + display: flex; + flex-direction: column; + gap: var(--orcid-space-8, 32px); +} + +.orcid-step-view__footer-divider { + width: 100%; + border-top: 1px solid var(--orcid-color-border-subtle, #dddddd); +} + +.orcid-step-view__actions { + display: flex; + gap: var(--orcid-space-4, 16px); + justify-content: center; +} + +.orcid-step-view__actions--full { + width: 100%; +} + +.orcid-step-view__actions--full button { + flex: 1 1 auto; +} + +.orcid-step-view__primary-action { + background-color: var(--orcid-color-brand-secondary-dark, #085c77) !important; + color: var(--orcid-color-text-light-high, #ffffff) !important; +} + +.orcid-step-view__primary-action[disabled] { + opacity: 0.6; +} + +@media (max-width: 767px) { + .orcid-step-view { + padding: var(--orcid-space-4, 16px); + border: 0; + border-radius: 0; + } + + .orcid-step-view__icon { + margin-bottom: var(--orcid-space-s, 8px); + } + + .orcid-step-view__actions { + flex-direction: column; + width: 100%; + } + + .orcid-step-view__actions button { + width: 100%; + } +} 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 new file mode 100644 index 0000000000..5488fce0c0 --- /dev/null +++ b/projects/orcid-ui/src/lib/components/step-view/step-view.component.ts @@ -0,0 +1,38 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { NgIf } from '@angular/common' +import { MatButtonModule } from '@angular/material/button' + +@Component({ + selector: 'orcid-step-view', + standalone: true, + imports: [NgIf, MatButtonModule], + templateUrl: './step-view.component.html', + styleUrls: ['./step-view.component.scss'], +}) +export class OrcidStepViewComponent { + /** Main title displayed at the top of the step view. */ + @Input() title = '' + + /** Optional subtitle shown below title (e.g. Step 1 of 2). */ + @Input() subtitle = '' + + /** Show ORCID icon block in header. */ + @Input() showHeaderIcon = true + + /** Header icon source path. */ + @Input() headerIconSrc = 'assets/vectors/orcid.logo.icon.svg' + + /** Header icon alt text. */ + @Input() headerIconAlt = 'orcid logo' + + /** Optional text for the primary action button. */ + @Input() primaryLabel = '' + + /** Disables the primary action button when true. */ + @Input() primaryDisabled = false + + /** Whether footer buttons should fill the container width. */ + @Input() fullWidthActions = true + + @Output() primaryAction = new EventEmitter() +} diff --git a/projects/orcid-ui/src/public-api.ts b/projects/orcid-ui/src/public-api.ts index 8c78466b78..2b9363cbfc 100644 --- a/projects/orcid-ui/src/public-api.ts +++ b/projects/orcid-ui/src/public-api.ts @@ -15,3 +15,4 @@ export * from './lib/components/action-surface-container/action-surface-containe export * from './lib/components/panel/panel.component' export * from './lib/components/skeleton-placeholder/skeleton-placeholder.component' export * from './lib/components/modal/modal.component' +export * from './lib/components/step-view/step-view.component' 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 65af088a35..fa6dff8da7 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 @@ -11,40 +11,110 @@ Two-factor authentication was not disabled - } -

- + } @if (!twoFactorState) { +

+

Add extra security to your ORCID account by enabling two-factor authentication (2FA). - -
- - Each time you sign in to ORCID you’ll be prompted to enter a six-digit - code we send to your preferred 2FA authentication application. - -

-

- - Two-factor authentication is currently: - -

-

- ON - OFF -

-
+

+ + Learn more about two-factor authentication + +
+ } @else { +
+
+

Sign in with 2FA

+

+ Use your authentication app to generate a verification code whenever you + sign in to ORCID. +

+
+ +
+

+ Authentication app +

+
+
+
+ +
+

Account recovery

+

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

+
+ +
+

+ 2FA recovery codes +

+
+
+
+ +

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

+ + Find out more about disabling two-factor authentication + + + +
+ } 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 e69de29bb2..34e337e30e 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 @@ -0,0 +1,80 @@ +.two-factor-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.two-factor-panel--enabled { + gap: 24px; +} + +.two-factor-panel__section { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 16px; + border-bottom: 1px solid #e0e0e0; +} + +.two-factor-panel__section h4 { + margin: 0; + font-size: 16px; + line-height: 24px; +} + +.two-factor-panel__section p { + margin: 0; +} + +.two-factor-panel__copy { + margin: 0; +} + +.two-factor-panel__copy--intro { + margin-top: var(--orcid-space-l, 32px); +} + +.two-factor-panel__link { + display: inline-flex; + align-items: center; + gap: 4px; + width: fit-content; + text-decoration: underline; +} + +.two-factor-panel__link--disable-info { + margin-top: calc(var(--orcid-space-s, 8px) - var(--orcid-space-m, 24px)); + margin-bottom: calc(var(--orcid-space-l, 32px) - var(--orcid-space-m, 24px)); +} + +.two-factor-panel__section-description { + margin-bottom: var(--orcid-space-m, 24px) !important; +} + +.two-factor-panel__requirement { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + border-top: 1px solid var(--orcid-color-border-subtle, #dddddd); + padding-top: 16px; +} + +.two-factor-panel__requirement-title { + margin: 0; + font-weight: 700; +} + +.two-factor-panel__requirement-icon { + color: var(--orcid-color-brand-primary-dark, #7faa26); + width: 24px; + height: 24px; + font-size: 24px; +} + +.two-factor-panel__disable-button { + align-self: flex-start; + background: var(--orcid-color-state-warning-dark, #d32f2f) !important; + border-color: var(--orcid-color-state-warning-dark, #d32f2f) !important; + color: #fff !important; +} diff --git a/src/app/cdk/account-panel/settings-panels/settings-panels.component.scss b/src/app/cdk/account-panel/settings-panels/settings-panels.component.scss index b89e3322d1..9c9716514f 100644 --- a/src/app/cdk/account-panel/settings-panels/settings-panels.component.scss +++ b/src/app/cdk/account-panel/settings-panels/settings-panels.component.scss @@ -39,6 +39,11 @@ strong { margin-inline-end: 3px; } + + .on, + .off { + font-weight: 400; + } } .actions-container { display: flex; diff --git a/src/app/rum/app-event-names.ts b/src/app/rum/app-event-names.ts index fd90aeb33f..990b93c43f 100644 --- a/src/app/rum/app-event-names.ts +++ b/src/app/rum/app-event-names.ts @@ -17,6 +17,10 @@ export enum AppEventName { SignInFailure = 'sign_in_failure', /** HTTP/network failure on sign-in POST (distinct from application-level sign_in_failure). */ SignInHttpError = 'sign_in_http_error', + TwoFactorSetupStep1Loaded = 'two_factor_setup_step1_loaded', + TwoFactorSetupStep2Loaded = 'two_factor_setup_step2_loaded', + TwoFactorSetupFinalButtonClicked = 'two_factor_setup_final_button_clicked', + TwoFactorSetupFinalCompleted = 'two_factor_setup_final_completed', // ─── Orcid registration journey events ───────────────────────────────────── StepASignInButtonClicked = 'step-a-sign-in-button-clicked', diff --git a/src/app/rum/terminating-rum-events.ts b/src/app/rum/terminating-rum-events.ts index dc519b03fb..fed1708840 100644 --- a/src/app/rum/terminating-rum-events.ts +++ b/src/app/rum/terminating-rum-events.ts @@ -27,6 +27,7 @@ export const TERMINATING_SIMPLE_EVENT_NAMES: ReadonlySet = new Set([ AppEventName.SignInGuardRedirectToRegister, AppEventName.SignInOauthInvalidGrantLegacy, AppEventName.TwoFactorSignInGuardRedirectToMyOrcid, + AppEventName.TwoFactorSetupFinalCompleted, AppEventName.RegisterGuardRedirectToAuthorize, AppEventName.RegisterPipelineError, AppEventName.OauthAuthorizationValidationFailed, 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 dca4214891..9e4f681e41 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,185 +1,136 @@
-

- - Add extra security to your ORCID account by enabling two-factor - authentication (2FA). - - - Each time you sign in to ORCID you’ll be prompted to enter a six-digit - code we send to your preferred 2FA authentication application. - -

- -
-
- - 1. Install a two-factor authentication app - -

- A 2FA app is required to create the six-digit code you need to access - your account each time you sign in. Most apps are for mobile devices; - some are also available as desktop or web-based apps. -

-

- - Download and install a 2FA app, such as - - Google Authenticator - , - FreeOTP - , - or - Authy -
- See the Knowledge Base for a list of apps we've tested ourselves -

-
-
- 2. Scan the QR code with your device -

- Open your 2FA app and scan the QR code image below. -

- qr code -

- - Can't scan the barcode? - - Get a text code - - and enter it into your 2FA app instead. - -

+ +

+ Scan the QR code with your authentication app. +

+ +
+ qr code
-
+ +

+ Can't scan the QR code? + + +

+ +
+
-
- - 3. Enter the six-digit code from your authentication app - -

- After scanning the QR code or entering the text code your 2FA app will - generate a six-digit code. Enter the code below. + +

+

+ Enter the 6-digit verification code from your authentication app

-
-
-
- - - {{ inputVerificationCode.value?.length || 0 }}/6 - - + + {{ inputVerificationCode.value?.length || 0 }}/6 + + +

-

- Authentication code is required -

-

- {{ inputVerificationCode.value?.length || 0 }}/6 -

-
+ Authentication code is required +

+

+ {{ inputVerificationCode.value?.length || 0 }}/6 +

+
- +

+ Invalid authentication code length +

+

+ {{ inputVerificationCode.value?.length || 0 }}/6 +

+
+ + +

-

- Invalid authentication code length -

-

- {{ inputVerificationCode.value?.length || 0 }}/6 -

-
-
- - error - Invalid authentication code - +

+

+ {{ inputVerificationCode.value?.length || 0 }}/6 +

-
+
-
- -
-
- - + mode="indeterminate" + >
-
+ diff --git a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.scss b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.scss index 97d8dae3d1..fb49e83a2e 100644 --- a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.scss +++ b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.scss @@ -1,23 +1,29 @@ -div { - p { - margin-top: 4px; - margin-bottom: 24px; - } -} - -div.knowledge-base { - margin-bottom: 24px; +textarea.text-code { + flex: 1; + line-height: 21px; + padding: 8px; + margin-bottom: 0; + resize: none; } -textarea.text-code { - width: 100%; - line-height: 8px; - padding: 16px 8px 8px; - margin-bottom: 16px; +.text-code-wrapper { + display: flex; + align-items: stretch; + gap: var(--orcid-space-s, 8px); + margin-bottom: var(--orcid-space-m, 24px); } -.row .col { +.textarea-copy-action { + flex: 0 0 40px; + border: 1px solid var(--orcid-color-border-subtle, #dddddd); + border-radius: 2px; + background: #fff; padding: 0; + color: var(--orcid-color-brand-secondary-dark, #085c77); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; } mat-error { @@ -47,3 +53,70 @@ mat-error { align-items: flex-start; margin-top: 16px; } + +.step-copy { + margin: 0; + font-size: var(--orcid-font-size-body-small, 14px); + line-height: 21px; + letter-spacing: 0.25px; +} + +.qr-wrapper { + display: flex; + justify-content: center; + align-items: center; + width: 220px; + height: 220px; + margin: var(--orcid-space-m, 24px) auto; + overflow: hidden; +} + +.qr-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + transform: scale(1.3); + transform-origin: center; +} + +.text-code-trigger { + background: transparent; + border: 0; + cursor: pointer; + padding: 0; + text-align: left; + color: var(--orcid-color-brand-secondary-dark, #085c77); +} + +.setup-code-row { + margin-bottom: var(--orcid-space-l, 32px); +} + +.verification-section { + border-top: 1px solid var(--orcid-color-border-subtle, #dddddd); + padding-top: var(--orcid-space-l, 32px); +} + +.verification-section .step-copy { + margin-bottom: var(--orcid-space-base, 16px); +} + +.mat-form-field-min { + width: 100%; +} + +.loading-wrapper { + display: flex; + justify-content: center; +} + +mat-error, +mat-error .mat-icon { + color: var(--orcid-color-state-warning-darkest, #b71c1c) !important; + font-size: 12px; +} + +.error-message, +.error-length { + font-size: 12px; +} diff --git a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.scss-theme.scss b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.scss-theme.scss index f7ffa09600..a92b8c246a 100644 --- a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.scss-theme.scss +++ b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.scss-theme.scss @@ -9,10 +9,6 @@ $background: map-get($theme, background); $config: mat.m2-define-typography-config(); - p { - font-size: mat.m2-font-size($config, body-1) !important; - } - a { font-size: mat.m2-font-size($config, body-1) !important; } diff --git a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.spec.ts b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.spec.ts index a0f8f61b32..f7af6f784a 100644 --- a/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.spec.ts +++ b/src/app/two-factor-setup/components/two-factor-enable/two-factor-enable.component.spec.ts @@ -17,11 +17,20 @@ import { MatInputModule } from '@angular/material/input' import { NoopAnimationsModule } from '@angular/platform-browser/animations' import { of } from 'rxjs' import { TwoFactorSetup } from 'src/app/types/two-factor.endpoint' +import { OrcidStepViewComponent } from '@orcid/ui' +import { MatIconModule } from '@angular/material/icon' +import { MatButtonModule } from '@angular/material/button' +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' +import { RumJourneyEventService } from '../../../rum/service/customEvent.service' +import { AppEventName } from '../../../rum/app-event-names' +import { MatTooltipModule } from '@angular/material/tooltip' +import { ClipboardModule } from '@angular/cdk/clipboard' describe('TwoFactorEnableComponent', () => { let component: TwoFactorEnableComponent let fixture: ComponentFixture let fakeTwoFactorAuthenticationService: TwoFactorAuthenticationService + let fakeObservability: jasmine.SpyObj let debugElement: DebugElement beforeEach(async () => { @@ -33,6 +42,10 @@ describe('TwoFactorEnableComponent', () => { register: of({ valid: true } as TwoFactorSetup), } ) + fakeObservability = jasmine.createSpyObj( + 'RumJourneyEventService', + ['recordSimpleEvent'] + ) await TestBed.configureTestingModule({ imports: [ @@ -42,6 +55,12 @@ describe('TwoFactorEnableComponent', () => { MatFormFieldModule, ReactiveFormsModule, RouterTestingModule, + OrcidStepViewComponent, + MatIconModule, + MatButtonModule, + MatProgressSpinnerModule, + MatTooltipModule, + ClipboardModule, ], declarations: [TwoFactorEnableComponent], providers: [ @@ -50,6 +69,10 @@ describe('TwoFactorEnableComponent', () => { provide: TwoFactorAuthenticationService, useValue: fakeTwoFactorAuthenticationService, }, + { + provide: RumJourneyEventService, + useValue: fakeObservability, + }, PlatformInfoService, ErrorHandlerService, SnackbarService, @@ -68,6 +91,9 @@ describe('TwoFactorEnableComponent', () => { it('should create', () => { expect(component).toBeTruthy() + expect(fakeObservability.recordSimpleEvent).toHaveBeenCalledWith( + AppEventName.TwoFactorSetupStep1Loaded + ) }) it('should include a qr code image', () => { @@ -91,18 +117,19 @@ describe('TwoFactorEnableComponent', () => { ).not.toBeNull() }) - it('should call register method when input is filled button has been press', async () => { + it('should call register method when input is filled and submit is triggered', async () => { component.twoFactorForm.get('verificationCode').setValue('123456') fixture.detectChanges() await fixture.whenStable() - const twoFactorButton = debugElement.query(By.css('#cy-continue')) - twoFactorButton.nativeElement.click() + component.onSubmit() fixture.detectChanges() expect(fakeTwoFactorAuthenticationService.register).toHaveBeenCalled() - expect(component.showBadVerificationCode).toBeFalse() + expect( + component.twoFactorForm.get('verificationCode')?.hasError('invalidCode') + ).toBeFalse() }) }) 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 bb018971b9..0c90c83b84 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 @@ -11,6 +11,11 @@ import { } from '@angular/material/tooltip' import { TwoFactorAuthenticationService } from '../../../core/two-factor-authentication/two-factor-authentication.service' import { TwoFactor } from '../../../types/two-factor.endpoint' +import { RumJourneyEventService } from '../../../rum/service/customEvent.service' +import { AppEventName } from '../../../rum/app-event-names' + +declare const runtimeEnvironment: any +declare const $localize: any export const clipboardTooltip: MatTooltipDefaultOptions = { showDelay: 500, @@ -39,12 +44,19 @@ export class TwoFactorEnableComponent implements OnInit { environment = runtimeEnvironment twoFactorForm: UntypedFormGroup showTextCode = false - showBadVerificationCode = false loading = false + textCodeCopiedTooltip = $localize`:@@account.setupCodeClipboard:Setup code has been copied to the clipboard` - constructor(private _twoFactorService: TwoFactorAuthenticationService) {} + constructor( + private _twoFactorService: TwoFactorAuthenticationService, + private _observability: RumJourneyEventService + ) {} ngOnInit(): void { + this._observability.recordSimpleEvent( + AppEventName.TwoFactorSetupStep1Loaded + ) + this.twoFactorForm = new UntypedFormGroup({ verificationCode: new UntypedFormControl('', [ Validators.required, @@ -55,29 +67,49 @@ export class TwoFactorEnableComponent implements OnInit { } onSubmit() { - this.showBadVerificationCode = false + const verificationCodeControl = this.twoFactorForm.get('verificationCode') + if (verificationCodeControl?.hasError('invalidCode')) { + const errors = { ...(verificationCodeControl.errors || {}) } + delete errors.invalidCode + verificationCodeControl.setErrors( + Object.keys(errors).length ? errors : null + ) + } if (this.twoFactorForm.invalid) { + this.twoFactorForm.markAllAsTouched() return } this.loading = true const twoFactor: TwoFactor = { - verificationCode: this.twoFactorForm.get('verificationCode').value, + verificationCode: this.twoFactorForm.get('verificationCode')?.value, } - this._twoFactorService.register(twoFactor).subscribe((res) => { - this.loading = false - if (!res.valid) { - this.showBadVerificationCode = true - } else if (res.valid) { - const backupCodes = res.backupCodes.join('\n') - this.twoFactorEnabled.emit({ - backupCodes, - backupCodesClipboard: res.backupCodes.join(' '), + this._twoFactorService.register(twoFactor).subscribe({ + next: (res) => { + this.loading = false + if (!res.valid) { + verificationCodeControl?.setErrors({ + ...(verificationCodeControl.errors || {}), + invalidCode: true, + }) + } else if (res.valid) { + const backupCodes = (res.backupCodes || []).join('\n') + this.twoFactorEnabled.emit({ + backupCodes, + backupCodesClipboard: (res.backupCodes || []).join(' '), + }) + } + }, + error: () => { + this.loading = false + verificationCodeControl?.setErrors({ + ...(verificationCodeControl.errors || {}), + invalidCode: true, }) - } + }, }) } @@ -93,4 +125,8 @@ export class TwoFactorEnableComponent implements OnInit { ) }) } + + get textCodeClipboard() { + return this.twoFactorForm.get('textCode')?.value || '' + } } 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 8890243787..119fabdb49 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,71 +1,85 @@
-
- Save your 2FA recovery codes! -

- Recovery codes can be used to access your account when you can’t receive - 2FA codes (for example, if you lose your device). Each code can only be - used once. -

- - Learn more about 2FA recovery codes - -
-
- -
-
-

+ +

+
+

Your 2FA recovery codes

+

+ Recovery codes can be used to access your account when you can’t + receive 2FA codes (for example, if you lose your device). Each code + can only be used once. +

+
- Download 2FA recovery codes + Learn more about 2FA recovery codes + - | - -
+ +
+ +
+ +
+ + + - -

-

- Download or copy these codes and store them in a safe spot, such as a - password manager. -

-

- This is the only time that you can download or copy these codes and ORCID - does not store a backup. If you lose access to both your device and these - codes, you will need to contact us to restore your account access. -

-
-
- -
+ + +
+ +
+

+ Confirm 2FA recovery codes +

+ + I have downloaded or copied my 2FA recovery codes and stored them + somewhere safe + +
+
diff --git a/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.scss b/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.scss index 3105a6f0b0..336b6667e6 100644 --- a/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.scss +++ b/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.scss @@ -1,21 +1,16 @@ -div { - p { - margin-top: 4px; - margin-bottom: 24px; - } -} - textarea.backup-codes { width: 100%; + max-width: 100%; + box-sizing: border-box; line-height: 21px; - margin: 32px 0; + margin-top: var(--orcid-space-l, 32px); + margin-bottom: var(--orcid-space-base, 16px); padding: 8px; + resize: vertical; } -a { - button { - text-decoration: underline; - } +.backup-codes-wrapper { + position: relative; } .button-container { @@ -23,3 +18,71 @@ a { align-items: flex-start; margin-top: 16px; } + +.copy-block { + display: flex; + flex-direction: column; + gap: var(--orcid-space-base, 16px); +} + +.copy-block h4, +.copy-block p { + margin: 0; +} + +.copy-block__intro { + display: flex; + flex-direction: column; + gap: var(--orcid-space-s, 8px); +} + +.copy-block__intro h4, +.copy-block__intro p { + margin: 0; +} + +.learn-more-link { + display: inline-flex; + align-items: center; + gap: 4px; + width: fit-content; + text-decoration: underline; +} + +.actions-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.link-action { + border: 0; + background: transparent; + padding: 0; + display: inline-flex; + gap: 8px; + align-items: center; + color: var(--orcid-color-brand-secondary-dark, #085c77); + cursor: pointer; +} + +.actions-divider { + width: 1px; + height: 18px; + background: var(--orcid-color-border-subtle, #dddddd); +} + +.confirm-block { + padding-top: 0; +} + +.confirm-block h4 { + margin: var(--orcid-space-l, 32px) 0 12px; +} + +.confirm-block mat-checkbox { + display: block; + font-size: var(--orcid-font-size-body-small, 14px); + line-height: 21px; +} diff --git a/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.spec.ts b/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.spec.ts index 860d4a7fad..7a6cd108d8 100644 --- a/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.spec.ts +++ b/src/app/two-factor-setup/components/two-factor-recovery-codes/two-factor-recovery-codes.component.spec.ts @@ -4,36 +4,91 @@ import { TwoFactorRecoveryCodesComponent } from './two-factor-recovery-codes.com import { WINDOW_PROVIDERS } from '../../../cdk/window' import { MatTooltipModule } from '@angular/material/tooltip' -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' import { ReactiveFormsModule } from '@angular/forms' import { RouterTestingModule } from '@angular/router/testing' import { ClipboardModule } from '@angular/cdk/clipboard' +import { OrcidStepViewComponent } from '@orcid/ui' +import { MatCheckboxModule } from '@angular/material/checkbox' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' +import { Router } from '@angular/router' +import { RumJourneyEventService } from '../../../rum/service/customEvent.service' +import { AppEventName } from '../../../rum/app-event-names' describe('TwoFactorRecoveryCodesComponent', () => { let component: TwoFactorRecoveryCodesComponent let fixture: ComponentFixture + let router: Router + let fakeObservability: jasmine.SpyObj beforeEach(async () => { + fakeObservability = jasmine.createSpyObj( + 'RumJourneyEventService', + ['recordSimpleEvent'] + ) + await TestBed.configureTestingModule({ imports: [ MatTooltipModule, ReactiveFormsModule, RouterTestingModule, ClipboardModule, + OrcidStepViewComponent, + MatCheckboxModule, + MatButtonModule, + MatIconModule, ], declarations: [TwoFactorRecoveryCodesComponent], - providers: [WINDOW_PROVIDERS], - schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + WINDOW_PROVIDERS, + { + provide: RumJourneyEventService, + useValue: fakeObservability, + }, + ], }).compileComponents() }) beforeEach(() => { fixture = TestBed.createComponent(TwoFactorRecoveryCodesComponent) component = fixture.componentInstance + component.backupCodes = 'code1\ncode2' + component.backupCodesClipboard = 'code1 code2' + router = TestBed.inject(Router) + spyOn(router, 'navigate').and.resolveTo(true) fixture.detectChanges() }) it('should create', () => { expect(component).toBeTruthy() + expect(fakeObservability.recordSimpleEvent).toHaveBeenCalledWith( + AppEventName.TwoFactorSetupStep2Loaded + ) + }) + + it('should only allow complete when copy/download and checkbox are done', () => { + expect(component.canCompleteSetup).toBeFalse() + + component.markCodesCopied() + expect(component.canCompleteSetup).toBeFalse() + + component.twoFactorForm.get('confirmCodes').setValue(true) + expect(component.canCompleteSetup).toBeTrue() + }) + + it('should record click and completion events when setup is completed', async () => { + component.markCodesCopied() + component.twoFactorForm.get('confirmCodes').setValue(true) + + component.completeSetup() + await fixture.whenStable() + + expect(fakeObservability.recordSimpleEvent).toHaveBeenCalledWith( + AppEventName.TwoFactorSetupFinalButtonClicked + ) + expect(fakeObservability.recordSimpleEvent).toHaveBeenCalledWith( + AppEventName.TwoFactorSetupFinalCompleted + ) + expect(router.navigate).toHaveBeenCalled() }) }) 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 b5e1b75f5a..677e406c58 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 @@ -1,7 +1,16 @@ -import { Component, Inject, Input, OnInit } from '@angular/core' +import { Component, Input, OnInit, inject } from '@angular/core' import { ApplicationRoutes } from '../../../constants' import { WINDOW } from '../../../cdk/window' -import { UntypedFormControl, UntypedFormGroup } from '@angular/forms' +import { + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms' +import { Router } from '@angular/router' +import { RumJourneyEventService } from '../../../rum/service/customEvent.service' +import { AppEventName } from '../../../rum/app-event-names' + +declare const $localize: any @Component({ selector: 'app-two-factor-recovery-codes', @@ -17,17 +26,24 @@ export class TwoFactorRecoveryCodesComponent implements OnInit { @Input() backupCodesClipboard: string applicationRoutes = ApplicationRoutes twoFactorForm: UntypedFormGroup + hasDownloadedOrCopied = false tooltipClipboard = $localize`:@@account.clipboard:Backup codes have been copied to the clipboard` + private window = inject(WINDOW) - constructor(@Inject(WINDOW) private window: Window) {} + constructor( + private router: Router, + private _observability: RumJourneyEventService + ) {} ngOnInit(): void { + this._observability.recordSimpleEvent( + AppEventName.TwoFactorSetupStep2Loaded + ) + this.twoFactorForm = new UntypedFormGroup({ - backupCodes: new UntypedFormControl( - { value: this.backupCodes, disabled: true }, - [] - ), + backupCodes: new UntypedFormControl(this.backupCodes, []), + confirmCodes: new UntypedFormControl(false, [Validators.requiredTrue]), }) } @@ -39,5 +55,35 @@ export class TwoFactorRecoveryCodesComponent implements OnInit { this.window.document.body.appendChild(link) link.click() this.window.document.body.removeChild(link) + this.hasDownloadedOrCopied = true + } + + markCodesCopied() { + this.hasDownloadedOrCopied = true + } + + get canCompleteSetup() { + return ( + this.hasDownloadedOrCopied && + this.twoFactorForm.get('confirmCodes')?.value + ) + } + + completeSetup() { + if (!this.canCompleteSetup) { + this.twoFactorForm.markAllAsTouched() + return + } + + this._observability.recordSimpleEvent( + AppEventName.TwoFactorSetupFinalButtonClicked + ) + this.router.navigate(['/' + this.applicationRoutes.account]).then((ok) => { + if (ok) { + this._observability.recordSimpleEvent( + AppEventName.TwoFactorSetupFinalCompleted + ) + } + }) } } 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 63039b883b..46ded42781 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 @@ -11,14 +11,6 @@ 'col l5 m6 s4': !platform.columns12 }" > - orcid logo -

- Enable two-factor authentication (2FA) -

{ expect(component).toBeTruthy() }) - it('should create the compiled', () => { - expect(compiled.querySelector('h2').textContent).toContain( - 'Enable two-factor authentication (2FA)' - ) - }) - it('should render TwoFactorEnableComponent', () => { const counter = findComponent(fixture, 'app-two-factor-enable') expect(counter).toBeTruthy() 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 6232da0e79..8313997315 100644 --- a/src/app/two-factor-setup/two-factor-setup.module.ts +++ b/src/app/two-factor-setup/two-factor-setup.module.ts @@ -19,13 +19,14 @@ 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner' +import { OrcidStepViewComponent } from '@orcid/ui' +import { MatCheckboxModule } from '@angular/material/checkbox' @NgModule({ declarations: [ TwoFactorSetupComponent, TwoFactorRecoveryCodesComponent, TwoFactorEnableComponent, - TwoFactorEnableComponent, ], imports: [ CommonModule, @@ -45,6 +46,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' ClipboardModule, MatTooltipModule, MatProgressSpinnerModule, + MatCheckboxModule, + OrcidStepViewComponent, ], }) export class TwoFactorSetupModule {}