Skip to content

Commit f9da526

Browse files
authored
[ENG-10063] orcid integration  (#939)
* feat(profile-settings): Add query-param to specify a tab (#906) * [ENG-10584][ENG-10585] Allow users to disconnect existing orcid in social tab (#912) * feat(settings): Allow users to disconnect orcid in social tab * feat(settings): Add dummy connect button when no orcid is associated with user * chore(settings): move authenticated identity to own component * refactor(settings): Implement CR suggestions; Update tests * refactor(settings): Update authenticated identity test * refactor(settings): Update authenticated identity test to use OSFTestingModule * feat(settings): Allow user to connect ORCID in profile settings page (#918) * [ENG-10684] Update Authenticated Identity section (#924) * feat(settings): update authenticated identity section * style(settings): Update styles * refactor(settings): Update Authenticated Identity section * chore(settings): Update Authenticated identity section language (#930) * fix(settings): Update connectOrcid to properly logging user out (#934)
1 parent 94ba3cf commit f9da526

15 files changed

Lines changed: 265 additions & 10 deletions

src/app/core/services/auth.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@ export class AuthService {
7373
window.location.href = loginUrl;
7474
}
7575

76-
logout(): void {
76+
logout(nextUrl?: string): void {
7777
this.loaderService.show();
7878
this.actions.clearCurrentUser();
7979

8080
if (isPlatformBrowser(this.platformId)) {
8181
this.cookieService.deleteAll();
82-
window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent('/')}`;
82+
window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent(nextUrl || '/')}`;
8383
}
8484
}
8585

src/app/features/profile/components/profile-information/profile-information.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RouterLink } from '@angular/router';
1010
import { EducationHistoryComponent } from '@osf/shared/components/education-history/education-history.component';
1111
import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component';
1212
import { SOCIAL_LINKS } from '@osf/shared/constants/social-links.const';
13+
import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum';
1314
import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens';
1415
import { Institution } from '@osf/shared/models/institutions/institutions.model';
1516
import { UserModel } from '@osf/shared/models/user/user.model';
@@ -50,7 +51,7 @@ export class ProfileInformationComponent {
5051

5152
orcidId = computed(() => {
5253
const orcid = this.currentUser()?.external_identity?.ORCID;
53-
return orcid?.status?.toUpperCase() === 'VERIFIED' ? orcid.id : undefined;
54+
return orcid?.status?.toUpperCase() === ExternalIdentityStatus.VERIFIED ? orcid.id : undefined;
5455
});
5556

5657
toProfileSettings() {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<div class="flex flex-column row-gap-4 border-1 border-round-lg grey-border-color p-3 md:p-4 xl:p-5">
2+
<div class="flex flex-row justify-content-between">
3+
<h2>
4+
{{ 'settings.profileSettings.social.labels.authenticatedIdentity' | translate }}
5+
</h2>
6+
</div>
7+
<div class="flex flex-column row-gap-4 w-full md:flex-row md:align-items-end md:column-gap-3">
8+
<div class="w-full md:w-12">
9+
@if (existingOrcid()) {
10+
<div class="flex flex-row align-items-center gap-2">
11+
<img ngSrc="assets/icons/colored/orcid.svg" width="16" height="16" alt="orcid" />
12+
<a class="font-bold" [href]="orcidUrl()"> {{ orcidUrl() }} </a>
13+
<p-button
14+
icon="fas fa-times"
15+
class="w-6 md:w-auto"
16+
severity="danger"
17+
variant="text"
18+
[pTooltip]="'settings.profileSettings.social.disconnectOrcid' | translate"
19+
[ariaLabel]="'settings.profileSettings.social.disconnectOrcid' | translate"
20+
(onClick)="disconnectOrcid()"
21+
>
22+
</p-button>
23+
</div>
24+
} @else {
25+
<img ngSrc="assets/images/integrations/orcid-logotype.png" width="130" height="40" alt="orcid" />
26+
<p class="mt-2" [innerHTML]="'settings.profileSettings.social.orcidDescription' | translate"></p>
27+
<p class="mt-2 font-bold">{{ 'settings.profileSettings.social.orcidWarning' | translate }}</p>
28+
<div class="mt-2">
29+
<p-button
30+
class="w-6 md:w-auto"
31+
[label]="'settings.profileSettings.social.connectOrcid' | translate"
32+
severity="secondary"
33+
(onClick)="connectOrcid()"
34+
>
35+
</p-button>
36+
</div>
37+
}
38+
</div>
39+
</div>
40+
</div>

src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.scss

Whitespace-only changes.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { MockProvider } from 'ng-mocks';
2+
3+
import { signal } from '@angular/core';
4+
import { ComponentFixture, TestBed } from '@angular/core/testing';
5+
6+
import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors';
7+
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
8+
9+
import { AuthenticatedIdentityComponent } from './authenticated-identity.component';
10+
11+
import { OSFTestingModule } from '@testing/osf.testing.module';
12+
import {
13+
CustomConfirmationServiceMock,
14+
CustomConfirmationServiceMockType,
15+
} from '@testing/providers/custom-confirmation-provider.mock';
16+
import { provideMockStore } from '@testing/providers/store-provider.mock';
17+
18+
describe('AuthenticatedIdentityComponent', () => {
19+
let component: AuthenticatedIdentityComponent;
20+
let fixture: ComponentFixture<AuthenticatedIdentityComponent>;
21+
let customConfirmationServiceMock: CustomConfirmationServiceMockType;
22+
23+
const mockExternalIdentities = signal([
24+
{
25+
id: 'ORCID',
26+
externalId: '0001-0002-0003-0004',
27+
status: 'VERIFIED',
28+
},
29+
]);
30+
31+
beforeEach(async () => {
32+
customConfirmationServiceMock = CustomConfirmationServiceMock.simple();
33+
await TestBed.configureTestingModule({
34+
imports: [AuthenticatedIdentityComponent, OSFTestingModule],
35+
providers: [
36+
MockProvider(CustomConfirmationService, customConfirmationServiceMock),
37+
provideMockStore({
38+
signals: [
39+
{
40+
selector: AccountSettingsSelectors.getExternalIdentities,
41+
value: mockExternalIdentities,
42+
},
43+
],
44+
}),
45+
],
46+
}).compileComponents();
47+
48+
fixture = TestBed.createComponent(AuthenticatedIdentityComponent);
49+
component = fixture.componentInstance;
50+
fixture.detectChanges();
51+
});
52+
53+
it('should create', () => {
54+
expect(component).toBeTruthy();
55+
});
56+
57+
it('should show existing user ORCID when present in external identities', () => {
58+
expect(component.existingOrcid()).toEqual('0001-0002-0003-0004');
59+
expect(component.orcidUrl()).toEqual('https://orcid.org/0001-0002-0003-0004');
60+
component.disconnectOrcid();
61+
expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled();
62+
});
63+
64+
it('should show connect button when no existing ORCID is present in external identities', () => {
65+
mockExternalIdentities.set([]);
66+
fixture.detectChanges();
67+
68+
expect(component.existingOrcid()).toBeUndefined();
69+
expect(component.orcidUrl()).toBeNull();
70+
});
71+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { createDispatchMap, select } from '@ngxs/store';
2+
3+
import { TranslatePipe } from '@ngx-translate/core';
4+
5+
import { Button } from 'primeng/button';
6+
import { Tooltip } from 'primeng/tooltip';
7+
8+
import { finalize } from 'rxjs';
9+
10+
import { NgOptimizedImage } from '@angular/common';
11+
import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core';
12+
13+
import { ENVIRONMENT } from '@core/provider/environment.provider';
14+
import { AuthService } from '@core/services/auth.service';
15+
import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum';
16+
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
17+
import { LoaderService } from '@osf/shared/services/loader.service';
18+
import { ToastService } from '@osf/shared/services/toast.service';
19+
20+
import {
21+
AccountSettingsSelectors,
22+
DeleteExternalIdentity,
23+
GetExternalIdentities,
24+
} from '../../../account-settings/store';
25+
import { ProfileSettingsTabOption } from '../../enums';
26+
27+
@Component({
28+
selector: 'osf-authenticated-identity',
29+
imports: [NgOptimizedImage, Button, Tooltip, TranslatePipe],
30+
templateUrl: './authenticated-identity.component.html',
31+
styleUrl: './authenticated-identity.component.scss',
32+
changeDetection: ChangeDetectionStrategy.OnPush,
33+
})
34+
export class AuthenticatedIdentityComponent implements OnInit {
35+
private readonly authService = inject(AuthService);
36+
private readonly environment = inject(ENVIRONMENT);
37+
private readonly customConfirmationService = inject(CustomConfirmationService);
38+
private readonly toastService = inject(ToastService);
39+
private readonly loaderService = inject(LoaderService);
40+
41+
private readonly ORCID_PROVIDER = 'ORCID';
42+
43+
ngOnInit() {
44+
this.actions.getExternalIdentities();
45+
}
46+
47+
readonly actions = createDispatchMap({
48+
deleteExternalIdentity: DeleteExternalIdentity,
49+
getExternalIdentities: GetExternalIdentities,
50+
});
51+
52+
readonly externalIdentities = select(AccountSettingsSelectors.getExternalIdentities);
53+
54+
readonly orcidUrl = computed(() => {
55+
return this.existingOrcid() ? `https://orcid.org/${this.existingOrcid()}` : null;
56+
});
57+
58+
readonly existingOrcid = computed(
59+
(): string | undefined =>
60+
this.externalIdentities()?.find((i) => i.id === 'ORCID' && i.status === ExternalIdentityStatus.VERIFIED)
61+
?.externalId
62+
);
63+
64+
disconnectOrcid(): void {
65+
this.customConfirmationService.confirmDelete({
66+
headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header',
67+
messageParams: { name: this.ORCID_PROVIDER },
68+
messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message',
69+
onConfirm: () => {
70+
this.loaderService.show();
71+
this.actions
72+
.deleteExternalIdentity(this.ORCID_PROVIDER)
73+
.pipe(finalize(() => this.loaderService.hide()))
74+
.subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete'));
75+
},
76+
});
77+
}
78+
79+
connectOrcid(): void {
80+
const webUrl = this.environment.webUrl;
81+
const casUrl = this.environment.casUrl;
82+
const finalDestination = new URL(`${webUrl}/settings/profile`);
83+
finalDestination.searchParams.set('tab', ProfileSettingsTabOption.Social.toString());
84+
const casLoginUrl = new URL(`${casUrl}/login`);
85+
casLoginUrl.search = new URLSearchParams({
86+
redirectOrcid: 'true',
87+
service: `${webUrl}/login`,
88+
next: encodeURIComponent(finalDestination.toString()),
89+
}).toString();
90+
this.authService.logout(casLoginUrl.toString());
91+
}
92+
}

src/app/features/settings/profile-settings/components/social/social.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<osf-authenticated-identity />
2+
13
<form [formGroup]="socialLinksForm">
24
<div formArrayName="links" class="flex flex-column row-gap-4 social">
35
@for (link of links.controls; track index; let index = $index) {

src/app/features/settings/profile-settings/components/social/social.component.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat
88
import { LoaderService } from '@osf/shared/services/loader.service';
99
import { ToastService } from '@osf/shared/services/toast.service';
1010

11+
import { AuthenticatedIdentityComponent } from '../authenticated-identity/authenticated-identity.component';
1112
import { SocialFormComponent } from '../social-form/social-form.component';
1213

1314
import { SocialComponent } from './social.component';
@@ -24,7 +25,12 @@ describe('SocialComponent', () => {
2425
jest.clearAllMocks();
2526

2627
await TestBed.configureTestingModule({
27-
imports: [SocialComponent, MockComponent(SocialFormComponent), MockPipe(TranslatePipe)],
28+
imports: [
29+
SocialComponent,
30+
MockComponent(SocialFormComponent),
31+
MockComponent(AuthenticatedIdentityComponent),
32+
MockPipe(TranslatePipe),
33+
],
2834
providers: [
2935
provideMockStore({
3036
signals: [{ selector: UserSelectors.getSocialLinks, value: MOCK_USER.social }],

src/app/features/settings/profile-settings/components/social/social.component.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ import { SocialModel } from '@shared/models/user/social.model';
2525
import { SocialLinksForm } from '@shared/models/user/social-links.model';
2626

2727
import { hasSocialLinkChanges, mapSocialLinkToPayload } from '../../helpers';
28+
import { AuthenticatedIdentityComponent } from '../authenticated-identity/authenticated-identity.component';
2829
import { SocialFormComponent } from '../social-form/social-form.component';
2930

3031
@Component({
3132
selector: 'osf-social',
32-
imports: [Button, ReactiveFormsModule, SocialFormComponent, TranslatePipe],
33+
imports: [Button, ReactiveFormsModule, SocialFormComponent, AuthenticatedIdentityComponent, TranslatePipe],
3334
templateUrl: './social.component.html',
3435
styleUrl: './social.component.scss',
3536
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -52,7 +53,9 @@ export class SocialComponent {
5253
readonly socialLinksForm = this.fb.group({ links: this.fb.array<SocialLinksForm>([]) });
5354

5455
constructor() {
55-
effect(() => this.setInitialData());
56+
effect(() => {
57+
this.setInitialData();
58+
});
5659
}
5760

5861
get links(): FormArray<FormGroup> {

src/app/features/settings/profile-settings/profile-settings.component.spec.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { BehaviorSubject } from 'rxjs';
55

66
import { ComponentFixture, TestBed } from '@angular/core/testing';
77
import { By } from '@angular/platform-browser';
8+
import { ActivatedRoute } from '@angular/router';
89

910
import { SelectComponent } from '@osf/shared/components/select/select.component';
1011
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
@@ -34,7 +35,11 @@ describe('ProfileSettingsComponent', () => {
3435
SelectComponent
3536
),
3637
],
37-
providers: [MockProvider(IS_MEDIUM, isMedium), MockProvider(TranslateService)],
38+
providers: [
39+
MockProvider(IS_MEDIUM, isMedium),
40+
MockProvider(TranslateService),
41+
{ provide: ActivatedRoute, useValue: { snapshot: { queryParams: {} } } },
42+
],
3843
}).compileComponents();
3944

4045
fixture = TestBed.createComponent(ProfileSettingsComponent);
@@ -46,6 +51,14 @@ describe('ProfileSettingsComponent', () => {
4651
expect(component).toBeTruthy();
4752
});
4853

54+
it('should update selected tab on init based on query param', () => {
55+
const testTabValue = 2;
56+
component['route'] = { snapshot: { queryParams: { tab: testTabValue } } } as any;
57+
58+
component.ngOnInit();
59+
expect(component['selectedTab']).toBe(testTabValue);
60+
});
61+
4962
it('should update selected tab when onTabChange is called', () => {
5063
const newTabIndex = 2;
5164
component.onTabChange(newTabIndex);

0 commit comments

Comments
 (0)