diff --git a/angular.json b/angular.json index a2e8775f0..22e8eedaf 100644 --- a/angular.json +++ b/angular.json @@ -63,7 +63,12 @@ "node_modules/ngx-markdown-editor/assets/highlight.js/highlight.min.js", "node_modules/ngx-markdown-editor/assets/marked.min.js", "src/assets/js/ace/snippetsMarkdown.js" - ] + ], + "server": "src/main.server.ts", + "outputMode": "server", + "ssr": { + "entry": "src/server.ts" + } }, "configurations": { "production": { diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 8c68eae0a..c21e678de 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -6,7 +6,7 @@ import { ConfirmationService, MessageService } from 'primeng/api'; import { providePrimeNG } from 'primeng/config'; import { DialogService } from 'primeng/dynamicdialog'; -import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideAnimations } from '@angular/platform-browser/animations'; @@ -49,7 +49,7 @@ export const appConfig: ApplicationConfig = { }, }, }), - provideHttpClient(withInterceptors([authInterceptor, viewOnlyInterceptor, errorInterceptor])), + provideHttpClient(withInterceptors([authInterceptor, viewOnlyInterceptor, errorInterceptor]), withFetch()), provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })), provideStore(STATES), provideZoneChangeDetection({ eventCoalescing: true }), diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index a46f725ba..286a1a753 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -3,11 +3,39 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ { path: 'terms-of-use', - renderMode: RenderMode.Server, + renderMode: RenderMode.Prerender, }, { path: 'privacy-policy', - renderMode: RenderMode.Server, + renderMode: RenderMode.Prerender, + }, + { + path: 'dashboard', + renderMode: RenderMode.Client, + }, + { + path: 'my-projects', + renderMode: RenderMode.Client, + }, + { + path: 'my-registrations', + renderMode: RenderMode.Client, + }, + { + path: 'my-preprints', + renderMode: RenderMode.Client, + }, + { + path: 'registries/drafts/**', + renderMode: RenderMode.Client, + }, + { + path: 'registries/revisions/**', + renderMode: RenderMode.Client, + }, + { + path: 'settings/**', + renderMode: RenderMode.Client, }, { path: 'forbidden', @@ -21,42 +49,106 @@ export const serverRoutes: ServerRoute[] = [ path: 'not-found', renderMode: RenderMode.Server, }, + { + path: 'search', + renderMode: RenderMode.Server, + }, + { + path: 'preprints/:providerId/:id/pending-moderation', + renderMode: RenderMode.Server, + }, + { + path: 'preprints/discover', + renderMode: RenderMode.Server, + }, + { + path: 'preprints/:providerId', + renderMode: RenderMode.Server, + }, + { + path: 'preprints/:providerId/discover', + renderMode: RenderMode.Server, + }, { path: 'preprints/:providerId/:id', renderMode: RenderMode.Server, }, { - path: 'dashboard', - renderMode: RenderMode.Client, + path: 'registries/discover', + renderMode: RenderMode.Server, }, { - path: 'my-projects', - renderMode: RenderMode.Client, + path: 'registries/:providerId', + renderMode: RenderMode.Server, }, { - path: 'my-registrations', - renderMode: RenderMode.Client, + path: 'institutions', + renderMode: RenderMode.Server, }, { - path: 'my-preprints', - renderMode: RenderMode.Client, + path: 'institutions/:institutionId', + renderMode: RenderMode.Server, }, { - path: 'profile', - renderMode: RenderMode.Client, + path: 'collections/:providerId/discover', + renderMode: RenderMode.Server, }, { - path: 'settings/**', - renderMode: RenderMode.Client, + path: 'meetings/**', + renderMode: RenderMode.Server, }, { - path: ':id/overview', + path: 'user/:id', + renderMode: RenderMode.Server, + }, + { + path: ':id/files/:provider/:fileId', + renderMode: RenderMode.Server, + }, + { + path: 'project/:id/files/:provider/:fileId', + renderMode: RenderMode.Server, + }, + { + path: 'project/:id/node/:nodeId/files/:provider/:fileId', renderMode: RenderMode.Server, }, { path: ':id', renderMode: RenderMode.Server, }, + { + path: ':id/overview', + renderMode: RenderMode.Server, + }, + { + path: ':id/files/**', + renderMode: RenderMode.Server, + }, + { + path: ':id/registrations', + renderMode: RenderMode.Server, + }, + { + path: ':id/analytics', + renderMode: RenderMode.Server, + }, + { + path: ':id/links', + renderMode: RenderMode.Server, + }, + { + path: ':id/resources', + renderMode: RenderMode.Server, + }, + { + path: ':id/components', + renderMode: RenderMode.Server, + }, + { + path: ':id/recent-activity', + renderMode: RenderMode.Server, + }, { path: '**', renderMode: RenderMode.Client, diff --git a/src/app/core/guards/is-file-provider.guard.spec.ts b/src/app/core/guards/is-file-provider.guard.spec.ts new file mode 100644 index 000000000..49e107f84 --- /dev/null +++ b/src/app/core/guards/is-file-provider.guard.spec.ts @@ -0,0 +1,49 @@ +import { ParamMap, UrlSegment } from '@angular/router'; + +import { FileProvider } from '@osf/features/files/constants'; + +import { isFileProvider } from './is-file-provider.guard'; + +describe('isFileProvider', () => { + const createMockParamMap = (): ParamMap => ({ + get: () => null, + getAll: () => [], + has: () => false, + keys: [], + }); + + const createMockSegment = (path: string): UrlSegment => ({ + path, + parameters: {}, + parameterMap: createMockParamMap(), + }); + + const createMockSegments = (path: string) => [createMockSegment(path)]; + + it('should return true when id matches a FileProvider value', () => { + Object.values(FileProvider).forEach((provider) => { + const result = isFileProvider({} as any, createMockSegments(provider)); + expect(result).toBe(true); + }); + }); + + it('should return false when id does not match any FileProvider value', () => { + const result = isFileProvider({} as any, createMockSegments('invalid-provider')); + expect(result).toBe(false); + }); + + it('should return false when segments array is empty', () => { + const result = isFileProvider({} as any, []); + expect(result).toBe(false); + }); + + it('should return false when first segment has no path', () => { + const result = isFileProvider({} as any, [createMockSegment('')]); + expect(result).toBe(false); + }); + + it('should return false when first segment is undefined', () => { + const result = isFileProvider({} as any, [undefined as any]); + expect(result).toBe(false); + }); +}); diff --git a/src/app/core/guards/is-file.guard.spec.ts b/src/app/core/guards/is-file.guard.spec.ts index 512fbd45a..c3d449077 100644 --- a/src/app/core/guards/is-file.guard.spec.ts +++ b/src/app/core/guards/is-file.guard.spec.ts @@ -1,3 +1,5 @@ +import { MockProvider } from 'ng-mocks'; + import { of } from 'rxjs'; import { PLATFORM_ID, runInInjectionContext } from '@angular/core'; @@ -45,10 +47,7 @@ describe('isFileGuard', () => { selectors: [], actions: [], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), { provide: PLATFORM_ID, useValue: 'browser', @@ -86,10 +85,7 @@ describe('isFileGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), { provide: PLATFORM_ID, useValue: 'browser', @@ -135,10 +131,7 @@ describe('isFileGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), { provide: PLATFORM_ID, useValue: 'browser', @@ -187,10 +180,7 @@ describe('isFileGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), { provide: PLATFORM_ID, useValue: 'browser', @@ -240,10 +230,7 @@ describe('isFileGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), { provide: PLATFORM_ID, useValue: 'browser', @@ -292,10 +279,7 @@ describe('isFileGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), { provide: PLATFORM_ID, useValue: 'browser', @@ -344,10 +328,7 @@ describe('isFileGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), { provide: PLATFORM_ID, useValue: 'browser', @@ -397,10 +378,7 @@ describe('isFileGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test?view_only=abc123').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test?view_only=abc123').build()), { provide: PLATFORM_ID, useValue: 'server', @@ -459,10 +437,7 @@ describe('isFileGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().withUrl('/test').build(), - }, + MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), { provide: PLATFORM_ID, useValue: 'browser', diff --git a/src/app/core/guards/is-project.guard.spec.ts b/src/app/core/guards/is-project.guard.spec.ts index 41df9a57c..59091d663 100644 --- a/src/app/core/guards/is-project.guard.spec.ts +++ b/src/app/core/guards/is-project.guard.spec.ts @@ -1,3 +1,5 @@ +import { MockProvider } from 'ng-mocks'; + import { of } from 'rxjs'; import { runInInjectionContext } from '@angular/core'; @@ -33,10 +35,7 @@ describe('isProjectGuard', () => { selectors: [], actions: [], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -70,10 +69,7 @@ describe('isProjectGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -115,10 +111,7 @@ describe('isProjectGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -164,10 +157,7 @@ describe('isProjectGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -214,10 +204,7 @@ describe('isProjectGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -263,10 +250,7 @@ describe('isProjectGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -316,10 +300,7 @@ describe('isProjectGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -369,10 +350,7 @@ describe('isProjectGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -417,10 +395,7 @@ describe('isProjectGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); diff --git a/src/app/core/guards/is-registry.guard.spec.ts b/src/app/core/guards/is-registry.guard.spec.ts index 60346f3ed..2381aae94 100644 --- a/src/app/core/guards/is-registry.guard.spec.ts +++ b/src/app/core/guards/is-registry.guard.spec.ts @@ -1,3 +1,5 @@ +import { MockProvider } from 'ng-mocks'; + import { of } from 'rxjs'; import { runInInjectionContext } from '@angular/core'; @@ -33,10 +35,7 @@ describe('isRegistryGuard', () => { selectors: [], actions: [], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -70,10 +69,7 @@ describe('isRegistryGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -115,10 +111,7 @@ describe('isRegistryGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -164,10 +157,7 @@ describe('isRegistryGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -214,10 +204,7 @@ describe('isRegistryGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -263,10 +250,7 @@ describe('isRegistryGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -316,10 +300,7 @@ describe('isRegistryGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -369,10 +350,7 @@ describe('isRegistryGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); @@ -417,10 +395,7 @@ describe('isRegistryGuard', () => { }, ], }), - { - provide: Router, - useValue: RouterMockBuilder.create().build(), - }, + MockProvider(Router, RouterMockBuilder.create().build()), ], }); diff --git a/src/app/core/guards/registration-moderation.guard.spec.ts b/src/app/core/guards/registration-moderation.guard.spec.ts new file mode 100644 index 000000000..1d3ae7b77 --- /dev/null +++ b/src/app/core/guards/registration-moderation.guard.spec.ts @@ -0,0 +1,149 @@ +import { of } from 'rxjs'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { GetRegistryProvider, RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; + +import { registrationModerationGuard } from './registration-moderation.guard'; + +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('registrationModerationGuard', () => { + let router: Router; + + const createMockProvider = (overrides?: Partial) => ({ + id: 'provider-123', + name: 'Test Provider', + descriptionHtml: '

Test

', + permissions: [], + brand: null, + iri: 'http://example.com/provider', + reviewsWorkflow: 'enabled', + ...overrides, + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [], + actions: [], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + ], + }); + + router = TestBed.inject(Router); + jest.clearAllMocks(); + }); + + it('should return true when provider already exists with reviewsWorkflow', () => { + const provider = createMockProvider({ reviewsWorkflow: 'enabled' }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: RegistrationProviderSelectors.getBrandedProvider, + value: provider, + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + ], + }); + + router = TestBed.inject(Router); + + const result = runInInjectionContext(TestBed, () => + registrationModerationGuard({ params: { providerId: 'provider-123' } } as any, {} as any) + ); + + expect(result).toBe(true); + }); + + it('should navigate to not-found and return false when provider exists without reviewsWorkflow', (done) => { + const provider = createMockProvider({ reviewsWorkflow: '' }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: RegistrationProviderSelectors.getBrandedProvider, + value: provider, + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + ], + }); + + router = TestBed.inject(Router); + + const result = runInInjectionContext(TestBed, () => + registrationModerationGuard({ params: { providerId: 'provider-123' } } as any, {} as any) + ); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/not-found']); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + + it('should dispatch GetRegistryProvider and return observable when provider does not exist initially', (done) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: RegistrationProviderSelectors.getBrandedProvider, + value: null, + }, + ], + actions: [ + { + action: new GetRegistryProvider('provider-123'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + ], + }); + + router = TestBed.inject(Router); + + const result = runInInjectionContext(TestBed, () => + registrationModerationGuard({ params: { providerId: 'provider-123' } } as any, {} as any) + ); + + expect(typeof result === 'object' && 'subscribe' in result).toBe(true); + done(); + }); +}); diff --git a/src/app/core/guards/view-only.guard.spec.ts b/src/app/core/guards/view-only.guard.spec.ts new file mode 100644 index 000000000..97d94c0f3 --- /dev/null +++ b/src/app/core/guards/view-only.guard.spec.ts @@ -0,0 +1,116 @@ +import { MockProvider } from 'ng-mocks'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; + +import { viewOnlyGuard } from './view-only.guard'; + +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; + +describe('viewOnlyGuard', () => { + let router: Router; + let viewOnlyHelper: ViewOnlyLinkHelperService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + MockProvider(ViewOnlyLinkHelperService, { + hasViewOnlyParam: jest.fn(), + getViewOnlyParam: jest.fn(), + }), + ], + }); + + router = TestBed.inject(Router); + viewOnlyHelper = TestBed.inject(ViewOnlyLinkHelperService); + jest.clearAllMocks(); + }); + + it('should return true when no view-only param exists', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(false); + + const result = runInInjectionContext(TestBed, () => + viewOnlyGuard({ routeConfig: { path: 'test' } } as any, {} as any) + ); + + expect(result).toBe(true); + expect(viewOnlyHelper.hasViewOnlyParam).toHaveBeenCalledWith(router); + }); + + it('should return true when view-only param exists but route is not blocked', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(true); + + const result = runInInjectionContext(TestBed, () => + viewOnlyGuard({ routeConfig: { path: 'allowed-route' } } as any, {} as any) + ); + + expect(result).toBe(true); + expect(viewOnlyHelper.hasViewOnlyParam).toHaveBeenCalledWith(router); + }); + + it('should navigate to overview when view-only param exists and route is blocked with valid navigation params', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(true); + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('abc123'); + Object.defineProperty(router, 'url', { value: '/resource-123/some-path', writable: true }); + + const result = runInInjectionContext(TestBed, () => + viewOnlyGuard({ routeConfig: { path: 'metadata' } } as any, {} as any) + ); + + expect(result).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['resource-123', 'overview'], { + queryParams: { view_only: 'abc123' }, + }); + }); + + it('should navigate to root when view-only param exists and route is blocked but no valid navigation params', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(true); + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue(null); + Object.defineProperty(router, 'url', { value: '/invalid-url', writable: true }); + + const result = runInInjectionContext(TestBed, () => + viewOnlyGuard({ routeConfig: { path: 'metadata' } } as any, {} as any) + ); + + expect(result).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/']); + }); + + it('should navigate to overview when route path starts with blocked route prefix', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(true); + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('xyz789'); + Object.defineProperty(router, 'url', { value: '/resource-456/metadata/subpath', writable: true }); + + const result = runInInjectionContext(TestBed, () => + viewOnlyGuard({ routeConfig: { path: 'metadata/subpath' } } as any, {} as any) + ); + + expect(result).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['resource-456', 'overview'], { + queryParams: { view_only: 'xyz789' }, + }); + }); + + it('should handle empty route path gracefully', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(true); + + const result = runInInjectionContext(TestBed, () => viewOnlyGuard({ routeConfig: { path: '' } } as any, {} as any)); + + expect(result).toBe(true); + }); + + it('should handle undefined route config gracefully', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(true); + + const result = runInInjectionContext(TestBed, () => viewOnlyGuard({ routeConfig: undefined } as any, {} as any)); + + expect(result).toBe(true); + }); +}); diff --git a/src/app/core/interceptors/auth.interceptor.spec.ts b/src/app/core/interceptors/auth.interceptor.spec.ts new file mode 100644 index 000000000..62e9a5f9c --- /dev/null +++ b/src/app/core/interceptors/auth.interceptor.spec.ts @@ -0,0 +1,138 @@ +import { CookieService } from 'ngx-cookie-service'; +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { HttpRequest } from '@angular/common/http'; +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { authInterceptor } from './auth.interceptor'; + +describe('authInterceptor', () => { + let cookieService: CookieService; + let mockHandler: jest.Mock; + + beforeEach(() => { + mockHandler = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + MockProvider(CookieService, { + get: jest.fn(), + }), + { + provide: 'PLATFORM_ID', + useValue: 'browser', + }, + { + provide: 'REQUEST', + useValue: null, + }, + ], + }); + + cookieService = TestBed.inject(CookieService); + jest.clearAllMocks(); + }); + + const createRequest = (url: string, options?: Partial>): HttpRequest => { + return new HttpRequest('GET', url, options?.body, { + responseType: options?.responseType || 'json', + ...options, + }); + }; + + const createHandler = () => { + const handler = mockHandler.mockReturnValue(of({})); + return handler; + }; + + it('should skip CrossRef funders API requests', () => { + const request = createRequest('/api.crossref.org/funders/10.13039/100000001'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest).toBe(request); + }); + + it('should set Accept header to */* for text response type', () => { + const request = createRequest('/api/v2/projects/', { responseType: 'text' }); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.get('Accept')).toBe('*/*'); + }); + + it('should set Accept header to API version for json response type', () => { + const request = createRequest('/api/v2/projects/', { responseType: 'json' }); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.get('Accept')).toBe('application/vnd.api+json;version=2.20'); + }); + + it('should set Content-Type header when not present', () => { + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.get('Content-Type')).toBe('application/vnd.api+json'); + }); + + it('should not override existing Content-Type header', () => { + const request = createRequest('/api/v2/projects/'); + const requestWithHeaders = request.clone({ + setHeaders: { 'Content-Type': 'application/json' }, + }); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(requestWithHeaders, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.get('Content-Type')).toBe('application/json'); + }); + + it('should add CSRF token and withCredentials in browser platform', () => { + jest.spyOn(cookieService, 'get').mockReturnValue('csrf-token-123'); + + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + expect(cookieService.get).toHaveBeenCalledWith('api-csrf'); + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.get('X-CSRFToken')).toBe('csrf-token-123'); + expect(modifiedRequest.withCredentials).toBe(true); + }); + + it('should not add CSRF token when not available in browser platform', () => { + jest.spyOn(cookieService, 'get').mockReturnValue(''); + + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + expect(cookieService.get).toHaveBeenCalledWith('api-csrf'); + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.has('X-CSRFToken')).toBe(false); + expect(modifiedRequest.withCredentials).toBe(true); + }); +}); diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 0ed51e98f..5d192b8e8 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -2,55 +2,33 @@ import { CookieService } from 'ngx-cookie-service'; import { Observable } from 'rxjs'; -import { isPlatformBrowser } from '@angular/common'; import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; -import { inject, PLATFORM_ID, REQUEST } from '@angular/core'; +import { inject } from '@angular/core'; export const authInterceptor: HttpInterceptorFn = ( req: HttpRequest, next: HttpHandlerFn ): Observable> => { - const cookieService = inject(CookieService); - const platformId = inject(PLATFORM_ID); - const serverRequest = inject(REQUEST, { optional: true }); - if (req.url.includes('/api.crossref.org/funders')) { return next(req); } - const headers: Record = {}; - headers['Accept'] = req.responseType === 'text' ? '*/*' : 'application/vnd.api+json;version=2.20'; + const cookieService = inject(CookieService); + const csrfToken = cookieService.get('api-csrf'); + + const headers: Record = { + Accept: req.responseType === 'text' ? '*/*' : 'application/vnd.api+json;version=2.20', + }; if (!req.headers.has('Content-Type')) { headers['Content-Type'] = 'application/vnd.api+json'; } - if (isPlatformBrowser(platformId)) { - const csrfToken = cookieService.get('api-csrf'); - if (csrfToken) { - headers['X-CSRFToken'] = csrfToken; - } - - const authReq = req.clone({ setHeaders: headers, withCredentials: true }); - - return next(authReq); - } - - if (serverRequest) { - const cookieHeader = serverRequest.headers.get('cookie') || ''; - if (cookieHeader) { - if (isPlatformBrowser(platformId)) { - headers['Cookie'] = cookieHeader; - } - - const csrfMatch = cookieHeader.match(/api-csrf=([^;]+)/); - if (csrfMatch) { - headers['X-CSRFToken'] = csrfMatch[1]; - } - } + if (csrfToken) { + headers['X-CSRFToken'] = csrfToken; } - const authReq = req.clone({ setHeaders: headers }); + const authReq = req.clone({ setHeaders: headers, withCredentials: true }); return next(authReq); }; diff --git a/src/app/core/interceptors/error.interceptor.spec.ts b/src/app/core/interceptors/error.interceptor.spec.ts new file mode 100644 index 000000000..52185f9c6 --- /dev/null +++ b/src/app/core/interceptors/error.interceptor.spec.ts @@ -0,0 +1,274 @@ +import { MockProvider } from 'ng-mocks'; + +import { throwError } from 'rxjs'; + +import { HttpContext, HttpErrorResponse, HttpRequest } from '@angular/common/http'; +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; +import { AuthService } from '@core/services/auth.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; + +import { errorInterceptor } from './error.interceptor'; + +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; + +describe('errorInterceptor', () => { + let toastService: ToastService; + let loaderService: LoaderService; + let router: Router; + let authService: AuthService; + let viewOnlyHelper: ViewOnlyLinkHelperService; + let sentryMock: jest.Mock; + + beforeEach(() => { + sentryMock = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + MockProvider(ToastService, { + showError: jest.fn(), + }), + MockProvider(LoaderService, { + hide: jest.fn(), + }), + MockProvider(AuthService, { + logout: jest.fn(), + }), + MockProvider(ViewOnlyLinkHelperService, { + hasViewOnlyParam: jest.fn(), + }), + { + provide: 'PLATFORM_ID', + useValue: 'browser', + }, + { + provide: SENTRY_TOKEN, + useValue: { captureException: sentryMock }, + }, + ], + }); + + toastService = TestBed.inject(ToastService); + loaderService = TestBed.inject(LoaderService); + router = TestBed.inject(Router); + authService = TestBed.inject(AuthService); + viewOnlyHelper = TestBed.inject(ViewOnlyLinkHelperService); + jest.clearAllMocks(); + }); + + const createRequest = (url = '/api/v2/test'): HttpRequest => { + return new HttpRequest('GET', url); + }; + + const createErrorHandler = (error: HttpErrorResponse) => { + const handler = jest.fn(); + handler.mockReturnValue(throwError(() => error)); + return handler; + }; + + it('should bypass error handling when BYPASS_ERROR_INTERCEPTOR is true', () => { + const error = new HttpErrorResponse({ error: 'test error', status: 500 }); + const request = createRequest().clone({ + context: new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true), + }); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(sentryMock).toHaveBeenCalledWith(error); + expect(toastService.showError).not.toHaveBeenCalled(); + expect(loaderService.hide).not.toHaveBeenCalled(); + }, + }); + }); + }); + + it('should handle browser ErrorEvent', () => { + const errorEvent = new ErrorEvent('test error'); + const error = new HttpErrorResponse({ error: errorEvent, status: 0 }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(toastService.showError).toHaveBeenCalledWith('test error'); + expect(loaderService.hide).toHaveBeenCalled(); + }, + }); + }); + }); + + it('should extract error message from API error response', () => { + const error = new HttpErrorResponse({ + error: { errors: [{ detail: 'Custom API error' }] }, + status: 400, + }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(toastService.showError).toHaveBeenCalledWith('Custom API error'); + expect(loaderService.hide).toHaveBeenCalled(); + }, + }); + }); + }); + + it('should use ERROR_MESSAGES for status codes', () => { + const error = new HttpErrorResponse({ error: {}, status: 404 }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(toastService.showError).toHaveBeenCalledWith('The requested resource was not found.'); + expect(loaderService.hide).toHaveBeenCalled(); + }, + }); + }); + }); + + it('should handle 5xx server errors with custom message', () => { + const error = new HttpErrorResponse({ + error: { message: 'Database connection failed' }, + status: 500, + }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(toastService.showError).toHaveBeenCalledWith('Database connection failed'); + expect(loaderService.hide).toHaveBeenCalled(); + }, + }); + }); + }); + + it('should re-throw 409 errors without special handling', () => { + const error = new HttpErrorResponse({ error: {}, status: 409 }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(toastService.showError).not.toHaveBeenCalled(); + expect(loaderService.hide).not.toHaveBeenCalled(); + }, + }); + }); + }); + + it('should handle 401 errors with logout for non-view-only requests', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(false); + + const error = new HttpErrorResponse({ error: {}, status: 401 }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(authService.logout).toHaveBeenCalled(); + expect(toastService.showError).not.toHaveBeenCalled(); + expect(loaderService.hide).not.toHaveBeenCalled(); + }, + }); + }); + }); + + it('should not logout for 401 errors in view-only mode', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(true); + + const error = new HttpErrorResponse({ error: {}, status: 401 }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(authService.logout).not.toHaveBeenCalled(); + expect(toastService.showError).not.toHaveBeenCalled(); + expect(loaderService.hide).not.toHaveBeenCalled(); + }, + }); + }); + }); + + it('should handle 403 errors for request access URLs', () => { + const error = new HttpErrorResponse({ + error: {}, + status: 403, + url: '/api/v2/nodes/project123/requests', + }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(loaderService.hide).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + expect(toastService.showError).not.toHaveBeenCalled(); + }, + }); + }); + }); + + it('should navigate to request-access page for 403 errors on node URLs', () => { + const error = new HttpErrorResponse({ + error: {}, + status: 403, + url: '/api/v2/nodes/project123', + }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(router.navigate).toHaveBeenCalledWith(['/request-access/project123']); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showError).toHaveBeenCalled(); + }, + }); + }); + }); + + it('should navigate to forbidden page for other 403 errors', () => { + const error = new HttpErrorResponse({ + error: {}, + status: 403, + url: '/api/v2/other/endpoint', + }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(router.navigate).toHaveBeenCalledWith(['/forbidden']); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showError).toHaveBeenCalled(); + }, + }); + }); + }); +}); diff --git a/src/app/core/interceptors/view-only.interceptor.spec.ts b/src/app/core/interceptors/view-only.interceptor.spec.ts new file mode 100644 index 000000000..83fbef868 --- /dev/null +++ b/src/app/core/interceptors/view-only.interceptor.spec.ts @@ -0,0 +1,138 @@ +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { HttpRequest } from '@angular/common/http'; +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; + +import { viewOnlyInterceptor } from './view-only.interceptor'; + +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; + +describe('viewOnlyInterceptor', () => { + let viewOnlyHelper: ViewOnlyLinkHelperService; + let mockHandler: jest.Mock; + + beforeEach(() => { + mockHandler = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + MockProvider(ViewOnlyLinkHelperService, { + getViewOnlyParam: jest.fn(), + }), + ], + }); + + viewOnlyHelper = TestBed.inject(ViewOnlyLinkHelperService); + jest.clearAllMocks(); + }); + + const createRequest = (url: string): HttpRequest => { + return new HttpRequest('GET', url); + }; + + const createHandler = () => { + const handler = mockHandler.mockReturnValue(of({})); + return handler; + }; + + it('should add view_only parameter to non-funders API requests when view-only param exists', () => { + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('abc123'); + + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => viewOnlyInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.url).toBe('/api/v2/projects/?view_only=abc123'); + }); + + it('should add view_only parameter with & separator when URL already has query params', () => { + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('xyz789'); + + const request = createRequest('/api/v2/projects/?page=1'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => viewOnlyInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.url).toBe('/api/v2/projects/?page=1&view_only=xyz789'); + }); + + it('should encode view_only parameter value', () => { + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('special chars & symbols'); + + const request = createRequest('/api/v2/files/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => viewOnlyInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.url).toBe('/api/v2/files/?view_only=special%20chars%20%26%20symbols'); + }); + + it('should not modify request when view_only parameter already exists in URL', () => { + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('existing'); + + const request = createRequest('/api/v2/nodes/?view_only=existing'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => viewOnlyInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.url).toBe('/api/v2/nodes/?view_only=existing'); + }); + + it('should not modify request when no view-only param exists', () => { + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue(null); + + const request = createRequest('/api/v2/users/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => viewOnlyInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.url).toBe('/api/v2/users/'); + }); + + it('should not modify funders API requests even when view-only param exists', () => { + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('funder123'); + + const request = createRequest('/api.crossref.org/funders/10.13039/100000001'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => viewOnlyInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.url).toBe('/api.crossref.org/funders/10.13039/100000001'); + }); + + it('should handle requests to other external APIs normally', () => { + jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('external123'); + + const request = createRequest('https://api.github.com/repos/user/repo'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => viewOnlyInterceptor(request, handler)); + + expect(handler).toHaveBeenCalledTimes(1); + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.url).toBe('https://api.github.com/repos/user/repo?view_only=external123'); + }); +}); diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 31d130d34..a24a9d66d 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { map, of } from 'rxjs'; -import { DatePipe } from '@angular/common'; +import { DatePipe, isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -13,6 +13,7 @@ import { effect, inject, OnInit, + PLATFORM_ID, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -68,6 +69,8 @@ export class AnalyticsComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly translateService = inject(TranslateService); private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); readonly resourceId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly resourceType = toSignal(this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined)); @@ -111,7 +114,9 @@ export class AnalyticsComponent implements OnInit { setupCleanup(): void { this.destroyRef.onDestroy(() => { - this.actions.clearAnalytics(); + if (this.isBrowser) { + this.actions.clearAnalytics(); + } }); } diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts index 7da6c59ee..bd5bb5233 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts @@ -8,7 +8,7 @@ import { PaginatorState } from 'primeng/paginator'; import { map, of } from 'rxjs'; -import { DatePipe } from '@angular/common'; +import { DatePipe, isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -16,6 +16,7 @@ import { DestroyRef, effect, inject, + PLATFORM_ID, Signal, signal, } from '@angular/core'; @@ -68,6 +69,8 @@ export class ViewDuplicatesComponent { private destroyRef = inject(DestroyRef); private project = select(ProjectOverviewSelectors.getProject); private registration = select(RegistrySelectors.getRegistry); + private platformId = inject(PLATFORM_ID); + private isBrowser = isPlatformBrowser(this.platformId); duplicates = select(DuplicatesSelectors.getDuplicates); isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); @@ -197,9 +200,11 @@ export class ViewDuplicatesComponent { setupCleanup(): void { this.destroyRef.onDestroy(() => { - this.actions.clearDuplicates(); - this.actions.clearProject(); - this.actions.clearRegistration(); + if (this.isBrowser) { + this.actions.clearDuplicates(); + this.actions.clearProject(); + this.actions.clearRegistration(); + } }); } diff --git a/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts index eaa999e98..098a1f9c8 100644 --- a/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts @@ -7,7 +7,7 @@ import { PaginatorState } from 'primeng/paginator'; import { map, of } from 'rxjs'; -import { DatePipe } from '@angular/common'; +import { DatePipe, isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -15,6 +15,7 @@ import { DestroyRef, effect, inject, + PLATFORM_ID, Signal, signal, } from '@angular/core'; @@ -56,6 +57,8 @@ export class ViewLinkedProjectsComponent { private destroyRef = inject(DestroyRef); private project = select(ProjectOverviewSelectors.getProject); private registration = select(RegistrySelectors.getRegistry); + private platformId = inject(PLATFORM_ID); + private isBrowser = isPlatformBrowser(this.platformId); linkedProjects = select(LinkedProjectsSelectors.getLinkedProjects); isLoading = select(LinkedProjectsSelectors.getLinkedProjectsLoading); @@ -123,9 +126,11 @@ export class ViewLinkedProjectsComponent { setupCleanup(): void { this.destroyRef.onDestroy(() => { - this.actions.clearLinkedProjects(); - this.actions.clearProject(); - this.actions.clearRegistration(); + if (this.isBrowser) { + this.actions.clearLinkedProjects(); + this.actions.clearProject(); + this.actions.clearRegistration(); + } }); } } diff --git a/src/app/features/collections/collections.routes.ts b/src/app/features/collections/collections.routes.ts index 1dbd7bc85..7c1fe8292 100644 --- a/src/app/features/collections/collections.routes.ts +++ b/src/app/features/collections/collections.routes.ts @@ -31,7 +31,7 @@ export const collectionsRoutes: Routes = [ }, { path: ':providerId', - redirectTo: ':providerId/discover', + redirectTo: ({ params }) => `${params['providerId']}/discover`, }, { path: ':providerId/discover', diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index ff55e38f1..99187f5a0 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -7,6 +7,7 @@ import { Stepper } from 'primeng/stepper'; import { filter, map, Observable, of, switchMap } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -15,6 +16,7 @@ import { effect, HostListener, inject, + PLATFORM_ID, signal, } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; @@ -76,6 +78,8 @@ export class AddToCollectionComponent implements CanDeactivateComponent { private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); private readonly loaderService = inject(LoaderService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); readonly selectedProjectId = toSignal( this.route.params.pipe(map((params) => params['id'])) ?? of(null) @@ -285,11 +289,13 @@ export class AddToCollectionComponent implements CanDeactivateComponent { private setupCleanup() { this.destroyRef.onDestroy(() => { - this.actions.clearAddToCollectionState(); - this.allowNavigation.set(false); + if (this.isBrowser) { + this.actions.clearAddToCollectionState(); + this.allowNavigation.set(false); - this.headerStyleHelper.resetToDefaults(); - this.brandService.resetBranding(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + } }); } diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts index 5120ddc96..95913df5c 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts @@ -13,6 +13,7 @@ import { Step, StepItem, StepPanel } from 'primeng/stepper'; import { Textarea } from 'primeng/textarea'; import { Tooltip } from 'primeng/tooltip'; +import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -22,6 +23,7 @@ import { inject, input, output, + PLATFORM_ID, signal, untracked, } from '@angular/core'; @@ -75,6 +77,9 @@ export class ProjectMetadataStepComponent { private readonly toastService = inject(ToastService); private readonly destroyRef = inject(DestroyRef); private readonly formService = inject(ProjectMetadataFormService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); + readonly currentYear = new Date(); readonly ProjectMetadataFormControls = ProjectMetadataFormControls; @@ -244,7 +249,9 @@ export class ProjectMetadataStepComponent { }); this.destroyRef.onDestroy(() => { - this.actions.clearProjects(); + if (this.isBrowser) { + this.actions.clearProjects(); + } }); } } diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts index cd7929102..9f22a910d 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -6,7 +6,17 @@ import { Button } from 'primeng/button'; import { debounceTime } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + PLATFORM_ID, + signal, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -56,6 +66,8 @@ export class CollectionsDiscoverComponent { private destroyRef = inject(DestroyRef); private brandService = inject(BrandService); private headerStyleHelper = inject(HeaderStyleService); + private platformId = inject(PLATFORM_ID); + private isBrowser = isPlatformBrowser(this.platformId); searchControl = new FormControl(''); providerId = signal(''); @@ -152,9 +164,11 @@ export class CollectionsDiscoverComponent { }); this.destroyRef.onDestroy(() => { - this.actions.clearCollections(); - this.headerStyleHelper.resetToDefaults(); - this.brandService.resetBranding(); + if (this.isBrowser) { + this.actions.clearCollections(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + } }); } diff --git a/src/app/features/files/files.routes.ts b/src/app/features/files/files.routes.ts index 5c14d10a2..5c93c2baf 100644 --- a/src/app/features/files/files.routes.ts +++ b/src/app/features/files/files.routes.ts @@ -32,7 +32,6 @@ export const filesRoutes: Routes = [ (c) => c.FileDetailComponent ); }, - children: [ { path: 'metadata', diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 08d8bc8b7..f06b4a010 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -20,6 +20,7 @@ import { take, } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; import { HttpEventType, HttpResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -30,6 +31,7 @@ import { HostBinding, inject, model, + PLATFORM_ID, signal, viewChild, } from '@angular/core'; @@ -130,6 +132,8 @@ export class FilesComponent { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); private readonly webUrl = this.environment.webUrl; private readonly apiDomainUrl = this.environment.apiDomainUrl; @@ -303,7 +307,6 @@ export class FilesComponent { if (currentRootFolder) { const provider = currentRootFolder.folder?.provider; const storageId = currentRootFolder.folder?.id; - // [NM TODO] Check if other providers allow revisions this.allowRevisions = provider === FileProvider.OsfStorage; this.isGoogleDrive.set(provider === FileProvider.GoogleDrive); if (this.isGoogleDrive()) { @@ -347,7 +350,9 @@ export class FilesComponent { }); this.destroyRef.onDestroy(() => { - this.actions.resetState(); + if (this.isBrowser) { + this.actions.resetState(); + } }); } diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index cbb42eff3..17522201b 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -8,7 +8,8 @@ import { TablePageEvent } from 'primeng/table'; import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs'; -import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { Component, computed, DestroyRef, effect, inject, OnInit, PLATFORM_ID, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -51,6 +52,8 @@ export class DashboardComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly customDialogService = inject(CustomDialogService); private readonly projectRedirectDialogService = inject(ProjectRedirectDialogService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); readonly searchControl = new FormControl(''); readonly activeProject = signal(null); @@ -126,7 +129,9 @@ export class DashboardComponent implements OnInit { setupCleanup(): void { this.destroyRef.onDestroy(() => { - this.actions.clearMyResources(); + if (this.isBrowser) { + this.actions.clearMyResources(); + } }); } diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 89c2af68e..688499d53 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -8,6 +8,7 @@ import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -16,6 +17,7 @@ import { effect, inject, OnInit, + PLATFORM_ID, signal, untracked, } from '@angular/core'; @@ -79,6 +81,8 @@ export class MyProjectsComponent implements OnInit { readonly projectRedirectDialogService = inject(ProjectRedirectDialogService); readonly queryService = inject(MyProjectsQueryService); readonly tableParamsService = inject(MyProjectsTableParamsService); + readonly platformId = inject(PLATFORM_ID); + readonly isBrowser = isPlatformBrowser(this.platformId); readonly isLoading = signal(false); readonly isMedium = toSignal(inject(IS_MEDIUM)); @@ -186,7 +190,9 @@ export class MyProjectsComponent implements OnInit { private setupCleanup(): void { this.destroyRef.onDestroy(() => { - this.actions.clearMyProjects(); + if (this.isBrowser) { + this.actions.clearMyProjects(); + } }); } diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.html b/src/app/features/preprints/components/advisory-board/advisory-board.component.html index ba5937da1..25a72d05a 100644 --- a/src/app/features/preprints/components/advisory-board/advisory-board.component.html +++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.html @@ -2,6 +2,6 @@
} diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.ts b/src/app/features/preprints/components/advisory-board/advisory-board.component.ts index ec2485492..2840658ce 100644 --- a/src/app/features/preprints/components/advisory-board/advisory-board.component.ts +++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.ts @@ -3,10 +3,11 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { StringOrNullOrUndefined } from '@osf/shared/helpers/types.helper'; import { BrandModel } from '@osf/shared/models/brand/brand.model'; +import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; @Component({ selector: 'osf-advisory-board', - imports: [NgClass], + imports: [NgClass, SafeHtmlPipe], templateUrl: './advisory-board.component.html', styleUrl: './advisory-board.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.html b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.html index a483012c0..6e5b9cd3f 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.html +++ b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.html @@ -9,9 +9,9 @@
- +