From e15da9b76beb1b60b521aab9a0db66b329211462 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 17 Jun 2022 15:43:46 +0200 Subject: [PATCH 001/106] 92282: Support restricted thumbnails --- angular.json | 3 +- ...arch-result-grid-element.component.spec.ts | 11 +- ...arch-result-grid-element.component.spec.ts | 11 +- ...arch-result-grid-element.component.spec.ts | 9 + .../testing/authorization-service.stub.ts | 8 + src/app/shared/testing/file-service.stub.ts | 7 + src/app/thumbnail/thumbnail.component.html | 15 +- src/app/thumbnail/thumbnail.component.spec.ts | 289 ++++++++++++++---- src/app/thumbnail/thumbnail.component.ts | 132 ++++++-- 9 files changed, 391 insertions(+), 94 deletions(-) create mode 100644 src/app/shared/testing/authorization-service.stub.ts create mode 100644 src/app/shared/testing/file-service.stub.ts diff --git a/angular.json b/angular.json index a0a4cd8ea1..7a83fcce31 100644 --- a/angular.json +++ b/angular.json @@ -64,7 +64,8 @@ "bundleName": "dspace-theme" } ], - "scripts": [] + "scripts": [], + "baseHref": "/" }, "configurations": { "production": { diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts index 2cb0413bbc..0f470fcbee 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts @@ -14,6 +14,12 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; describe('CollectionAdminSearchResultGridElementComponent', () => { let component: CollectionAdminSearchResultGridElementComponent; @@ -45,7 +51,10 @@ describe('CollectionAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, - { provide: LinkService, useValue: linkService } + { provide: LinkService, useValue: linkService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, ] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts index 17ce2cd7a1..c9f2cda02a 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts @@ -16,6 +16,12 @@ import { CommunitySearchResult } from '../../../../../shared/object-collection/s import { Community } from '../../../../../core/shared/community.model'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; describe('CommunityAdminSearchResultGridElementComponent', () => { let component: CommunityAdminSearchResultGridElementComponent; @@ -47,7 +53,10 @@ describe('CommunityAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, - { provide: LinkService, useValue: linkService } + { provide: LinkService, useValue: linkService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index dedada5f5f..7488b935f3 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -18,6 +18,12 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; @@ -55,6 +61,9 @@ describe('ItemAdminSearchResultGridElementComponent', () => { { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: ThemeService, useValue: mockThemeService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/shared/testing/authorization-service.stub.ts b/src/app/shared/testing/authorization-service.stub.ts new file mode 100644 index 0000000000..253599233e --- /dev/null +++ b/src/app/shared/testing/authorization-service.stub.ts @@ -0,0 +1,8 @@ +import { Observable, of as observableOf } from 'rxjs'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; + +export class AuthorizationDataServiceStub { + isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string): Observable { + return observableOf(false); + } +} diff --git a/src/app/shared/testing/file-service.stub.ts b/src/app/shared/testing/file-service.stub.ts new file mode 100644 index 0000000000..c675df83e1 --- /dev/null +++ b/src/app/shared/testing/file-service.stub.ts @@ -0,0 +1,7 @@ +import { of as observableOf } from 'rxjs'; + +export class FileServiceStub { + retrieveFileDownloadLink() { + return observableOf(null); + } +} diff --git a/src/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html index bf70928392..0a6cdedc26 100644 --- a/src/app/thumbnail/thumbnail.component.html +++ b/src/app/thumbnail/thumbnail.component.html @@ -1,14 +1,15 @@ -
- +
+ text-content - - -
+ + + +
{{ placeholder | translate }}
- +
diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts index eea585f9f8..5d74df02b0 100644 --- a/src/app/thumbnail/thumbnail.component.spec.ts +++ b/src/app/thumbnail/thumbnail.component.spec.ts @@ -3,12 +3,15 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Bitstream } from '../core/shared/bitstream.model'; import { SafeUrlPipe } from '../shared/utils/safe-url-pipe'; +import { of as observableOf } from 'rxjs'; import { ThumbnailComponent } from './thumbnail.component'; import { RemoteData } from '../core/data/remote-data'; -import { - createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject, -} from '../shared/remote-data.utils'; +import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; +import { AuthService } from '../core/auth/auth.service'; +import { FileService } from '../core/shared/file.service'; +import { VarDirective } from '../shared/utils/var.directive'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; // tslint:disable-next-line:pipe-prefix @Pipe({ name: 'translate' }) @@ -18,143 +21,311 @@ class MockTranslatePipe implements PipeTransform { } } +const CONTENT = 'content.url'; + describe('ThumbnailComponent', () => { let comp: ThumbnailComponent; let fixture: ComponentFixture; let de: DebugElement; let el: HTMLElement; + let authService; + let authorizationService; + let fileService; beforeEach(waitForAsync(() => { + authService = jasmine.createSpyObj('AuthService', { + isAuthenticated: observableOf(true), + }); + authorizationService = jasmine.createSpyObj('AuthorizationService', { + isAuthorized: observableOf(true), + }); + fileService = jasmine.createSpyObj('FileService', { + retrieveFileDownloadLink: null + }); + fileService.retrieveFileDownloadLink.and.callFake((url) => observableOf(`${url}?authentication-token=fake`)); + TestBed.configureTestingModule({ - declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe], + declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe, VarDirective], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: FileService, useValue: fileService } + ] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(ThumbnailComponent); + fixture.detectChanges(); + + authService = TestBed.inject(AuthService); + comp = fixture.componentInstance; // ThumbnailComponent test instance de = fixture.debugElement.query(By.css('div.thumbnail')); el = de.nativeElement; }); - const withoutThumbnail = () => { - describe('and there is a default image', () => { - it('should display the default image', () => { - comp.src = 'http://bit.stream'; - comp.defaultImage = 'http://default.img'; - comp.errorHandler(); - expect(comp.src).toBe(comp.defaultImage); - }); - it('should include the alt text', () => { - comp.src = 'http://bit.stream'; - comp.defaultImage = 'http://default.img'; - comp.errorHandler(); - comp.ngOnChanges(); + describe('loading', () => { + it('should start out with isLoading$ true', () => { + expect(comp.isLoading$.getValue()).toBeTrue(); + }); + + it('should set isLoading$ to false once an image is successfully loaded', () => { + comp.setSrc('http://bit.stream'); + fixture.debugElement.query(By.css('img.thumbnail-content')).triggerEventHandler('load', new Event('load')); + expect(comp.isLoading$.getValue()).toBeFalse(); + }); + + it('should set isLoading$ to false once the src is set to null', () => { + comp.setSrc(null); + expect(comp.isLoading$.getValue()).toBeFalse(); + }); + + it('should show a loading animation while isLoading$ is true', () => { + expect(de.query(By.css('ds-loading'))).toBeTruthy(); + + comp.isLoading$.next(false); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-loading'))).toBeFalsy(); + }); + + describe('with a thumbnail image', () => { + beforeEach(() => { + comp.src$.next('https://bit.stream'); fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; + }); + + it('should render but hide the image while loading and show it once done', () => { + let img = fixture.debugElement.query(By.css('img.thumbnail-content')); + expect(img).toBeTruthy(); + expect(img.classes['d-none']).toBeTrue(); + + comp.isLoading$.next(false); + fixture.detectChanges(); + img = fixture.debugElement.query(By.css('img.thumbnail-content')); + expect(img).toBeTruthy(); + expect(img.classes['d-none']).toBeFalsy(); + }); + + }); + + describe('without a thumbnail image', () => { + beforeEach(() => { + comp.src$.next(null); + fixture.detectChanges(); + }); + + it('should only show the HTML placeholder once done loading', () => { + expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeFalsy(); + + comp.isLoading$.next(false); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeTruthy(); + }); + }); + + }); + + const errorHandler = () => { + let fallbackSpy; + + beforeEach(() => { + fallbackSpy = spyOn(comp, 'showFallback').and.callThrough(); + }); + + describe('retry with authentication token', () => { + beforeEach(() => { + // disconnect error handler to be sure it's only called once + const img = fixture.debugElement.query(By.css('img.thumbnail-content')); + img.nativeNode.onerror = null; + }); + + it('should remember that it already retried once', () => { + expect(comp.retriedWithToken).toBeFalse(); + comp.errorHandler(); + expect(comp.retriedWithToken).toBeTrue(); + }); + + describe('if not logged in', () => { + beforeEach(() => { + authService.isAuthenticated.and.returnValue(observableOf(false)); + }); + + it('should fall back to default', () => { + comp.errorHandler(); + expect(fallbackSpy).toHaveBeenCalled(); + }); + }); + + describe('if logged in', () => { + beforeEach(() => { + authService.isAuthenticated.and.returnValue(observableOf(true)); + }); + + describe('and authorized to download the thumbnail', () => { + beforeEach(() => { + authorizationService.isAuthorized.and.returnValue(observableOf(true)); + }); + + it('should add an authentication token to the thumbnail URL', () => { + comp.errorHandler(); + + if ((comp.thumbnail as RemoteData)?.hasFailed) { + // If we failed to retrieve the Bitstream in the first place, fall back to the default + expect(comp.src$.getValue()).toBe(null); + expect(fallbackSpy).toHaveBeenCalled(); + } else { + expect(comp.src$.getValue()).toBe(CONTENT + '?authentication-token=fake'); + expect(fallbackSpy).not.toHaveBeenCalled(); + } + }); + }); + + describe('but not authorized to download the thumbnail', () => { + beforeEach(() => { + authorizationService.isAuthorized.and.returnValue(observableOf(false)); + }); + + it('should fall back to default', () => { + comp.errorHandler(); + + expect(comp.src$.getValue()).toBe(null); + expect(fallbackSpy).toHaveBeenCalled(); + + // We don't need to check authorization if we failed to retrieve the Bitstreamin the first place + if (!(comp.thumbnail as RemoteData)?.hasFailed) { + expect(authorizationService.isAuthorized).toHaveBeenCalled(); + } + }); + }); + }); + }); + + describe('after retrying with token', () => { + beforeEach(() => { + comp.retriedWithToken = true; + }); + + it('should fall back to default', () => { + comp.errorHandler(); + expect(authService.isAuthenticated).not.toHaveBeenCalled(); + expect(fileService.retrieveFileDownloadLink).not.toHaveBeenCalled(); + expect(fallbackSpy).toHaveBeenCalled(); + }); + }); + }; + + describe('fallback', () => { + describe('if there is a default image', () => { + it('should display the default image', () => { + comp.src$.next('http://bit.stream'); + comp.defaultImage = 'http://default.img'; + comp.errorHandler(); + expect(comp.src$.getValue()).toBe(comp.defaultImage); + }); + + it('should include the alt text', () => { + comp.src$.next('http://bit.stream'); + comp.defaultImage = 'http://default.img'; + comp.errorHandler(); + + fixture.detectChanges(); + const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); }); }); - describe('and there is no default image', () => { - it('should display the placeholder', () => { - comp.src = 'http://default.img'; - comp.errorHandler(); - expect(comp.src).toBe(null); - comp.ngOnChanges(); + describe('if there is no default image', () => { + it('should display the HTML placeholder', () => { + comp.src$.next('http://default.img'); + comp.defaultImage = null; + comp.errorHandler(); + expect(comp.src$.getValue()).toBe(null); + fixture.detectChanges(); const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement; expect(placeholder.innerHTML).toBe('TRANSLATED ' + comp.placeholder); }); }); - }; + }); describe('with thumbnail as Bitstream', () => { - let thumbnail: Bitstream; + let thumbnail; beforeEach(() => { thumbnail = new Bitstream(); thumbnail._links = { self: { href: 'self.url' }, bundle: { href: 'bundle.url' }, format: { href: 'format.url' }, - content: { href: 'content.url' }, + content: { href: CONTENT }, thumbnail: undefined, }; + comp.thumbnail = thumbnail; }); it('should display an image', () => { - comp.thumbnail = thumbnail; comp.ngOnChanges(); fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; - expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href); + const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; + expect(image.getAttribute('src')).toBe(thumbnail._links.content.href); }); it('should include the alt text', () => { - comp.thumbnail = thumbnail; comp.ngOnChanges(); fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; + const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); }); describe('when there is no thumbnail', () => { - withoutThumbnail(); + errorHandler(); }); }); describe('with thumbnail as RemoteData', () => { - let thumbnail: RemoteData; + let thumbnail: Bitstream; - describe('while loading', () => { - beforeEach(() => { - thumbnail = createPendingRemoteDataObject(); - }); - - it('should show a loading animation', () => { - comp.thumbnail = thumbnail; - comp.ngOnChanges(); - fixture.detectChanges(); - expect(de.query(By.css('ds-loading'))).toBeTruthy(); - }); + beforeEach(() => { + thumbnail = new Bitstream(); + thumbnail._links = { + self: { href: 'self.url' }, + bundle: { href: 'bundle.url' }, + format: { href: 'format.url' }, + content: { href: CONTENT }, + thumbnail: undefined + }; }); describe('when there is a thumbnail', () => { beforeEach(() => { - const bitstream = new Bitstream(); - bitstream._links = { - self: { href: 'self.url' }, - bundle: { href: 'bundle.url' }, - format: { href: 'format.url' }, - content: { href: 'content.url' }, - thumbnail: undefined, - }; - thumbnail = createSuccessfulRemoteDataObject(bitstream); + comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail); }); it('should display an image', () => { - comp.thumbnail = thumbnail; comp.ngOnChanges(); fixture.detectChanges(); const image: HTMLElement = de.query(By.css('img')).nativeElement; - expect(image.getAttribute('src')).toBe(comp.thumbnail.payload._links.content.href); + expect(image.getAttribute('src')).toBe(thumbnail._links.content.href); }); it('should display the alt text', () => { - comp.thumbnail = thumbnail; comp.ngOnChanges(); fixture.detectChanges(); const image: HTMLElement = de.query(By.css('img')).nativeElement; expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); }); + + describe('but it can\'t be loaded', () => { + errorHandler(); + }); }); describe('when there is no thumbnail', () => { beforeEach(() => { - thumbnail = createFailedRemoteDataObject(); + comp.thumbnail = createFailedRemoteDataObject(); }); - withoutThumbnail(); + errorHandler(); }); }); }); diff --git a/src/app/thumbnail/thumbnail.component.ts b/src/app/thumbnail/thumbnail.component.ts index 3e122cde78..ac0992c345 100644 --- a/src/app/thumbnail/thumbnail.component.ts +++ b/src/app/thumbnail/thumbnail.component.ts @@ -1,7 +1,13 @@ import { Component, Input, OnChanges } from '@angular/core'; import { Bitstream } from '../core/shared/bitstream.model'; -import { hasValue } from '../shared/empty.util'; +import { hasNoValue, hasValue } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; +import { BehaviorSubject, of as observableOf } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { AuthService } from '../core/auth/auth.service'; +import { FileService } from '../core/shared/file.service'; /** * This component renders a given Bitstream as a thumbnail. @@ -28,7 +34,9 @@ export class ThumbnailComponent implements OnChanges { /** * The src attribute used in the template to render the image. */ - src: string = null; + src$ = new BehaviorSubject(undefined); + + retriedWithToken = false; /** * i18n key of thumbnail alt text @@ -45,49 +53,123 @@ export class ThumbnailComponent implements OnChanges { */ @Input() limitWidth? = true; - isLoading: boolean; + /** + * Whether the thumbnail is currently loading + * Start out as true to avoid flashing the alt text while a thumbnail is being loaded. + */ + isLoading$ = new BehaviorSubject(true); + + constructor( + protected auth: AuthService, + protected authorizationService: AuthorizationDataService, + protected fileService: FileService, + ) { + } /** * Resolve the thumbnail. * Use a default image if no actual image is available. */ ngOnChanges(): void { - if (this.thumbnail === undefined || this.thumbnail === null) { + if (hasNoValue(this.thumbnail)) { return; } - if (this.thumbnail instanceof Bitstream) { - this.resolveThumbnail(this.thumbnail as Bitstream); + + const thumbnail = this.bitstream; + if (hasValue(thumbnail?._links?.content?.href)) { + this.setSrc(thumbnail?._links?.content?.href); } else { - const thumbnailRD = this.thumbnail as RemoteData; - if (thumbnailRD.isLoading) { - this.isLoading = true; - } else { - this.resolveThumbnail(thumbnailRD.payload as Bitstream); - } + this.showFallback(); } } - private resolveThumbnail(thumbnail: Bitstream): void { - if (hasValue(thumbnail) && hasValue(thumbnail._links) - && hasValue(thumbnail._links.content) - && thumbnail._links.content.href) { - this.src = thumbnail._links.content.href; - } else { - this.src = this.defaultImage; + /** + * The current thumbnail Bitstream + * @private + */ + private get bitstream(): Bitstream { + if (this.thumbnail instanceof Bitstream) { + return this.thumbnail as Bitstream; + } else if (this.thumbnail instanceof RemoteData) { + return (this.thumbnail as RemoteData).payload; } - this.isLoading = false; } /** * Handle image download errors. - * If the image can't be found, use the defaultImage instead. - * If that also can't be found, use null to fall back to the HTML placeholder. + * If the image can't be loaded, try re-requesting it with an authorization token in case it's a restricted Bitstream + * Otherwise, fall back to the default image or a HTML placeholder */ errorHandler() { - if (this.src !== this.defaultImage) { - this.src = this.defaultImage; + if (!this.retriedWithToken && hasValue(this.thumbnail)) { + // the thumbnail may have failed to load because it's restricted + // → retry with an authorization token + // only do this once; fall back to the default if it still fails + this.retriedWithToken = true; + + const thumbnail = this.bitstream; + this.auth.isAuthenticated().pipe( + switchMap((isLoggedIn) => { + if (isLoggedIn && hasValue(thumbnail)) { + return this.authorizationService.isAuthorized(FeatureID.CanDownload, thumbnail.self); + } else { + return observableOf(false); + } + }), + switchMap((isAuthorized) => { + if (isAuthorized) { + return this.fileService.retrieveFileDownloadLink(thumbnail._links.content.href); + } else { + return observableOf(null); + } + }) + ).subscribe((url: string) => { + if (hasValue(url)) { + // If we got a URL, try to load it + // (if it still fails this method will be called again, and we'll fall back to the default) + // Otherwise, fall back to the default image right now + this.setSrc(url); + } else { + this.showFallback(); + } + }); } else { - this.src = null; + this.showFallback(); } } + + /** + * To be called when the requested thumbnail could not be found + * - If the current src is not the default image, try that first + * - If this was already the case and the default image could not be found either, + * show an HTML placecholder by setting src to null + * + * Also stops the loading animation. + */ + showFallback() { + if (this.src$.getValue() !== this.defaultImage) { + this.setSrc(this.defaultImage); + } else { + this.setSrc(null); + } + } + + /** + * Set the thumbnail. + * Stop the loading animation if setting to null. + * @param src + */ + setSrc(src: string): void { + this.src$.next(src); + if (src === null) { + this.isLoading$.next(false); + } + } + + /** + * Stop the loading animation once the thumbnail is successfully loaded + */ + successHandler() { + this.isLoading$.next(false); + } } From 0a73875d17d7cebbc5fcb7c907e55c6ad320c63d Mon Sep 17 00:00:00 2001 From: nikunj59 Date: Tue, 21 Jun 2022 21:42:33 +0530 Subject: [PATCH 002/106] CST-6110 changes for robust password error --- .../profile-page-security-form.component.html | 1 + ...rofile-page-security-form.component.spec.ts | 13 +++++++++++++ .../profile-page-security-form.component.ts | 6 ++++++ .../profile-page/profile-page.component.html | 1 + src/app/profile-page/profile-page.component.ts | 5 ++++- .../create-profile.component.html | 1 + .../create-profile.component.spec.ts | 18 ++++++++++++++++++ .../create-profile/create-profile.component.ts | 8 +++++--- src/assets/i18n/en.json5 | 4 ++++ 9 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html index 7c1dff5bdf..66c9d73be2 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -9,3 +9,4 @@
{{FORM_PREFIX + 'error.password-length' | translate}}
{{FORM_PREFIX + 'error.matching-passwords' | translate}}
{{FORM_PREFIX + 'error.empty-password' | translate}}
+
{{FORM_PREFIX + 'error.robust-password' | translate}}
diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts index 213a83b86e..e8fd4740eb 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -9,6 +9,7 @@ import { FormBuilderService } from '../../shared/form/builder/form-builder.servi import { ProfilePageSecurityFormComponent } from './profile-page-security-form.component'; import { of as observableOf } from 'rxjs'; import { RestResponse } from '../../core/cache/response.models'; +import { By } from '@angular/platform-browser'; describe('ProfilePageSecurityFormComponent', () => { let component: ProfilePageSecurityFormComponent; @@ -76,4 +77,16 @@ describe('ProfilePageSecurityFormComponent', () => { })); }); }); + + describe('On robust password Error', () => { + it('should show/hide robust password error', () => { + component.isRobustPasswordError = true; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('[data-test="robust-password-error"]'))).toBeTruthy(); + + component.isRobustPasswordError = false; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('[data-test="robust-password-error"]'))).toBeFalsy(); + }) + }); }); diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index 4f310204e3..1eacb9f1be 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -60,6 +60,12 @@ export class ProfilePageSecurityFormComponent implements OnInit { */ @Input() FORM_PREFIX: string; + + /** + * monitor to password is weak or not from server response + */ + @Input() + isRobustPasswordError: boolean; private subs: Subscription[] = []; constructor(protected formService: DynamicFormService, diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index 6e22f73a75..63c34d02b3 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -24,6 +24,7 @@ [FORM_PREFIX]="'profile.security.form.'" (isInvalid)="setInvalid($event)" (passwordValue)="setPasswordValue($event)" + [isRobustPasswordError]="isRobustPasswordError | async" >
diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 5629a1ae18..5762192e92 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -73,6 +73,7 @@ export class ProfilePageComponent implements OnInit { */ private currentUser: EPerson; canChangePassword$: Observable; + isRobustPasswordError: BehaviorSubject = new BehaviorSubject(false); isResearcherProfileEnabled$: BehaviorSubject = new BehaviorSubject(false); @@ -147,7 +148,9 @@ export class ProfilePageComponent implements OnInit { this.epersonService.patch(this.currentUser, [operation]).pipe( getFirstCompletedRemoteData() ).subscribe((response: RemoteData) => { - if (response.hasSucceeded) { + if (response.statusCode === 422) { + this.isRobustPasswordError.next(true); + } else if (response.hasSucceeded) { this.notificationsService.success( this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.title'), this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.content') diff --git a/src/app/register-page/create-profile/create-profile.component.html b/src/app/register-page/create-profile/create-profile.component.html index f56059ad69..5ef17c6b64 100644 --- a/src/app/register-page/create-profile/create-profile.component.html +++ b/src/app/register-page/create-profile/create-profile.component.html @@ -73,6 +73,7 @@ [FORM_PREFIX]="'register-page.create-profile.security.'" (isInvalid)="setInValid($event)" (passwordValue)="setPasswordValue($event)" + [isRobustPasswordError]="isRobustPasswordError | async" > diff --git a/src/app/register-page/create-profile/create-profile.component.spec.ts b/src/app/register-page/create-profile/create-profile.component.spec.ts index b95e380e08..d2d4c4c0ee 100644 --- a/src/app/register-page/create-profile/create-profile.component.spec.ts +++ b/src/app/register-page/create-profile/create-profile.component.spec.ts @@ -238,6 +238,24 @@ describe('CreateProfileComponent', () => { expect(router.navigate).not.toHaveBeenCalled(); expect(notificationsService.error).toHaveBeenCalled(); }); + + it('should submit an eperson for creation but password is weak', () => { + + (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 422)); + + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).toHaveBeenCalledWith(eperson, 'test-token'); + expect(comp.isRobustPasswordError.value).toBeTrue(); + }); + it('should submit not create an eperson when the user info form is invalid', () => { (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 500)); diff --git a/src/app/register-page/create-profile/create-profile.component.ts b/src/app/register-page/create-profile/create-profile.component.ts index d042e63ece..ff71e1ee01 100644 --- a/src/app/register-page/create-profile/create-profile.component.ts +++ b/src/app/register-page/create-profile/create-profile.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { map } from 'rxjs/operators'; import { Registration } from '../../core/shared/registration.model'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; @@ -40,7 +40,7 @@ export class CreateProfileComponent implements OnInit { userInfoForm: FormGroup; activeLangs: LangConfig[]; - + isRobustPasswordError: BehaviorSubject = new BehaviorSubject(false); constructor( private translateService: TranslateService, private ePersonDataService: EPersonDataService, @@ -160,7 +160,9 @@ export class CreateProfileComponent implements OnInit { this.ePersonDataService.createEPersonForToken(eperson, this.token).pipe( getFirstCompletedRemoteData(), ).subscribe((rd: RemoteData) => { - if (rd.hasSucceeded) { + if (rd.statusCode === 422) { + this.isRobustPasswordError.next(true); + } else if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get('register-page.create-profile.submit.success.head'), this.translateService.get('register-page.create-profile.submit.success.content')); this.store.dispatch(new AuthenticateAction(this.email, this.password)); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index cb664e19f7..bf5ccb4f06 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3056,6 +3056,8 @@ "profile.security.form.notifications.error.not-same": "The provided passwords are not the same.", + "profile.security.form.notifications.error.robust-password": "Please select a more robust password.", + "profile.title": "Update Profile", "profile.card.researcher": "Researcher Profile", @@ -3146,6 +3148,8 @@ "register-page.create-profile.security.error.empty-password": "Please enter a password in the box below.", + "register-page.create-profile.security.error.robust-password": "Please select a more robust password.", + "register-page.create-profile.security.error.matching-passwords": "The passwords do not match.", "register-page.create-profile.security.error.password-length": "The password should be at least 6 characters long.", From 17a89387f290da1ba4a6c6829c96881fa77314b0 Mon Sep 17 00:00:00 2001 From: nikunj59 Date: Wed, 22 Jun 2022 13:03:06 +0530 Subject: [PATCH 003/106] CST-6110 changes for robust password error --- .../profile-page/profile-page.component.spec.ts | 17 +++++++++++++++++ src/assets/i18n/en.json5 | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index 6893ac2437..bd85448566 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -228,6 +228,23 @@ describe('ProfilePageComponent', () => { expect(epersonService.patch).toHaveBeenCalledWith(user, operations); }); }); + + describe('when password is filled in, and is weak', () => { + let result; + let operations; + + it('should return call epersonService.patch', () => { + (epersonService.patch as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 422)); + component.setPasswordValue('testest'); + + component.setInvalid(false); + operations = [{ op: 'add', path: '/password', value: 'testest' }]; + result = component.updateSecurity(); + expect(result).toEqual(true); + expect(epersonService.patch).toHaveBeenCalledWith(user, operations); + expect(component.isRobustPasswordError.value).toBeTrue(); + }); + }); }); describe('canChangePassword$', () => { diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index bf5ccb4f06..353912a6b7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3056,7 +3056,7 @@ "profile.security.form.notifications.error.not-same": "The provided passwords are not the same.", - "profile.security.form.notifications.error.robust-password": "Please select a more robust password.", + "profile.security.form.error.robust-password": "Please select a more robust password.", "profile.title": "Update Profile", From 9e2a682a516d4804ed545a32c0bfba12d0e6e094 Mon Sep 17 00:00:00 2001 From: nikunj59 Date: Fri, 24 Jun 2022 19:26:30 +0530 Subject: [PATCH 004/106] CST-6153 changes for current password field introduce --- ...ofile-page-security-form.component.spec.ts | 13 +++++++++++ .../profile-page-security-form.component.ts | 21 +++++++++++++----- .../profile-page/profile-page.component.html | 1 + .../profile-page.component.spec.ts | 9 ++++++-- .../profile-page/profile-page.component.ts | 22 ++++++++++++++----- src/assets/i18n/en.json5 | 2 ++ 6 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts index 213a83b86e..ba758b1203 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -74,6 +74,19 @@ describe('ProfilePageSecurityFormComponent', () => { expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password'); })); + + it('should emit the value on password change with current password for profile-page', fakeAsync(() => { + spyOn(component.passwordValue, 'emit'); + spyOn(component.currentPasswordValue, 'emit'); + component.FORM_PREFIX = 'profile.security.form.'; + component.ngOnInit(); + component.formGroup.patchValue({password: 'new-password'}); + component.formGroup.patchValue({'current-password': 'current-password'}); + tick(300); + + expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password'); + expect(component.currentPasswordValue.emit).toHaveBeenCalledWith('current-password'); + })) }); }); }); diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index 4f310204e3..ab39ca1929 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -27,6 +27,10 @@ export class ProfilePageSecurityFormComponent implements OnInit { * Emits the value of the password */ @Output() passwordValue = new EventEmitter(); + /** + * Emits the value of the current-password + */ + @Output() currentPasswordValue = new EventEmitter(); /** * The form's input models @@ -69,6 +73,14 @@ export class ProfilePageSecurityFormComponent implements OnInit { } ngOnInit(): void { + if (this.FORM_PREFIX === 'profile.security.form.') { + this.formModel.unshift(new DynamicInputModel({ + id: 'current-password', + name: 'current-password', + inputType: 'password', + required: true + })); + } if (this.passwordCanBeEmpty) { this.formGroup = this.formService.createFormGroup(this.formModel, {validators: [this.checkPasswordsEqual, this.checkPasswordLength]}); @@ -85,11 +97,7 @@ export class ProfilePageSecurityFormComponent implements OnInit { this.subs.push(this.formGroup.statusChanges.pipe( debounceTime(300), map((status: string) => { - if (status !== 'VALID') { - return true; - } else { - return false; - } + return status !== 'VALID'; })).subscribe((status) => this.isInvalid.emit(status)) ); @@ -97,6 +105,9 @@ export class ProfilePageSecurityFormComponent implements OnInit { debounceTime(300), ).subscribe((valueChange) => { this.passwordValue.emit(valueChange.password); + if (this.FORM_PREFIX === 'profile.security.form.') { + this.currentPasswordValue.emit(valueChange['current-password']); + } })); } diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index 6e22f73a75..b6f53b08bf 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -24,6 +24,7 @@ [FORM_PREFIX]="'profile.security.form.'" (isInvalid)="setInvalid($event)" (passwordValue)="setPasswordValue($event)" + (currentPasswordValue)="setCurrentPasswordValue($event)" > diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index 6893ac2437..71537e9c32 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -180,7 +180,7 @@ describe('ProfilePageComponent', () => { beforeEach(() => { component.setPasswordValue(''); - + component.setCurrentPasswordValue('current-password'); result = component.updateSecurity(); }); @@ -199,6 +199,7 @@ describe('ProfilePageComponent', () => { beforeEach(() => { component.setPasswordValue('test'); component.setInvalid(true); + component.setCurrentPasswordValue('current-password'); result = component.updateSecurity(); }); @@ -215,8 +216,12 @@ describe('ProfilePageComponent', () => { beforeEach(() => { component.setPasswordValue('testest'); component.setInvalid(false); + component.setCurrentPasswordValue('current-password'); - operations = [{ op: 'add', path: '/password', value: 'testest' }]; + operations = [ + { op: 'add', path: '/password', value: 'testest' }, + { op: 'add', path: '/challenge', value: 'current-password' } + ]; result = component.updateSecurity(); }); diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 5629a1ae18..15811ee77f 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -67,6 +67,10 @@ export class ProfilePageComponent implements OnInit { * The password filled in, in the security form */ private password: string; + /** + * The current-password filled in, in the security form + */ + private currentPassword: string; /** * The authenticated user @@ -138,15 +142,15 @@ export class ProfilePageComponent implements OnInit { */ updateSecurity() { const passEntered = isNotEmpty(this.password); - if (this.invalidSecurity) { this.notificationsService.error(this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.general')); } if (!this.invalidSecurity && passEntered) { - const operation = {op: 'add', path: '/password', value: this.password} as Operation; - this.epersonService.patch(this.currentUser, [operation]).pipe( - getFirstCompletedRemoteData() - ).subscribe((response: RemoteData) => { + const operations = [ + { op: 'add', path: '/password', value: this.password }, + { op: 'add', path: '/challenge', value: this.currentPassword } + ] as Operation[]; + this.epersonService.patch(this.currentUser, operations).pipe(getFirstCompletedRemoteData()).subscribe((response: RemoteData) => { if (response.hasSucceeded) { this.notificationsService.success( this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.title'), @@ -170,6 +174,14 @@ export class ProfilePageComponent implements OnInit { this.password = $event; } + /** + * Set the current-password value based on the value emitted from the security form + * @param $event + */ + setCurrentPasswordValue($event: string) { + this.currentPassword = $event; + } + /** * Submit of the security form that triggers the updateProfile method */ diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 3d5f15b4f2..860db5aac6 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3046,6 +3046,8 @@ "profile.security.form.label.passwordrepeat": "Retype to confirm", + "profile.security.form.label.current-password": "Current password", + "profile.security.form.notifications.success.content": "Your changes to the password were saved.", "profile.security.form.notifications.success.title": "Password saved", From 36560e0e6500d646c2ec1608f7b6a23800c22ce6 Mon Sep 17 00:00:00 2001 From: nikunj59 Date: Mon, 27 Jun 2022 20:24:46 +0530 Subject: [PATCH 005/106] CST-6153 added test case --- .../profile-page.component.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index 71537e9c32..31332a5fd7 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -233,6 +233,29 @@ describe('ProfilePageComponent', () => { expect(epersonService.patch).toHaveBeenCalledWith(user, operations); }); }); + + describe('when password is filled in, and is valid but return 403', () => { + let result; + let operations; + + it('should return call epersonService.patch', (done) => { + epersonService.patch.and.returnValue(observableOf(Object.assign(new RestResponse(false, 403, 'Error')))); + component.setPasswordValue('testest'); + component.setInvalid(false); + component.setCurrentPasswordValue('current-password'); + operations = [ + { op: 'add', path: '/password', value: 'testest' }, + { op: 'add', path: '/challenge', value: 'current-password' } + ]; + result = component.updateSecurity(); + epersonService.patch(user, operations).subscribe((response) => { + expect(response.statusCode).toEqual(403); + done(); + }); + expect(epersonService.patch).toHaveBeenCalledWith(user, operations); + expect(result).toEqual(true); + }); + }); }); describe('canChangePassword$', () => { From 10bbb01a442d03725b5825b9372a834ba809bd25 Mon Sep 17 00:00:00 2001 From: nikunj59 Date: Thu, 30 Jun 2022 14:24:51 +0530 Subject: [PATCH 006/106] CST-6153 added error msg for required field of security form --- .../profile-page-security-form.component.spec.ts | 2 +- src/assets/i18n/en.json5 | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts index ba758b1203..88f50e3dea 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -86,7 +86,7 @@ describe('ProfilePageSecurityFormComponent', () => { expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password'); expect(component.currentPasswordValue.emit).toHaveBeenCalledWith('current-password'); - })) + })); }); }); }); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 1abcd551a6..4a6fb33e25 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3060,6 +3060,8 @@ "profile.security.form.notifications.error.not-same": "The provided passwords are not the same.", + "profile.security.form.notifications.error.general": "Please fill required fields of security form.", + "profile.title": "Update Profile", "profile.card.researcher": "Researcher Profile", From 7afa4dcd8dc426ed10b54c8d3edc116ecdc50f74 Mon Sep 17 00:00:00 2001 From: Michael Spalti Date: Thu, 4 Aug 2022 11:21:14 -0700 Subject: [PATCH 007/106] New themable components --- .../community-page.component.html | 3 +- .../community-page/community-page.module.ts | 8 ++++++ ...nity-page-sub-collection-list.component.ts | 7 ++++- ...nity-page-sub-collection-list.component.ts | 28 +++++++++++++++++++ ...unity-page-sub-community-list.component.ts | 9 ++++-- ...unity-page-sub-community-list.component.ts | 28 +++++++++++++++++++ ...ty-page-sub-collection-list.component.html | 0 ...ty-page-sub-collection-list.component.scss | 0 ...nity-page-sub-collection-list.component.ts | 12 ++++++++ ...ity-page-sub-community-list.component.html | 0 ...ity-page-sub-community-list.component.scss | 0 ...unity-page-sub-community-list.component.ts | 12 ++++++++ src/themes/custom/eager-theme.module.ts | 2 +- src/themes/custom/lazy-theme.module.ts | 11 ++++++++ 14 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts create mode 100644 src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts create mode 100644 src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html create mode 100644 src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss create mode 100644 src/themes/custom/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts create mode 100644 src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.html create mode 100644 src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.scss create mode 100644 src/themes/custom/app/community-page/sub-community-list/community-page-sub-community-list.component.ts diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index 6b277bd07f..314a14658d 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -25,11 +25,12 @@
+ - +