diff --git a/CHANGELOG b/CHANGELOG index 9758ee36b..2651daf29 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,26 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO. +26.7.0 (2026-04-08) +=================== + +* ORCiD Integration Project - FE Part + +26.6.1 (2026-03-26) +=================== + +* Hotfix to prevent redirect on 403 error + +26.6.0 (2026-03-26) +=================== + +* Misc. improvements and bug fixes + +26.5.1 (2026-03-18) +=================== + +* Hotfix for Angular SSR + 26.5.0 (2026-02-26) =================== diff --git a/Dockerfile b/Dockerfile index f487a8ccb..f169b2f08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ WORKDIR /app RUN npm prune --omit=dev --no-audit --no-fund EXPOSE 4000 ENV PORT=4000 -CMD ["node", "dist/osf/server/server.mjs"] +CMD ["node", "--enable-source-maps", "dist/osf/server/server.mjs"] # Static dist artifact stage FROM node:22-alpine AS dist diff --git a/angular.json b/angular.json index bcda2ac25..47e749c53 100644 --- a/angular.json +++ b/angular.json @@ -98,6 +98,7 @@ "ssr": { "entry": "src/server.ts" }, + "sourceMap": true, "budgets": [ { "type": "initial", diff --git a/package.json b/package.json index 493a12012..7fec6b86b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "osf", - "version": "26.5.0", + "version": "26.7.0", "scripts": { "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", @@ -30,7 +30,7 @@ "test:check-coverage-thresholds": "node .github/scripts/check-coverage-thresholds.js", "test:display": "node .github/counter/counter.test.display.js", "watch": "ng build --watch --configuration development", - "serve:ssr:osf": "node dist/osf/server/server.mjs" + "serve:ssr:osf": "node --enable-source-maps dist/osf/server/server.mjs" }, "private": true, "dependencies": { 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/preprints/services/preprints.service.ts b/src/app/features/preprints/services/preprints.service.ts index 0b037250a..d9edd0e91 100644 --- a/src/app/features/preprints/services/preprints.service.ts +++ b/src/app/features/preprints/services/preprints.service.ts @@ -100,7 +100,7 @@ export class PreprintsService { .pipe( map((response) => PreprintsMapper.fromPreprintWithEmbedsJsonApi(response)), catchError((error) => { - if (error.status === 410) { + if (error.error?.errors?.[0]?.meta?.flagged_content) { this.router.navigate(['/spam-content']); } return throwError(() => error); diff --git a/src/app/features/profile/components/profile-information/profile-information.component.spec.ts b/src/app/features/profile/components/profile-information/profile-information.component.spec.ts index 4d860b61f..947edf462 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.spec.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.spec.ts @@ -7,6 +7,7 @@ import { provideRouter } 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 { 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'; @@ -93,7 +94,7 @@ describe('ProfileInformationComponent', () => { external_identity: { ORCID: { id: '0000-0002-1825-0097', - status: 'verified', + status: ExternalIdentityStatus.VERIFIED, }, }, } as UserModel); @@ -107,7 +108,7 @@ describe('ProfileInformationComponent', () => { external_identity: { ORCID: { id: '0000-0002-1825-0097', - status: 'pending', + status: ExternalIdentityStatus.LINK, }, }, } as UserModel); 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..9b49b9506 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts @@ -0,0 +1,150 @@ +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { Mock } from 'vitest'; + +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { AuthService } from '@core/services/auth.service'; +import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors'; +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 { provideOSFCore } from '@testing/osf.testing.provider'; +import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { DeleteExternalIdentity, GetExternalIdentities } from '../../../account-settings/store'; + +import { AuthenticatedIdentityComponent } from './authenticated-identity.component'; + +describe('AuthenticatedIdentityComponent', () => { + let component: AuthenticatedIdentityComponent; + let fixture: ComponentFixture; + let store: Store; + let customConfirmationServiceMock: CustomConfirmationServiceMockType; + let loaderServiceMock: LoaderServiceMock; + let toastServiceMock: ToastServiceMockType; + let authServiceMock: AuthServiceMockType; + + const mockExternalIdentities = signal([ + { + id: 'ORCID', + externalId: '0001-0002-0003-0004', + status: ExternalIdentityStatus.VERIFIED, + }, + ]); + + beforeEach(() => { + customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); + loaderServiceMock = new LoaderServiceMock(); + toastServiceMock = ToastServiceMock.simple(); + authServiceMock = AuthServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [AuthenticatedIdentityComponent], + providers: [ + provideOSFCore(), + MockProvider(ENVIRONMENT, { webUrl: 'http://localhost:4200', casUrl: 'http://localhost:8080' }), + MockProvider(AuthService, authServiceMock), + MockProvider(LoaderService, loaderServiceMock), + MockProvider(CustomConfirmationService, customConfirmationServiceMock), + MockProvider(ToastService, toastServiceMock), + provideMockStore({ + signals: [ + { + selector: AccountSettingsSelectors.getExternalIdentities, + value: mockExternalIdentities, + }, + ], + }), + ], + }); + + store = TestBed.inject(Store); + 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(); + }); + + it('should dispatch get external identities on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetExternalIdentities()); + }); + + it('should delete ORCID and show success when confirmation is accepted', () => { + (store.dispatch as Mock).mockClear(); + component.disconnectOrcid(); + + const { onConfirm } = customConfirmationServiceMock.confirmDelete.mock.calls[0][0]; + onConfirm(); + + expect(loaderServiceMock.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteExternalIdentity('ORCID')); + expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( + 'settings.accountSettings.connectedIdentities.successDelete' + ); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + }); + + it('should not delete ORCID when confirmation is not accepted', () => { + (store.dispatch as Mock).mockClear(); + component.disconnectOrcid(); + + expect(loaderServiceMock.show).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DeleteExternalIdentity)); + expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); + expect(loaderServiceMock.hide).not.toHaveBeenCalled(); + }); + + it('should redirect to CAS login with ORCID redirect and social tab destination', () => { + component.connectOrcid(); + + expect(authServiceMock.logout).toHaveBeenCalledTimes(1); + + const logoutUrl = authServiceMock.logout.mock.calls[0][0] as string; + const casUrl = new URL(logoutUrl); + + expect(casUrl.origin).toBe('http://localhost:8080'); + expect(casUrl.pathname).toBe('/login'); + expect(casUrl.searchParams.get('redirectOrcid')).toBe('true'); + expect(casUrl.searchParams.get('service')).toBe('http://localhost:4200/login'); + + const nextParam = casUrl.searchParams.get('next'); + expect(nextParam).toBeTruthy(); + + const finalDestination = new URL(decodeURIComponent(nextParam as string)); + expect(finalDestination.origin).toBe('http://localhost:4200'); + expect(finalDestination.pathname).toBe('/settings/profile'); + expect(finalDestination.searchParams.get('tab')).toBe('2'); + }); +}); 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..fb9bdf6fe --- /dev/null +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts @@ -0,0 +1,90 @@ +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(() => (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 @@ + +