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 @@
+
+