Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 5 additions & 3 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { PseudoModule } from 'src/locale/i18n.pseudo.component'
import { TitleService } from './core/title-service/title.service'
import { HttpContentTypeHeaderInterceptor } from './core/http-content-type-header-interceptor/http-content-type-header-interceptor'
import { XsrfFallbackInterceptor } from './core/xsrf/xsrf-fallback.interceptor'
import { FirefoxXsrfPreloadInterceptor } from './core/lang-preload/firefox-xsrf-preload.interceptor'
import { XsrfPreloadInterceptor } from './core/xsrf/xsrf-preload.interceptor'
import { RetryTransientInterceptor } from './core/http/retry-transient.interceptor'
import {
HTTP_INTERCEPTORS,
Expand Down Expand Up @@ -47,10 +47,12 @@ import { FormsModule } from '@angular/forms'
],
providers: [
TitleService,
// Firefox-only workaround to ensure XSRF cookie is established before backend calls
// Ensures the XSRF cookie is established before any backend call, on every
// browser: without it the first mutating request goes out with no
// x-xsrf-token header and gets a 403
{
provide: HTTP_INTERCEPTORS,
useClass: FirefoxXsrfPreloadInterceptor,
useClass: XsrfPreloadInterceptor,
multi: true,
},
{
Expand Down
14 changes: 14 additions & 0 deletions src/app/authorize/pages/authorize/authorize.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { TogglzService } from 'src/app/core/togglz/togglz.service'
import { TogglzFlag } from 'src/app/types/config.endpoint'
import { OauthURLSessionManagerService } from 'src/app/core/oauth-urlsession-manager/oauth-urlsession-manager.service'
import { RumJourneyEventService } from 'src/app/rum/service/customEvent.service'
import { InterstitialObservabilityService } from 'src/app/core/login-interstitials-manager/interstitial-observability.service'

import { AuthorizeComponent } from './authorize.component'

Expand All @@ -38,6 +39,7 @@ describe('AuthorizeComponent', () => {
let togglzSpy: jasmine.SpyObj<TogglzService>
let oauthUrlSessionSpy: jasmine.SpyObj<OauthURLSessionManagerService>
let rumSpy: jasmine.SpyObj<RumJourneyEventService>
let interstitialObservabilitySpy: jasmine.SpyObj<InterstitialObservabilityService>
let windowMock: any

beforeEach(() => {
Expand All @@ -57,6 +59,11 @@ describe('AuthorizeComponent', () => {
rumSpy = jasmine.createSpyObj('RumJourneyEventService', [
'recordSimpleEvent',
])
interstitialObservabilitySpy =
jasmine.createSpyObj<InterstitialObservabilityService>(
'InterstitialObservabilityService',
['shown', 'outcome', 'closed']
)

windowMock = {
outOfRouterNavigation: jasmine.createSpy('outOfRouterNavigation'),
Expand Down Expand Up @@ -95,6 +102,10 @@ describe('AuthorizeComponent', () => {
useValue: oauthUrlSessionSpy,
},
{ provide: RumJourneyEventService, useValue: rumSpy },
{
provide: InterstitialObservabilityService,
useValue: interstitialObservabilitySpy,
},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents()
Expand Down Expand Up @@ -326,6 +337,9 @@ describe('AuthorizeComponent', () => {
finish$.next()

expect(finishSpy).toHaveBeenCalled()
// The dialog path closes on afterClosed(); here `finish` is the only signal
// the interstitial is over, so without this the journey never ends
expect(interstitialObservabilitySpy.closed).toHaveBeenCalled()
})

it('handleRedirect: with interstitial -> shows interstitial instead of redirect', () => {
Expand Down
11 changes: 9 additions & 2 deletions src/app/authorize/pages/authorize/authorize.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ComponentType } from '@angular/cdk/overlay'
import { Component, Inject, ViewChild } from '@angular/core'
import { Component, Inject, inject, ViewChild } from '@angular/core'
import { Observable, forkJoin, of } from 'rxjs'
import {
filter,
Expand All @@ -14,6 +14,7 @@ import { PlatformInfo, PlatformInfoService } from 'src/app/cdk/platform-info'
import { WINDOW } from 'src/app/cdk/window'
import { UserService } from 'src/app/core'
import { LoginMainInterstitialsManagerService } from 'src/app/core/login-interstitials-manager/login-main-interstitials-manager.service'
import { InterstitialObservabilityService } from 'src/app/core/login-interstitials-manager/interstitial-observability.service'
import { RecordService } from 'src/app/core/record/record.service'
import { TogglzService } from 'src/app/core/togglz/togglz.service'
import { LegacyOauthRequestInfoForm as RequestInfoForm } from 'src/app/types/request-info-form.endpoint'
Expand Down Expand Up @@ -50,6 +51,7 @@ export class AuthorizeComponent {
redirectByReportAlreadyAuthorize: boolean
OAUTH2_AUTHORIZATION_ENABLE: boolean
private log: ReturnType<FeatureLoggerService['scoped']>
private interstitialObservability = inject(InterstitialObservabilityService)

constructor(
private userService: UserService,
Expand Down Expand Up @@ -183,8 +185,13 @@ export class AuthorizeComponent {

const componentRef = this.outlet.attachComponentPortal(portal)

// The dialog path closes the journey on afterClosed(); here the host owns
// the component's lifetime, so `finish` is the equivalent end point
componentRef.instance.finish
.pipe(switchMap(() => this.finishRedirect()))
.pipe(
tap(() => this.interstitialObservability.closed()),
switchMap(() => this.finishRedirect())
)
.subscribe()

componentRef.changeDetectorRef.detectChanges()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<mat-card-header class="authorize-header">
<mat-card-title role="heading" aria-level="1">
<mat-icon class="logo-icon"
><img src="./assets/vectors/orcid.logo.icon.svg" alt="orcid logo" />
</mat-icon>
<h1 class="orc-font-heading-small" i18n="@@interstitial.addABackupEmail">
Add a backup email address
</h1>
</mat-card-title>
</mat-card-header>

<mat-card-content class="authorize-content" *ngIf="form">
<app-info-panel>
<div i18n="@@interstitial.weHaveNoticedYouOnlyHaveOneEmail">
We’ve noticed you only have one email address associated with your ORCID
account. Adding a backup email will help you sign in to ORCID if you lose
access to your primary email address.
</div>
</app-info-panel>

<section class="primary-email">
<h2 class="orc-font-body" i18n="@@interstitial.yourPrimaryEmailAddress">
Your primary email address
</h2>
<mat-divider></mat-divider>
<div class="primary-email-row">
<strong class="orc-font-body-small">{{ primaryEmail?.value }}</strong>
<div
class="email-category"
*ngIf="primaryEmailCategory === 'PROFESSIONAL'"
>
<mat-icon class="material-icons-outlined">work</mat-icon>
<span class="orc-font-small-print" i18n="@@interstitial.professional"
>Professional</span
>
</div>
<div class="email-category" *ngIf="primaryEmailCategory === 'PERSONAL'">
<mat-icon class="material-icons-outlined">fingerprint</mat-icon>
<span class="orc-font-small-print" i18n="@@interstitial.personal"
>Personal</span
>
</div>
</div>
<mat-divider></mat-divider>
</section>

<section class="backup-email">
<h2 class="orc-font-body" i18n="@@interstitial.yourBackupEmailAddress">
Your backup email address
</h2>
<p i18n="@@interstitial.addAnotherEmailToYourOrcidAccount">
Add another email to your ORCID account as a backup. A mix of personal and
professional email addresses works best.
</p>

<form [formGroup]="form">
<div class="input-container">
<mat-label
id="backup-email-input-label"
class="orc-font-small-print strong"
[ngClass]="{ error: hasVisibleError }"
i18n="@@interstitial.backupEmail"
>
Backup email
</mat-label>
<!-- no-hint drops the reserved subscript space; the errors below are
siblings, so that space is only a gap -->
<mat-form-field
appearance="outline"
class="no-hint"
[hideRequiredMarker]="true"
>
<input
aria-labelledby="backup-email-input-label"
id="backup-email-input"
formControlName="backupEmail"
matInput
type="email"
autocomplete="email"
placeholder="{{ backupEmailPlaceholder }}"
[attr.aria-invalid]="hasVisibleError"
[attr.aria-describedby]="visibleErrorId"
/>
</mat-form-field>
<mat-error
*ngIf="isErrorVisible('required')"
id="backup-email-required-error"
role="alert"
class="orc-font-small-print"
i18n="@@interstitial.pleaseEnterYourEmail"
>
Please enter your email
</mat-error>
<mat-error
*ngIf="isErrorVisible('invalid')"
id="backup-email-invalid-error"
role="alert"
class="orc-font-small-print"
i18n="@@side-bar.invalidEmailErrorMessage"
>
Please enter a valid email address, for example
joe&#64;institution.edu
</mat-error>
<mat-error
*ngIf="isErrorVisible('in_use')"
id="backup-email-in-use-error"
role="alert"
class="orc-font-small-print"
i18n="@@interstitial.thisEmailIsAlreadyAssociatedWithAnExistingRecord"
>
This email is already associated with an existing ORCID record. Please
use a different email address.
</mat-error>
</div>
</form>
</section>

<mat-divider class="footer-divider"></mat-divider>

<button
class="authorize-button"
mat-raised-button
color="primary"
(click)="addBackupEmail()"
id="authorize-button"
[disabled]="saving"
i18n="@@interstitial.addBackupEmailAddress"
>
Add backup email address
</button>
<div class="row deny-button-wrapper">
<!-- A button, not an anchor: the dialog sets disableClose, so this is the
only way out and it has to be reachable by keyboard -->
<button
id="deny-button"
type="button"
class="link-button underline"
(click)="declineBackupEmail()"
>
<i i18n="@@interstitial.continueWithoutAddingABackupEmailAddress"
>Continue without adding a backup email address</i
>
</button>
</div>
</mat-card-content>

<mat-spinner
*ngIf="!form"
class="spinner"
[diameter]="80"
[strokeWidth]="5"
></mat-spinner>
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
@import '../../../../../assets/scss/orcid.spacing.scss';

// The orc-font-* classes set neither line-height nor font-weight, so the Figma
// metrics are spelled out here.

section {
margin-top: $spacing-large;
}

h2 {
margin: 0 0 $spacing-base 0;
font-weight: bold;
line-height: 24px; // Figma body-M 16/24
}

.primary-email-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-small;

strong {
font-weight: 700;
line-height: 21px; // Figma $body-s 14/21 bold
}
}

.email-category {
display: flex;
align-items: center;
gap: $spacing-xsmall;

span {
line-height: 18px; // Figma $body-xs 12/18
}

mat-icon {
height: 24px;
width: 24px;
font-size: 24px;
}
}

.backup-email p {
margin: 0;
}

.input-container,
mat-form-field {
width: 100%;
}

// Figma stacks the label, the field and the message with an even 8px gap
.input-container {
margin-top: $spacing-base;
display: flex;
flex-direction: column;
gap: $spacing-small;
}

mat-label.orc-font-small-print {
font-weight: bold;
line-height: 18px; // Figma $body-xs 12/18
}

mat-error.orc-font-small-print {
line-height: 18px;
}

button.authorize-button {
width: 100%;
margin-bottom: $spacing-base;
}

// The decline action is a button so it is keyboard reachable, styled as a link
button.link-button {
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
text-decoration: underline;
}

mat-divider {
margin-top: $spacing-base;
margin-bottom: $spacing-base;
}

mat-divider.footer-divider {
margin-top: $spacing-large;
margin-bottom: $spacing-large;
}

mat-icon > img {
height: 20px;
margin-top: 2px; // Fix icon component with SVG image
}

mat-spinner {
align-self: center;
margin: 60px;
}

mat-card-header.authorize-header {
mat-card-title {
margin: 0 0 $spacing-large 0 !important;
}

h1 {
margin-top: 0;
}

.orc-font-heading-small {
font-style: normal;
font-weight: 500;
}

mat-icon.logo-icon {
height: 80px;
width: 64px;
img {
height: 64px;
}
}
}

mat-card-content.authorize-content {
margin: 0px !important;

div.deny-button-wrapper {
justify-content: center;
}
}
Loading
Loading