From f9da526f0ca3073f3f61c2cbae32b4853791f806 Mon Sep 17 00:00:00 2001 From: futa-ikeda <51409893+futa-ikeda@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:46:18 -0400 Subject: [PATCH 1/2] =?UTF-8?q?[ENG-10063]=20orcid=20integration=C2=A0=20(?= =?UTF-8?q?#939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- src/app/core/services/auth.service.ts | 4 +- .../profile-information.component.ts | 3 +- .../authenticated-identity.component.html | 40 ++++++++ .../authenticated-identity.component.scss | 0 .../authenticated-identity.component.spec.ts | 71 ++++++++++++++ .../authenticated-identity.component.ts | 92 ++++++++++++++++++ .../components/social/social.component.html | 2 + .../social/social.component.spec.ts | 8 +- .../components/social/social.component.ts | 7 +- .../profile-settings.component.spec.ts | 15 ++- .../profile-settings.component.ts | 19 +++- .../enums/external-identity-status.enum.ts | 5 + .../models/user/external-identity.model.ts | 4 +- src/assets/i18n/en.json | 5 + .../images/integrations/orcid-logotype.png | Bin 0 -> 14455 bytes 15 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.html create mode 100644 src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.scss create mode 100644 src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts create mode 100644 src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts create mode 100644 src/app/shared/enums/external-identity-status.enum.ts create mode 100644 src/assets/images/integrations/orcid-logotype.png diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index b51cb58b9..074e4d732 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -73,13 +73,13 @@ export class AuthService { window.location.href = loginUrl; } - logout(): void { + logout(nextUrl?: string): void { this.loaderService.show(); this.actions.clearCurrentUser(); if (isPlatformBrowser(this.platformId)) { this.cookieService.deleteAll(); - window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent('/')}`; + window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent(nextUrl || '/')}`; } } diff --git a/src/app/features/profile/components/profile-information/profile-information.component.ts b/src/app/features/profile/components/profile-information/profile-information.component.ts index da555cac9..68b9edf81 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.ts @@ -10,6 +10,7 @@ import { RouterLink } from '@angular/router'; import { EducationHistoryComponent } from '@osf/shared/components/education-history/education-history.component'; import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component'; import { SOCIAL_LINKS } from '@osf/shared/constants/social-links.const'; +import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { UserModel } from '@osf/shared/models/user/user.model'; @@ -50,7 +51,7 @@ export class ProfileInformationComponent { orcidId = computed(() => { const orcid = this.currentUser()?.external_identity?.ORCID; - return orcid?.status?.toUpperCase() === 'VERIFIED' ? orcid.id : undefined; + return orcid?.status?.toUpperCase() === ExternalIdentityStatus.VERIFIED ? orcid.id : undefined; }); toProfileSettings() { diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.html b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.html new file mode 100644 index 000000000..ecd67c295 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.html @@ -0,0 +1,40 @@ +
+
+

+ {{ 'settings.profileSettings.social.labels.authenticatedIdentity' | translate }} +

+
+
+
+ @if (existingOrcid()) { + + } @else { + orcid +

+

{{ 'settings.profileSettings.social.orcidWarning' | translate }}

+
+ + +
+ } +
+
+
diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.scss b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts new file mode 100644 index 000000000..5f2583462 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts @@ -0,0 +1,71 @@ +import { MockProvider } from 'ng-mocks'; + +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; + +import { AuthenticatedIdentityComponent } from './authenticated-identity.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('AuthenticatedIdentityComponent', () => { + let component: AuthenticatedIdentityComponent; + let fixture: ComponentFixture; + let customConfirmationServiceMock: CustomConfirmationServiceMockType; + + const mockExternalIdentities = signal([ + { + id: 'ORCID', + externalId: '0001-0002-0003-0004', + status: 'VERIFIED', + }, + ]); + + beforeEach(async () => { + customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); + await TestBed.configureTestingModule({ + imports: [AuthenticatedIdentityComponent, OSFTestingModule], + providers: [ + MockProvider(CustomConfirmationService, customConfirmationServiceMock), + provideMockStore({ + signals: [ + { + selector: AccountSettingsSelectors.getExternalIdentities, + value: mockExternalIdentities, + }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AuthenticatedIdentityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show existing user ORCID when present in external identities', () => { + expect(component.existingOrcid()).toEqual('0001-0002-0003-0004'); + expect(component.orcidUrl()).toEqual('https://orcid.org/0001-0002-0003-0004'); + component.disconnectOrcid(); + expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled(); + }); + + it('should show connect button when no existing ORCID is present in external identities', () => { + mockExternalIdentities.set([]); + fixture.detectChanges(); + + expect(component.existingOrcid()).toBeUndefined(); + expect(component.orcidUrl()).toBeNull(); + }); +}); diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts new file mode 100644 index 000000000..77619339e --- /dev/null +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts @@ -0,0 +1,92 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; + +import { finalize } from 'rxjs'; + +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { AuthService } from '@core/services/auth.service'; +import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { + AccountSettingsSelectors, + DeleteExternalIdentity, + GetExternalIdentities, +} from '../../../account-settings/store'; +import { ProfileSettingsTabOption } from '../../enums'; + +@Component({ + selector: 'osf-authenticated-identity', + imports: [NgOptimizedImage, Button, Tooltip, TranslatePipe], + templateUrl: './authenticated-identity.component.html', + styleUrl: './authenticated-identity.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AuthenticatedIdentityComponent implements OnInit { + private readonly authService = inject(AuthService); + private readonly environment = inject(ENVIRONMENT); + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly toastService = inject(ToastService); + private readonly loaderService = inject(LoaderService); + + private readonly ORCID_PROVIDER = 'ORCID'; + + ngOnInit() { + this.actions.getExternalIdentities(); + } + + readonly actions = createDispatchMap({ + deleteExternalIdentity: DeleteExternalIdentity, + getExternalIdentities: GetExternalIdentities, + }); + + readonly externalIdentities = select(AccountSettingsSelectors.getExternalIdentities); + + readonly orcidUrl = computed(() => { + return this.existingOrcid() ? `https://orcid.org/${this.existingOrcid()}` : null; + }); + + readonly existingOrcid = computed( + (): string | undefined => + this.externalIdentities()?.find((i) => i.id === 'ORCID' && i.status === ExternalIdentityStatus.VERIFIED) + ?.externalId + ); + + disconnectOrcid(): void { + this.customConfirmationService.confirmDelete({ + headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', + messageParams: { name: this.ORCID_PROVIDER }, + messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', + onConfirm: () => { + this.loaderService.show(); + this.actions + .deleteExternalIdentity(this.ORCID_PROVIDER) + .pipe(finalize(() => this.loaderService.hide())) + .subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete')); + }, + }); + } + + connectOrcid(): void { + const webUrl = this.environment.webUrl; + const casUrl = this.environment.casUrl; + const finalDestination = new URL(`${webUrl}/settings/profile`); + finalDestination.searchParams.set('tab', ProfileSettingsTabOption.Social.toString()); + const casLoginUrl = new URL(`${casUrl}/login`); + casLoginUrl.search = new URLSearchParams({ + redirectOrcid: 'true', + service: `${webUrl}/login`, + next: encodeURIComponent(finalDestination.toString()), + }).toString(); + this.authService.logout(casLoginUrl.toString()); + } +} diff --git a/src/app/features/settings/profile-settings/components/social/social.component.html b/src/app/features/settings/profile-settings/components/social/social.component.html index e34553fe1..777d4fd88 100644 --- a/src/app/features/settings/profile-settings/components/social/social.component.html +++ b/src/app/features/settings/profile-settings/components/social/social.component.html @@ -1,3 +1,5 @@ + +