From aea41d74eca654515a2d3b11528112ac10e14c68 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 14:56:49 +0100 Subject: [PATCH 01/25] Request-a-copy improv: Secure media and image viewers --- .../item-secure-media-viewer.component.html | 35 ++++ .../item-secure-media-viewer.component.scss | 1 + ...item-secure-media-viewer.component.spec.ts | 160 ++++++++++++++++ .../item-secure-media-viewer.component.ts | 171 ++++++++++++++++++ .../secure-media-viewer-image.component.html | 7 + .../secure-media-viewer-image.component.scss | 20 ++ ...ecure-media-viewer-image.component.spec.ts | 91 ++++++++++ .../secure-media-viewer-image.component.ts | 109 +++++++++++ .../secure-media-viewer-video.component.html | 54 ++++++ .../secure-media-viewer-video.component.scss | 10 + ...ecure-media-viewer-video.component.spec.ts | 149 +++++++++++++++ .../secure-media-viewer-video.component.ts | 103 +++++++++++ 12 files changed, 910 insertions(+) create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.html create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.scss create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.spec.ts create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.ts create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.html create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.scss create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.spec.ts create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.ts create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.html create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.scss create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.spec.ts create mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.ts diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.html b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.html new file mode 100644 index 0000000000..6acb4299ee --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.html @@ -0,0 +1,35 @@ + + +
+ + + + + + + + + +
+ + + + + +
diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.scss b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.spec.ts b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.spec.ts new file mode 100644 index 0000000000..afc77ddb37 --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.spec.ts @@ -0,0 +1,160 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../../../core/auth/auth.service'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { MediaViewerItem } from '../../../../core/shared/media-viewer-item.model'; +import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { AuthServiceMock } from '../../../../shared/mocks/auth.service.mock'; +import { MockBitstreamFormat1 } from '../../../../shared/mocks/item.mock'; +import { getMockThemeService } from '../../../../shared/mocks/theme-service.mock'; +import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { ThemeService } from '../../../../shared/theme-support/theme.service'; +import { FileSizePipe } from '../../../../shared/utils/file-size-pipe'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { ItemSecureMediaViewerComponent } from './item-secure-media-viewer.component'; + +describe('ItemSecureMediaViewerComponent', () => { + let comp: ItemSecureMediaViewerComponent; + let fixture: ComponentFixture; + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10201, + content: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', + }, + content: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + }, + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx', + }, + ], + }, + }); + + const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findAllByItemAndBundleName: createSuccessfulRemoteDataObject$( + createPaginatedList([mockBitstream]), + ), + }); + + const mockMediaViewerItem: MediaViewerItem = Object.assign( + new MediaViewerItem(), + { bitstream: mockBitstream, format: 'image', thumbnail: null }, + ); + + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, + ItemSecureMediaViewerComponent, + VarDirective, + FileSizePipe, + MetadataFieldWrapperComponent, + ], + providers: [ + { provide: BitstreamDataService, useValue: bitstreamDataService }, + { provide: ThemeService, useValue: getMockThemeService() }, + { provide: AuthService, useValue: new AuthServiceMock() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemSecureMediaViewerComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('when the bitstreams are loading', () => { + beforeEach(() => { + comp.mediaList$.next([mockMediaViewerItem]); + comp.mediaOptions = { + image: true, + video: true, + }; + comp.isLoading = true; + fixture.detectChanges(); + }); + + it('should call the createMediaViewerItem', () => { + const mediaItem = comp.createMediaViewerItem( + mockBitstream, + MockBitstreamFormat1, + undefined, + ); + expect(mediaItem).toBeTruthy(); + expect(mediaItem.thumbnail).toBe(null); + }); + + it('should display a loading component', () => { + const loading = fixture.debugElement.query(By.css('ds-loading')); + expect(loading.nativeElement).toBeDefined(); + }); + }); + + describe('when the bitstreams loading is failed', () => { + beforeEach(() => { + comp.mediaList$.next([]); + comp.mediaOptions = { + image: true, + video: true, + }; + comp.isLoading = false; + fixture.detectChanges(); + }); + + it('should call the createMediaViewerItem', () => { + const mediaItem = comp.createMediaViewerItem( + mockBitstream, + MockBitstreamFormat1, + undefined, + ); + expect(mediaItem).toBeTruthy(); + expect(mediaItem.thumbnail).toBe(null); + }); + + it('should display a default, thumbnail', () => { + const defaultThumbnail = fixture.debugElement.query( + By.css('ds-secure-media-viewer-image'), + ); + expect(defaultThumbnail.nativeElement).toBeDefined(); + }); + }); +}); diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.ts b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.ts new file mode 100644 index 0000000000..fb06d0ec95 --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.ts @@ -0,0 +1,171 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + Subscription, +} from 'rxjs'; +import { + filter, + take, +} from 'rxjs/operators'; + +import { MediaViewerConfig } from '../../../../../config/media-viewer-config.interface'; +import { environment } from '../../../../../environments/environment'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { Item } from '../../../../core/shared/item.model'; +import { MediaViewerItem } from '../../../../core/shared/media-viewer-item.model'; +import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; +import { hasValue } from '../../../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; +import { followLink } from '../../../../shared/utils/follow-link-config.model'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; +import { SecureMediaViewerImageComponent } from './media-viewer-image/secure-media-viewer-image.component'; +import { SecureMediaViewerVideoComponent } from './media-viewer-video/secure-media-viewer-video.component'; + +/** + * This component renders the media viewers + */ +@Component({ + selector: 'ds-item-secure-media-viewer', + templateUrl: './item-secure-media-viewer.component.html', + styleUrls: ['./item-secure-media-viewer.component.scss'], + imports: [ + AsyncPipe, + NgIf, + TranslateModule, + ThemedLoadingComponent, + VarDirective, + SecureMediaViewerVideoComponent, + SecureMediaViewerImageComponent, + ThemedThumbnailComponent, + ], + standalone: true, +}) +export class ItemSecureMediaViewerComponent implements OnDestroy, OnInit { + @Input() item: Item; + + @Input() mediaOptions: MediaViewerConfig = environment.mediaViewer; + + @Input() accessToken: string; + + mediaList$: BehaviorSubject = new BehaviorSubject([]); + + captions$: BehaviorSubject = new BehaviorSubject([]); + + isLoading = true; + + thumbnailPlaceholder = './assets/images/replacement_document.svg'; + + thumbnailsRD$: Observable>>; + + subs: Subscription[] = []; + + constructor( + protected bitstreamDataService: BitstreamDataService, + protected changeDetectorRef: ChangeDetectorRef, + ) { + } + + ngOnDestroy(): void { + this.subs.forEach((subscription: Subscription) => subscription.unsubscribe()); + } + + /** + * This method loads all the Bitstreams and Thumbnails and converts it to {@link MediaViewerItem}s + */ + ngOnInit(): void { + const types: string[] = [ + ...(this.mediaOptions.image ? ['image'] : []), + ...(this.mediaOptions.video ? ['audio', 'video'] : []), + ]; + this.thumbnailsRD$ = this.loadRemoteData('THUMBNAIL'); + this.subs.push(this.loadRemoteData('ORIGINAL').subscribe((bitstreamsRD: RemoteData>) => { + if (bitstreamsRD.payload.page.length === 0) { + this.isLoading = false; + this.mediaList$.next([]); + } else { + this.subs.push(this.thumbnailsRD$.subscribe((thumbnailsRD: RemoteData>) => { + for ( + let index = 0; + index < bitstreamsRD.payload.page.length; + index++ + ) { + this.subs.push(bitstreamsRD.payload.page[index].format + .pipe(getFirstSucceededRemoteDataPayload()) + .subscribe((format: BitstreamFormat) => { + const mediaItem = this.createMediaViewerItem( + bitstreamsRD.payload.page[index], + format, + thumbnailsRD.payload && thumbnailsRD.payload.page[index], + ); + if (types.includes(mediaItem.format)) { + this.mediaList$.next([...this.mediaList$.getValue(), mediaItem]); + } else if (format.mimetype === 'text/vtt') { + this.captions$.next([...this.captions$.getValue(), bitstreamsRD.payload.page[index]]); + } + })); + } + this.isLoading = false; + this.changeDetectorRef.detectChanges(); + })); + } + })); + } + + /** + * This method will retrieve the next page of Bitstreams from the external BitstreamDataService call. + * @param bundleName Bundle name + */ + loadRemoteData( + bundleName: string, + ): Observable>> { + return this.bitstreamDataService + .findAllByItemAndBundleName( + this.item, + bundleName, + {}, + true, + true, + followLink('format'), + ) + .pipe( + filter( + (bitstreamsRD: RemoteData>) => + hasValue(bitstreamsRD) && + (hasValue(bitstreamsRD.errorMessage) || hasValue(bitstreamsRD.payload)), + ), + take(1), + ); + } + + /** + * This method creates a {@link MediaViewerItem} from incoming {@link Bitstream}s + * @param original original bitstream + * @param format original bitstream format + * @param thumbnail thumbnail bitstream + */ + createMediaViewerItem(original: Bitstream, format: BitstreamFormat, thumbnail: Bitstream): MediaViewerItem { + const mediaItem = new MediaViewerItem(); + mediaItem.bitstream = original; + mediaItem.format = format.mimetype.split('/')[0]; + mediaItem.mimetype = format.mimetype; + mediaItem.thumbnail = thumbnail ? thumbnail._links.content.href : null; + return mediaItem; + } +} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.html b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.html new file mode 100644 index 0000000000..bafc6f079c --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.scss b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.scss new file mode 100644 index 0000000000..cba963b6fa --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.scss @@ -0,0 +1,20 @@ +:host ::ng-deep { + .ngx-gallery { + width: unset !important; + height: unset !important; + } + + ngx-gallery-image { + max-width: 340px !important; + + .ngx-gallery-image { + background-position: left; + } + } + + ngx-gallery-image:after { + padding-top: 75%; + display: block; + content: ''; + } +} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.spec.ts b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.spec.ts new file mode 100644 index 0000000000..536c073e41 --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.spec.ts @@ -0,0 +1,91 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { NgxGalleryOptions } from '@kolkov/ngx-gallery'; +import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../../../../core/auth/auth.service'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { MediaViewerItem } from '../../../../../core/shared/media-viewer-item.model'; +import { MockBitstreamFormat1 } from '../../../../../shared/mocks/item.mock'; +import { SecureMediaViewerImageComponent } from './secure-media-viewer-image.component'; + +describe('ItemSecureMediaViewerImageComponent', () => { + let component: SecureMediaViewerImageComponent; + let fixture: ComponentFixture; + + const authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(false), + }); + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10201, + content: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', + }, + content: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + }, + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx', + }, + ], + }, + }); + + const mockMediaViewerItems: MediaViewerItem[] = Object.assign( + new Array(), + [ + { bitstream: mockBitstream, format: 'image', thumbnail: null }, + { bitstream: mockBitstream, format: 'image', thumbnail: null }, + ], + ); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SecureMediaViewerImageComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: AuthService, useValue: authService }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SecureMediaViewerImageComponent); + component = fixture.componentInstance; + component.galleryOptions = [new NgxGalleryOptions({})]; + component.galleryImages = component.convertToGalleryImage( + mockMediaViewerItems, + ); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should contain a gallery options', () => { + expect(component.galleryOptions.length).toBeGreaterThan(0); + }); + + it('should contain an image array', () => { + expect(component.galleryImages.length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.ts b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.ts new file mode 100644 index 0000000000..c6c9cec24a --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.ts @@ -0,0 +1,109 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnChanges, + OnInit, +} from '@angular/core'; +import { + NgxGalleryAnimation, + NgxGalleryImage, + NgxGalleryModule, + NgxGalleryOptions, +} from '@kolkov/ngx-gallery'; +import { Observable } from 'rxjs'; + +import { AuthService } from '../../../../../core/auth/auth.service'; +import { MediaViewerItem } from '../../../../../core/shared/media-viewer-item.model'; + +/** + * This componenet render an image gallery for the image viewer + */ +@Component({ + selector: 'ds-secure-media-viewer-image', + templateUrl: './secure-media-viewer-image.component.html', + styleUrls: ['./secure-media-viewer-image.component.scss'], + imports: [ + NgxGalleryModule, + AsyncPipe, + ], + standalone: true, +}) +export class SecureMediaViewerImageComponent implements OnChanges, OnInit { + @Input() images: MediaViewerItem[]; + @Input() preview?: boolean; + @Input() image?: string; + @Input() accessToken: string; + + thumbnailPlaceholder = './assets/images/replacement_image.svg'; + + galleryOptions: NgxGalleryOptions[] = []; + + galleryImages: NgxGalleryImage[] = []; + + /** + * Whether or not the current user is authenticated + */ + isAuthenticated$: Observable; + + constructor( + protected authService: AuthService, + ) { + } + + ngOnChanges(): void { + this.galleryOptions = [ + { + preview: this.preview !== undefined ? this.preview : true, + image: true, + imageSize: 'contain', + thumbnails: false, + imageArrows: false, + startIndex: 0, + imageAnimation: NgxGalleryAnimation.Slide, + previewCloseOnEsc: true, + previewZoom: true, + previewRotate: true, + previewFullscreen: true, + }, + ]; + if (this.image) { + this.galleryImages = [ + { + small: this.image, + medium: this.image, + big: this.image, + }, + ]; + } else { + this.galleryImages = this.convertToGalleryImage(this.images); + } + } + + ngOnInit(): void { + this.isAuthenticated$ = this.authService.isAuthenticated(); + this.ngOnChanges(); + } + + /** + * This method convert an array of MediaViewerItem into NgxGalleryImage array + * @param medias input NgxGalleryImage array + */ + convertToGalleryImage(medias: MediaViewerItem[]): NgxGalleryImage[] { + const mappedImages = []; + for (const image of medias) { + if (image.format === 'image') { + mappedImages.push({ + small: image.thumbnail + ? image.thumbnail + : this.thumbnailPlaceholder, + medium: image.thumbnail + ? image.thumbnail + : this.thumbnailPlaceholder, + big: image.bitstream._links.content.href + '?accessToken=' + this.accessToken, + }); + } + } + return mappedImages; + } +} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.html b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.html new file mode 100644 index 0000000000..8a6a447d9e --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.html @@ -0,0 +1,54 @@ + +
+ + + +
+ +
+ +
+
+
diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.scss b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.scss new file mode 100644 index 0000000000..bb8b9d360e --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.scss @@ -0,0 +1,10 @@ +video { + width: 100%; + height: auto; + max-width: 340px; +} + +.buttons { + display: flex; + gap: .25rem; +} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.spec.ts b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.spec.ts new file mode 100644 index 0000000000..1e3282641c --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.spec.ts @@ -0,0 +1,149 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { MediaViewerItem } from '../../../../../core/shared/media-viewer-item.model'; +import { MetadataFieldWrapperComponent } from '../../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { MockBitstreamFormat1 } from '../../../../../shared/mocks/item.mock'; +import { TranslateLoaderMock } from '../../../../../shared/mocks/translate-loader.mock'; +import { FileSizePipe } from '../../../../../shared/utils/file-size-pipe'; +import { VarDirective } from '../../../../../shared/utils/var.directive'; +import { SecureMediaViewerVideoComponent } from './secure-media-viewer-video.component'; + +describe('SecureMediaViewerVideoComponent', () => { + let component: SecureMediaViewerVideoComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, + SecureMediaViewerVideoComponent, + VarDirective, + FileSizePipe, + MetadataFieldWrapperComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10201, + content: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', + }, + content: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + }, + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx', + }, + ], + }, + }); + + const mockMediaViewerItems: MediaViewerItem[] = Object.assign( + new Array(), + [ + { bitstream: mockBitstream, format: 'video', thumbnail: null }, + { bitstream: mockBitstream, format: 'video', thumbnail: null }, + ], + ); + const mockMediaViewerItem: MediaViewerItem[] = Object.assign( + new Array(), + [{ bitstream: mockBitstream, format: 'video', thumbnail: null }], + ); + + beforeEach(() => { + fixture = TestBed.createComponent(SecureMediaViewerVideoComponent); + component = fixture.componentInstance; + component.medias = mockMediaViewerItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('should show controller buttons when the having mode then one video', () => { + beforeEach(() => { + component.medias = mockMediaViewerItems; + fixture.detectChanges(); + }); + + it('should show buttons', () => { + const controllerButtons = fixture.debugElement.query(By.css('.buttons')); + expect(controllerButtons).toBeTruthy(); + }); + + describe('when the "Next" button is clicked', () => { + beforeEach(() => { + component.currentIndex = 0; + fixture.detectChanges(); + }); + + it('should increase the index', () => { + const viewMore = fixture.debugElement.query(By.css('.next')); + viewMore.triggerEventHandler('click', null); + expect(component.currentIndex).toBe(1); + }); + }); + + describe('when the "Previous" button is clicked', () => { + beforeEach(() => { + component.currentIndex = 1; + fixture.detectChanges(); + }); + + it('should decrease the index', () => { + const viewMore = fixture.debugElement.query(By.css('.previous')); + viewMore.triggerEventHandler('click', null); + expect(component.currentIndex).toBe(0); + }); + }); + + describe('when the "Playlist element" button is clicked', () => { + beforeEach(() => { + component.isCollapsed = true; + fixture.detectChanges(); + }); + + it('should set the the index with the selected one', () => { + const viewMore = fixture.debugElement.query(By.css('.list-element')); + viewMore.triggerEventHandler('click', null); + expect(component.currentIndex).toBe(0); + }); + }); + }); +}); diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.ts b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.ts new file mode 100644 index 0000000000..75f597c8a2 --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.ts @@ -0,0 +1,103 @@ +import { + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { Bitstream } from 'src/app/core/shared/bitstream.model'; + +import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; +import { MediaViewerItem } from '../../../../../core/shared/media-viewer-item.model'; +import { BtnDisabledDirective } from '../../../../../shared/btn-disabled.directive'; +import { CaptionInfo } from '../../../../media-viewer/media-viewer-video/caption-info'; +import { languageHelper } from '../../../../media-viewer/media-viewer-video/language-helper'; + +/** + * This component renders a video viewer and playlist for the media viewer + */ +@Component({ + selector: 'ds-secure-media-viewer-video', + templateUrl: './secure-media-viewer-video.component.html', + styleUrls: ['./secure-media-viewer-video.component.scss'], + imports: [ + NgForOf, + NgbDropdownModule, + TranslateModule, + NgIf, + BtnDisabledDirective, + ], + standalone: true, +}) +export class SecureMediaViewerVideoComponent { + @Input() medias: MediaViewerItem[]; + + @Input() captions: Bitstream[] = []; + + @Input() accessToken: string; + + isCollapsed = false; + + currentIndex = 0; + + replacements = { + video: './assets/images/replacement_video.svg', + audio: './assets/images/replacement_audio.svg', + }; + + constructor( + public dsoNameService: DSONameService, + ) { + } + + /** + * This method check if there is caption file for the media + * The caption file name is the media name plus "-" following two letter + * language code and .vtt suffix + * + * html5 video only support WEBVTT format + * + * Two letter language code reference + * https://www.w3schools.com/tags/ref_language_codes.asp + */ + getMediaCap(name: string, captions: Bitstream[]): CaptionInfo[] { + const capInfos: CaptionInfo[] = []; + const filteredCapMedias: Bitstream[] = captions + .filter((media: Bitstream) => media.name.substring(0, (media.name.length - 7)).toLowerCase() === name.toLowerCase()); + + for (const media of filteredCapMedias) { + const srclang: string = media.name.slice(-6, -4).toLowerCase(); + capInfos.push(new CaptionInfo( + media._links.content.href + '?accessToken=' + this.accessToken, + srclang, + languageHelper[srclang], + )); + } + return capInfos; + } + + /** + * This method sets the received index into currentIndex + * @param index Selected index + */ + selectedMedia(index: number) { + this.currentIndex = index; + } + + /** + * This method increases the number of the currentIndex + */ + nextMedia() { + this.currentIndex++; + } + + /** + * This method decreases the number of the currentIndex + */ + prevMedia() { + this.currentIndex--; + } +} From f3bb7327dc03cb6be1bbdf91a6ab6dfa3d20de89 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 14:57:15 +0100 Subject: [PATCH 02/25] Request-a-copy improv: Secure file section and download links --- ...m-secure-file-download-link.component.html | 12 + ...m-secure-file-download-link.component.scss | 0 ...ecure-file-download-link.component.spec.ts | 343 ++++++++++++++++++ ...tem-secure-file-download-link.component.ts | 146 ++++++++ .../item-secure-file-section.component.html | 87 +++++ .../item-secure-file-section.component.scss | 5 + ...item-secure-file-section.component.spec.ts | 96 +++++ .../item-secure-file-section.component.ts | 156 ++++++++ 8 files changed, 845 insertions(+) create mode 100644 src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html create mode 100644 src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.scss create mode 100644 src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.spec.ts create mode 100644 src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts create mode 100644 src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.html create mode 100644 src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.scss create mode 100644 src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.spec.ts create mode 100644 src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.ts diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html new file mode 100644 index 0000000000..476b3f356b --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.scss b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.spec.ts b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.spec.ts new file mode 100644 index 0000000000..dffdd007c0 --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.spec.ts @@ -0,0 +1,343 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { getBitstreamModuleRoute } from '../../../../app-routing-paths'; +import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; +import { ItemRequestDataService } from '../../../../core/data/item-request-data.service'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemRequest } from '../../../../core/shared/item-request.model'; +import { URLCombiner } from '../../../../core/url-combiner/url-combiner'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { RouterLinkDirectiveStub } from '../../../../shared/testing/router-link-directive.stub'; +import { getItemModuleRoute } from '../../../item-page-routing-paths'; +import { ItemSecureFileDownloadLinkComponent } from './item-secure-file-download-link.component'; + +describe('FileDownloadLinkComponent', () => { + let component: ItemSecureFileDownloadLinkComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + let itemRequestDataService: ItemRequestDataService; + let bitstream: Bitstream; + let item: Item; + let itemRequest: ItemRequest; + let routeStub: any; + + function init() { + itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { + canDownload: observableOf(true), + }); + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUuid', + _links: { + self: { href: 'obj-selflink' }, + }, + }); + item = Object.assign(new Item(), { + uuid: 'itemUuid', + _links: { + self: { href: 'obj-selflink' }, + }, + }); + routeStub = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject(item), + }), + children: [], + }; + + itemRequest = Object.assign(new ItemRequest(), + { + accessToken: 'accessToken', + itemId: item.uuid, + bitstreamId: bitstream.uuid, + allfiles: false, + requestEmail: 'user@name.org', + requestName: 'User Name', + requestMessage: 'I would like to request a copy', + }); + } + + function initTestbed() { + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), ItemSecureFileDownloadLinkComponent, + RouterLinkDirectiveStub, + ], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: RouterLinkDirectiveStub }, + { provide: ItemRequestDataService, useValue: itemRequestDataService }, + ], + }) .compileComponents(); + } + + describe('when the user has download rights AND a valid item access token', () => { + /** + * We expect the normal download link to be rendered, whether or not there is a valid item request or request a copy feature + * available, since the user already has the right to download this file + */ + beforeEach(waitForAsync(() => { + init(); + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true), + }); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + component.itemRequest = itemRequest; + component.enableRequestACopy = true; + fixture.detectChanges(); + }); + it('should init the component', () => { + expect(component).toBeTruthy(); + }); + it('canDownload$ should return true', () => { + component.canDownload$.subscribe((canDownload) => { + expect(canDownload).toBe(true); + }); + }); + it('canDownloadWithToken$ should return true', () => { + component.canDownloadWithToken$.subscribe((canDownloadWithToken) => { + expect(canDownloadWithToken).toBe(true); + }); + }); + it('canRequestACopy$ should return true', () => { + component.canRequestACopy$.subscribe((canRequestACopy) => { + expect(canRequestACopy).toBe(true); + }); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + component.bitstreamPath$.subscribe((bitstreamPath) => { + expect(bitstreamPath).toEqual({ + routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), + queryParams: {}, + }); + }); + }); + }); + + describe('when the user has download rights but no valid item access token', () => { + /** + * We expect the normal download link to be rendered, whether or not there is a valid item request or request a copy feature + * available, since the user already has the right to download this file + */ + beforeEach(waitForAsync(() => { + init(); + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true), + }); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + component.itemRequest = null; + component.enableRequestACopy = true; + fixture.detectChanges(); + }); + it('should init the component', () => { + expect(component).toBeTruthy(); + }); + it('canDownload$ should return true', () => { + component.canDownload$.subscribe((canDownload) => { + expect(canDownload).toBe(true); + }); + }); + it('canDownloadWithToken$ should return false', () => { + component.canDownloadWithToken$.subscribe((canDownloadWithToken) => { + expect(canDownloadWithToken).toBe(false); + }); + }); + it('canRequestACopy$ should return true', () => { + component.canRequestACopy$.subscribe((canRequestACopy) => { + expect(canRequestACopy).toBe(true); + }); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + component.bitstreamPath$.subscribe((bitstreamPath) => { + expect(bitstreamPath).toEqual({ + routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), + queryParams: {}, + }); + }); + }); + }); + + describe('when the user has no download rights but there is a valid access token', () => { + /** + * We expect the download-with-token link to be rendered, since we have a valid request but no normal download rights + */ + beforeEach(waitForAsync(() => { + init(); + authorizationService = { + isAuthorized: (featureId: FeatureID) => { + if (featureId === FeatureID.CanDownload) { + return observableOf(false); + } + return observableOf(true); + }, + } as AuthorizationDataService; + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + component.itemRequest = itemRequest; + component.enableRequestACopy = true; + fixture.detectChanges(); + }); + it('should init the component', () => { + expect(component).toBeTruthy(); + }); + it('canDownload$ should return false', () => { + component.canDownload$.subscribe((canDownload) => { + expect(canDownload).toBe(false); + }); + }); + it('canDownloadWithToken$ should return true', () => { + component.canDownloadWithToken$.subscribe((canDownloadWithToken) => { + expect(canDownloadWithToken).toBe(true); + }); + }); + it('canRequestACopy$ should return true', () => { + component.canRequestACopy$.subscribe((canRequestACopy) => { + expect(canRequestACopy).toBe(true); + }); + }); + it('should return the access token path based on the input bitstream', () => { + component.bitstreamPath$.subscribe((accessTokenPath) => { + expect(accessTokenPath).toEqual({ + routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), + queryParams: { + accessToken: itemRequest.accessToken, + }, + }); + }); + }); + }); + + describe('when the user has no download rights but has the right to request a copy and there is no valid access token', () => { + /** + * We expect the request-a-copy link to be rendered instead of the normal download link or download-by-token link + */ + beforeEach(waitForAsync(() => { + init(); + authorizationService = { + isAuthorized: (featureId: FeatureID) => { + if (featureId === FeatureID.CanDownload) { + return observableOf(false); + } + return observableOf(true); + }, + } as AuthorizationDataService; + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); + component = fixture.componentInstance; + component.item = item; + component.bitstream = bitstream; + component.itemRequest = null; + component.enableRequestACopy = true; + fixture.detectChanges(); + }); + it('should init the component', () => { + expect(component).toBeTruthy(); + }); + it('canDownload should be false', () => { + component.canDownload$.subscribe((canDownload) => { + expect(canDownload).toBeFalse(); + }); + }); + it('canDownloadWithToken should be false', () => { + component.canDownloadWithToken$.subscribe((canDownload) => { + expect(canDownload).toBeFalse(); + }); + }); + it('canRequestACopy should be true', () => { + component.canRequestACopy$.subscribe((canRequestACopy) => { + expect(canRequestACopy).toBeTrue(); + }); + }); + it('should return the bitstreamPath based a request-a-copy item + bitstream ID link', () => { + component.bitstreamPath$.subscribe((bitstreamPath) => { + expect(bitstreamPath).toEqual({ + routerLink: new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString(), + queryParams: { bitstream: bitstream.uuid }, + }); + }); + }); + + }); + + describe('when the user has no download rights and no request a copy rights and there is no valid itemRequest', () => { + /** + * We expect a normal download link (which would then be treated as a forbidden and redirect to the login page as per normal) + */ + beforeEach(waitForAsync(() => { + init(); + // This mock will return false for both canDownload and canRequestACopy checks + authorizationService = { + isAuthorized: (featureId: FeatureID) => { + return observableOf(false); + }, + } as AuthorizationDataService; + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + component.itemRequest = null; + component.enableRequestACopy = false; + fixture.detectChanges(); + }); + it('should init the component', () => { + expect(component).toBeTruthy(); + }); + it('canDownload$ should be false', () => { + component.canDownload$.subscribe((canDownload) => { + expect(canDownload).toBeFalse(); + }); + }); + it('canDownloadWithToken$ should be false', () => { + component.canDownloadWithToken$.subscribe((canDownloadWithToken) => { + expect(canDownloadWithToken).toBeFalse(); + }); + }); + it('canRequestACopy$ should be false', () => { + component.canRequestACopy$.subscribe((canRequestACopy) => { + expect(canRequestACopy).toBeFalse(); + }); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + component.bitstreamPath$.subscribe((bitstreamPath) => { + expect(bitstreamPath).toEqual({ + routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), + queryParams: {}, + }); + }); + }); + }); +}); + diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts new file mode 100644 index 0000000000..62b432e68a --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts @@ -0,0 +1,146 @@ +import { + AsyncPipe, + NgClass, + NgIf, + NgTemplateOutlet, +} from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + getBitstreamDownloadRoute, + getBitstreamDownloadWithAccessTokenRoute, + getBitstreamRequestACopyRoute, +} from '../../../../app-routing-paths'; +import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemRequest } from '../../../../core/shared/item-request.model'; +import { + hasValue, + isNotEmpty, +} from '../../../../shared/empty.util'; + +@Component({ + selector: 'ds-item-secure-file-download-link', + templateUrl: './item-secure-file-download-link.component.html', + styleUrls: ['./item-secure-file-download-link.component.scss'], + standalone: true, + imports: [ + RouterLink, NgClass, NgIf, NgTemplateOutlet, AsyncPipe, TranslateModule, + ], +}) +/** + * Component displaying a download link + * When the user is authenticated, a short-lived token retrieved from the REST API is added to the download link, + * ensuring the user is authorized to download the file. + */ +export class ItemSecureFileDownloadLinkComponent implements OnInit { + + /** + * Optional bitstream instead of href and file name + */ + @Input() bitstream: Bitstream; + + @Input() item: Item; + + /** + * Additional css classes to apply to link + */ + @Input() cssClasses = ''; + + /** + * A boolean representing if link is shown in same tab or in a new one. + */ + @Input() isBlank = false; + + @Input() itemRequest: ItemRequest; + + @Input() enableRequestACopy = true; + + bitstreamPath$: Observable<{ + routerLink: string, + queryParams: any, + }>; + + // authorized to download normally + canDownload$: Observable; + // authorized to download with token + canDownloadWithToken$: Observable; + // authorized to request a copy + canRequestACopy$: Observable; + + constructor( + private authorizationService: AuthorizationDataService, + ) { + } + + /** + * Initialise component observables to test access rights to a normal bitstream download, a valid token download + * (for a given bitstream), and ability to request a copy of a bitstream. + */ + ngOnInit() { + this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + this.canDownloadWithToken$ = observableOf(this.itemRequest ? (this.itemRequest.allfiles !== false || this.itemRequest.bitstreamId === this.bitstream.uuid) : false); + this.canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + + this.bitstreamPath$ = observableCombineLatest([this.canDownload$, this.canDownloadWithToken$, this.canRequestACopy$]).pipe( + map(([canDownload, canDownloadWithToken, canRequestACopy]) => this.getBitstreamPath(canDownload, canDownloadWithToken, canRequestACopy)), + ); + } + + /** + * Return a path to the bitstream based on what kind of access and authorization the user has, and whether + * they may request a copy + * + * @param canDownload user can download normally + * @param canDownloadWithToken user can download using a token granted by a request approver + * @param canRequestACopy user can request approval to access a copy + */ + getBitstreamPath(canDownload: boolean, canDownloadWithToken, canRequestACopy: boolean) { + // No matter what, if the user can download with their own authZ, allow it + if (canDownload) { + return this.getBitstreamDownloadPath(); + } + // Otherwise, if they access token is valid, use this + if (canDownloadWithToken) { + return this.getAccessByTokenBitstreamPath(this.itemRequest); + } + // If the user can't download, but can request a copy, show the request a copy link + if (!canDownload && canRequestACopy && hasValue(this.item)) { + return getBitstreamRequestACopyRoute(this.item, this.bitstream); + } + // By default, return the plain path + return this.getBitstreamDownloadPath(); + } + + /** + * Resolve special bitstream path which includes access token parameter + * @param itemRequest the item request object + */ + getAccessByTokenBitstreamPath(itemRequest: ItemRequest) { + return getBitstreamDownloadWithAccessTokenRoute(this.bitstream, itemRequest.accessToken); + } + + /** + * Get normal bitstream download path, with no parameters + */ + getBitstreamDownloadPath() { + return { + routerLink: getBitstreamDownloadRoute(this.bitstream), + queryParams: {}, + }; + } +} diff --git a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.html b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.html new file mode 100644 index 0000000000..4792f2657d --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.html @@ -0,0 +1,87 @@ + +
+
+

{{"item.page.filesection.original.bundle" | translate}}

+ + + +
+
+ +
+
+
+
{{"item.page.filesection.name" | translate}}
+
{{ dsoNameService.getName(file) }}
+ +
{{"item.page.filesection.size" | translate}}
+
{{(file.sizeBytes) | dsFileSize }}
+ + +
{{"item.page.filesection.format" | translate}}
+
{{(file.format | async)?.payload?.description}}
+ + +
{{"item.page.filesection.description" | translate}}
+
{{file.firstMetadataValue("dc.description")}}
+
+
+
+
+ + + + + {{"item.page.filesection.download" | translate}} + +
+
+
+
+
+
+
+

{{"item.page.filesection.license.bundle" | translate}}

+ + + +
+
+ +
+
+
+
{{"item.page.filesection.name" | translate}}
+
{{ dsoNameService.getName(file) }}
+ +
{{"item.page.filesection.size" | translate}}
+
{{(file.sizeBytes) | dsFileSize }}
+ +
{{"item.page.filesection.format" | translate}}
+
{{(file.format | async)?.payload?.description}}
+ + +
{{"item.page.filesection.description" | translate}}
+
{{file.firstMetadataValue("dc.description")}}
+
+
+
+ + {{"item.page.filesection.download" | translate}} + +
+
+
+
+
+
diff --git a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.scss b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.scss new file mode 100644 index 0000000000..5384f90cec --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.scss @@ -0,0 +1,5 @@ +@media screen and (min-width: map-get($grid-breakpoints, md)) { + dt { + text-align: right; + } +} diff --git a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.spec.ts b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.spec.ts new file mode 100644 index 0000000000..557eb06b86 --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.spec.ts @@ -0,0 +1,96 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideMockStore } from '@ngrx/store/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { APP_CONFIG } from 'src/config/app-config.interface'; +import { environment } from 'src/environments/environment'; + +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; +import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { MockBitstreamFormat1 } from '../../../../shared/mocks/item.mock'; +import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { FileSizePipe } from '../../../../shared/utils/file-size-pipe'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; +import { ItemSecureFileDownloadLinkComponent } from '../file-download-link/item-secure-file-download-link.component'; +import { ItemSecureFileSectionComponent } from './item-secure-file-section.component'; + +describe('FullFileSectionComponent', () => { + let comp: ItemSecureFileSectionComponent; + let fixture: ComponentFixture; + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10201, + content: 'test-content-url', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + id: 'test-id', + _links: { + self: { href: 'test-href' }, + content: { href: 'test-content-href' }, + }, + }); + + const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([mockBitstream, mockBitstream, mockBitstream])), + }); + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, + ItemSecureFileSectionComponent, + VarDirective, + FileSizePipe, + MetadataFieldWrapperComponent, + ], + providers: [ + provideMockStore(), + { provide: BitstreamDataService, useValue: bitstreamDataService }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: PaginationService, useValue: new PaginationServiceStub() }, + { provide: APP_CONFIG, useValue: environment }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(ItemSecureFileSectionComponent, { + remove: { imports: [PaginationComponent, MetadataFieldWrapperComponent,ItemSecureFileDownloadLinkComponent, ThemedThumbnailComponent, ThemedFileDownloadLinkComponent] }, + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemSecureFileSectionComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('when the full file section gets loaded with bitstreams available', () => { + it('should contain a list with bitstreams', () => { + const fileSection = fixture.debugElement.queryAll(By.css('.file-section')); + expect(fileSection.length).toEqual(6); + }); + }); +}); diff --git a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.ts b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.ts new file mode 100644 index 0000000000..de1602a7f7 --- /dev/null +++ b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.ts @@ -0,0 +1,156 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + Inject, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { + switchMap, + tap, +} from 'rxjs/operators'; +import { + APP_CONFIG, + AppConfig, +} from 'src/config/app-config.interface'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemRequest } from '../../../../core/shared/item-request.model'; +import { + hasValue, + isEmpty, +} from '../../../../shared/empty.util'; +import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; +import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; +import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { FileSizePipe } from '../../../../shared/utils/file-size-pipe'; +import { followLink } from '../../../../shared/utils/follow-link-config.model'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; +import { FileSectionComponent } from '../../../simple/field-components/file-section/file-section.component'; +import { ItemSecureFileDownloadLinkComponent } from '../file-download-link/item-secure-file-download-link.component'; + +/** + * This component renders the file section of the item + * inside a 'ds-metadata-field-wrapper' component. + */ + +@Component({ + selector: 'ds-item-secure-full-file-section', + styleUrls: ['./item-secure-file-section.component.scss'], + templateUrl: './item-secure-file-section.component.html', + standalone: true, + imports: [ + ItemSecureFileDownloadLinkComponent, + CommonModule, + ThemedFileDownloadLinkComponent, + MetadataFieldWrapperComponent, + ThemedLoadingComponent, + TranslateModule, + FileSizePipe, + VarDirective, + PaginationComponent, + ThemedThumbnailComponent, + ], +}) +export class ItemSecureFileSectionComponent extends FileSectionComponent implements OnDestroy, OnInit { + + @Input() item: Item; + @Input() itemRequest: ItemRequest; + + label: string; + + originals$: Observable>>; + licenses$: Observable>>; + + originalOptions = Object.assign(new PaginationComponentOptions(), { + id: 'obo', + currentPage: 1, + pageSize: this.appConfig.item.bitstream.pageSize, + }); + + licenseOptions = Object.assign(new PaginationComponentOptions(), { + id: 'lbo', + currentPage: 1, + pageSize: this.appConfig.item.bitstream.pageSize, + }); + + constructor( + bitstreamDataService: BitstreamDataService, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + protected paginationService: PaginationService, + public dsoNameService: DSONameService, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + ) { + super(bitstreamDataService, notificationsService, translateService, dsoNameService, appConfig); + } + + ngOnInit(): void { + this.initialize(); + } + + initialize(): void { + this.originals$ = this.paginationService.getCurrentPagination(this.originalOptions.id, this.originalOptions).pipe( + switchMap((options: PaginationComponentOptions) => this.bitstreamDataService.findAllByItemAndBundleName( + this.item, + 'ORIGINAL', + { elementsPerPage: options.pageSize, currentPage: options.currentPage }, + true, + true, + followLink('format'), + followLink('thumbnail'), + )), + tap((rd: RemoteData>) => { + if (hasValue(rd.errorMessage)) { + this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.statusCode} ${rd.errorMessage}`); + } + }, + ), + ); + + this.licenses$ = this.paginationService.getCurrentPagination(this.licenseOptions.id, this.licenseOptions).pipe( + switchMap((options: PaginationComponentOptions) => this.bitstreamDataService.findAllByItemAndBundleName( + this.item, + 'LICENSE', + { elementsPerPage: options.pageSize, currentPage: options.currentPage }, + true, + true, + followLink('format'), + followLink('thumbnail'), + )), + tap((rd: RemoteData>) => { + if (hasValue(rd.errorMessage)) { + this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.statusCode} ${rd.errorMessage}`); + } + }, + ), + ); + + } + + hasValuesInBundle(bundle: PaginatedList) { + return hasValue(bundle) && !isEmpty(bundle.page); + } + + ngOnDestroy(): void { + this.paginationService.clearPagination(this.originalOptions.id); + this.paginationService.clearPagination(this.licenseOptions.id); + } + +} From 4281267cb617b991780e62c7824b9c8d731a9e24 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 14:58:16 +0100 Subject: [PATCH 03/25] Request-a-copy improv: Secure item view comps --- .../item-access-by-token-page.component.html | 8 + .../item-access-by-token-page.component.scss | 0 ...tem-access-by-token-page.component.spec.ts | 299 ++++++++++++++++++ .../item-access-by-token-page.component.ts | 178 +++++++++++ .../item-access-by-token-view.component.html | 101 ++++++ .../item-access-by-token-view.component.scss | 0 ...tem-access-by-token-view.component.spec.ts | 213 +++++++++++++ .../item-access-by-token-view.component.ts | 128 ++++++++ .../testing/router-link-directive.stub.ts | 1 + 9 files changed, 928 insertions(+) create mode 100644 src/app/item-page/access-by-token/item-access-by-token-page.component.html create mode 100644 src/app/item-page/access-by-token/item-access-by-token-page.component.scss create mode 100644 src/app/item-page/access-by-token/item-access-by-token-page.component.spec.ts create mode 100644 src/app/item-page/access-by-token/item-access-by-token-page.component.ts create mode 100644 src/app/item-page/access-by-token/item-access-by-token-view.component.html create mode 100644 src/app/item-page/access-by-token/item-access-by-token-view.component.scss create mode 100644 src/app/item-page/access-by-token/item-access-by-token-view.component.spec.ts create mode 100644 src/app/item-page/access-by-token/item-access-by-token-view.component.ts diff --git a/src/app/item-page/access-by-token/item-access-by-token-page.component.html b/src/app/item-page/access-by-token/item-access-by-token-page.component.html new file mode 100644 index 0000000000..a99f01858b --- /dev/null +++ b/src/app/item-page/access-by-token/item-access-by-token-page.component.html @@ -0,0 +1,8 @@ +
+
+ + +
+
diff --git a/src/app/item-page/access-by-token/item-access-by-token-page.component.scss b/src/app/item-page/access-by-token/item-access-by-token-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/access-by-token/item-access-by-token-page.component.spec.ts b/src/app/item-page/access-by-token/item-access-by-token-page.component.spec.ts new file mode 100644 index 0000000000..e270b34669 --- /dev/null +++ b/src/app/item-page/access-by-token/item-access-by-token-page.component.spec.ts @@ -0,0 +1,299 @@ +import { KeyValuePipe } from '@angular/common'; +import { PLATFORM_ID } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + of as observableOf, +} from 'rxjs'; + +import { getForbiddenRoute } from '../../app-routing-paths'; +import { AuthService } from '../../core/auth/auth.service'; +import { NotifyInfoService } from '../../core/coar-notify/notify-info/notify-info.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { Item } from '../../core/shared/item.model'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { ItemAccessByTokenPageComponent } from './item-access-by-token-page.component'; +import { ItemAccessByTokenViewComponent } from './item-access-by-token-view.component'; + +describe('ItemAccessByTokenPageComponent', () => { + let component: ItemAccessByTokenPageComponent; + let fixture: ComponentFixture; + let itemRequestService: jasmine.SpyObj; + let router: jasmine.SpyObj; + let authorizationService: AuthorizationDataService; + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(false), + }); + let signpostingDataService: SignpostingDataService; + + const mocklink = { + href: 'http://test.org', + rel: 'test', + type: 'test', + }; + + const mocklink2 = { + href: 'http://test2.org', + rel: 'test', + type: 'test', + }; + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: observableOf([mocklink, mocklink2]), + }); + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '', + }); + + const mockItem = Object.assign(new Item(), { + uuid: 'test-item-uuid', + id: 'test-item-id', + metadata: { + 'dspace.entity.type': [{ + value: 'Publication', + language: 'en', + place: 0, + authority: null, + confidence: -1, + }], + }, + _links: { + self: { href: 'obj-selflink' }, + }, + }); + + const mockItemRequest = Object.assign(new ItemRequest(), { + token: 'valid-token', + accessToken: 'valid-token', + itemId: mockItem.uuid, + }); + + const queryParams = { accessToken: 'valid-token' }; + const mockActivatedRoute = { + queryParams: new BehaviorSubject(queryParams), + data: observableOf({ + dso: createSuccessfulRemoteDataObject(mockItem), + }), + params: observableOf({ itemId: mockItem.uuid, queryParams: [ { accessToken: 'valid-token' } ] }), + children: [], + }; + itemRequestService = jasmine.createSpyObj('ItemRequestDataService', { + getSanitizedRequestByAccessToken: observableOf(createSuccessfulRemoteDataObject(mockItemRequest)), + }); + router = jasmine.createSpyObj('Router', ['navigateByUrl'], { + events: observableOf([]), + }); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + KeyValuePipe, + ], + providers: [ + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: ItemDataService, useValue: {} }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ServerResponseService, useValue: {} }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: NotifyInfoService, useValue: { isCoarConfigEnabled: () => observableOf(false) } }, + { provide: PLATFORM_ID, useValue: 'browser' }, + KeyValuePipe, + { + provide: Store, + useValue: { + pipe: () => observableOf({}), + dispatch: () => { + }, + select: () => observableOf({}), + }, + }, + { + provide: AuthService, useValue: { + isAuthenticated: () => observableOf(true), + }, + }, + ], + }).overrideComponent(ItemAccessByTokenPageComponent, { + set: { + template: '
', + }, + }).overrideComponent(ItemAccessByTokenViewComponent, { + set: { + template: '
', + }, + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemAccessByTokenPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + }); + + /** + * Tests in this component are concerned only with successful access token processing (or error handling) + * and a resulting item request object. Testing of template elements is out of scope and left for child components. + */ + describe('ngOnInit - basic component testing', () => { + it('should find valid access token and sanitize it', fakeAsync(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + KeyValuePipe, + RouterTestingModule.withRoutes([]), + ], + providers: [ + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: AuthService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ServerResponseService, useValue: {} }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: NotifyInfoService, useValue: { isCoarConfigEnabled: () => observableOf(false) } }, + { provide: PLATFORM_ID, useValue: 'browser' }, + KeyValuePipe, + { + provide: Store, + useValue: { + pipe: () => observableOf({}), + dispatch: () => {}, + select: () => observableOf({}), + }, + }, + { provide: AuthService, useValue: { + isAuthenticated: () => observableOf(false ) }, + }, + ], + }).overrideComponent(ItemAccessByTokenViewComponent, { + set: { template: '
' } } ).compileComponents(); + + fixture = TestBed.createComponent(ItemAccessByTokenPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(itemRequestService.getSanitizedRequestByAccessToken).toHaveBeenCalledWith('valid-token'); + + })); + + it('should process valid access token and load item request', fakeAsync(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + KeyValuePipe, + RouterTestingModule.withRoutes([]), + ], + providers: [ + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: AuthService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ServerResponseService, useValue: {} }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: NotifyInfoService, useValue: { isCoarConfigEnabled: () => observableOf(false) } }, + { provide: PLATFORM_ID, useValue: 'browser' }, + KeyValuePipe, + { + provide: Store, + useValue: { + pipe: () => observableOf({}), + dispatch: () => {}, + select: () => observableOf({}), + }, + }, + { provide: AuthService, useValue: { + isAuthenticated: () => observableOf(false ) }, + }, + ], + }).overrideComponent(ItemAccessByTokenViewComponent, { + set: { template: '
' } } ).compileComponents(); + + fixture = TestBed.createComponent(ItemAccessByTokenPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.itemRequest$.subscribe((request) => { + expect(request).toBeTruthy(); + }); + })); + + it('should redirect to forbidden route when access token is missing', fakeAsync(() => { + const routeWithoutToken = { + queryParams: observableOf({}), + data: observableOf({ + dso: createSuccessfulRemoteDataObject(mockItem), + }), + params: observableOf({ itemId: mockItem.uuid }), + children: [], + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + KeyValuePipe, + ], + providers: [ + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: routeWithoutToken }, + { provide: AuthService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ServerResponseService, useValue: {} }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: NotifyInfoService, useValue: { isCoarConfigEnabled: () => observableOf(false) } }, + { provide: PLATFORM_ID, useValue: 'browser' }, + { + provide: Store, + useValue: { + pipe: () => observableOf({}), + dispatch: () => {}, + select: () => observableOf({}), + }, + }, + { provide: AuthService, useValue: { + isAuthenticated: () => observableOf(false ) }, + }, + ], + }).overrideComponent(ItemAccessByTokenViewComponent, { + set: { + template: '
', + } }) + .compileComponents(); + + fixture = TestBed.createComponent(ItemAccessByTokenPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: false }); + })); + }); +}); + diff --git a/src/app/item-page/access-by-token/item-access-by-token-page.component.ts b/src/app/item-page/access-by-token/item-access-by-token-page.component.ts new file mode 100644 index 0000000000..5250ac37a2 --- /dev/null +++ b/src/app/item-page/access-by-token/item-access-by-token-page.component.ts @@ -0,0 +1,178 @@ +import { + AsyncPipe, + KeyValuePipe, + Location, + NgForOf, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + OnDestroy, + OnInit, + PLATFORM_ID, +} from '@angular/core'; +import { + ActivatedRoute, + Router, + RouterLink, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { getForbiddenRoute } from '../../app-routing-paths'; +import { AuthService } from '../../core/auth/auth.service'; +import { NotifyInfoService } from '../../core/coar-notify/notify-info/notify-info.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { redirectOn4xx } from '../../core/shared/authorized.operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../../core/shared/operators'; +import { fadeInOut } from '../../shared/animations/fade'; +import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { hasValue } from '../../shared/empty.util'; +import { ErrorComponent } from '../../shared/error/error.component'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { MetadataFieldWrapperComponent } from '../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { ThemedResultsBackButtonComponent } from '../../shared/results-back-button/themed-results-back-button.component'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { ViewTrackerComponent } from '../../statistics/angulartics/dspace/view-tracker.component'; +import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component'; +import { ThemedItemAlertsComponent } from '../alerts/themed-item-alerts.component'; +import { CollectionsComponent } from '../field-components/collections/collections.component'; +import { ThemedFullFileSectionComponent } from '../full/field-components/file-section/themed-full-file-section.component'; +import { ThemedMediaViewerComponent } from '../media-viewer/themed-media-viewer.component'; +import { MiradorViewerComponent } from '../mirador-viewer/mirador-viewer.component'; +import { ThemedFileSectionComponent } from '../simple/field-components/file-section/themed-file-section.component'; +import { ItemPageAbstractFieldComponent } from '../simple/field-components/specific-field/abstract/item-page-abstract-field.component'; +import { ItemPageDateFieldComponent } from '../simple/field-components/specific-field/date/item-page-date-field.component'; +import { GenericItemPageFieldComponent } from '../simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ThemedItemPageTitleFieldComponent } from '../simple/field-components/specific-field/title/themed-item-page-field.component'; +import { ItemPageUriFieldComponent } from '../simple/field-components/specific-field/uri/item-page-uri-field.component'; +import { ItemPageComponent } from '../simple/item-page.component'; +import { ThemedMetadataRepresentationListComponent } from '../simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { ItemVersionsComponent } from '../versions/item-versions.component'; +import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; +import { ItemSecureFileSectionComponent } from './field-components/file-section/item-secure-file-section.component'; +import { ItemAccessByTokenViewComponent } from './item-access-by-token-view.component'; + +@Component({ + selector: 'ds-access-by-token-item-page', + styleUrls: ['./item-access-by-token-page.component.scss'], + templateUrl: './item-access-by-token-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [fadeInOut], + standalone: true, + imports: [ + ErrorComponent, + ThemedLoadingComponent, + TranslateModule, + ThemedFullFileSectionComponent, + CollectionsComponent, + ItemVersionsComponent, + NgIf, + NgForOf, + AsyncPipe, + KeyValuePipe, + RouterLink, + ThemedItemPageTitleFieldComponent, + DsoEditMenuComponent, + ItemVersionsNoticeComponent, + ViewTrackerComponent, + ThemedItemAlertsComponent, + VarDirective, + ItemSecureFileSectionComponent, + GenericItemPageFieldComponent, + ItemPageAbstractFieldComponent, + ItemPageDateFieldComponent, + ItemPageUriFieldComponent, + MetadataFieldWrapperComponent, + MiradorViewerComponent, + ThemedFileSectionComponent, + ThemedMediaViewerComponent, + ThemedMetadataRepresentationListComponent, + ThemedResultsBackButtonComponent, + ThemedThumbnailComponent, + ItemAccessByTokenViewComponent, + ], +}) +export class ItemAccessByTokenPageComponent extends ItemPageComponent implements OnInit, OnDestroy { + + itemRequest$: Observable; + + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected items: ItemDataService, + protected authService: AuthService, + protected authorizationService: AuthorizationDataService, + protected _location: Location, + protected responseService: ServerResponseService, + protected signpostingDataService: SignpostingDataService, + protected linkHeadService: LinkHeadService, + protected notifyInfoService: NotifyInfoService, + private itemRequestDataService: ItemRequestDataService, + @Inject(PLATFORM_ID) protected platformId: string, + ) { + super(route, router, items, authorizationService, responseService, signpostingDataService, linkHeadService, notifyInfoService, platformId); + } + + protected readonly hasValue = hasValue; + + /** + * Initialise this component + * 1. take the access token from the query params and complete the stream + * 2. test for access token or redirect to forbidden page + * 3. get the sanitized token, make sure it is valid (if not, redirect to forbidden page) + * 4. return observable to itemRequest$ for the view to subscribe to + */ + ngOnInit(): void { + this.itemRequest$ = this.route.queryParams.pipe( + take(1), + map(params => { + if (!hasValue(params?.accessToken)) { + this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: false }); + return null; + } + return params.accessToken; + }), + filter(token => hasValue(token)), + switchMap(token => this.itemRequestDataService.getSanitizedRequestByAccessToken(token)), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + getFirstSucceededRemoteDataPayload(), + tap(request => { + if (!hasValue(request)) { + this.router.navigateByUrl(getForbiddenRoute()); + } + }), + ); + + // Call item page component initialization. + super.ngOnInit(); + } + + /** + * Navigate back in browser history. + */ + back() { + this._location.back(); + } + +} + diff --git a/src/app/item-page/access-by-token/item-access-by-token-view.component.html b/src/app/item-page/access-by-token/item-access-by-token-view.component.html new file mode 100644 index 0000000000..047966e615 --- /dev/null +++ b/src/app/item-page/access-by-token/item-access-by-token-view.component.html @@ -0,0 +1,101 @@ + + +
+
+ + +
+
+ +
+ + + +
+
+

{{'bitstream-request-a-copy.access-by-token.warning' | translate}}

+

{{ 'bitstream-request-a-copy.access-by-token.expiry-label' | translate }} {{ getAccessPeriodEndDate() }}

+
+
+
+ + + + + +
+ + +
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
diff --git a/src/app/item-page/access-by-token/item-access-by-token-view.component.scss b/src/app/item-page/access-by-token/item-access-by-token-view.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/access-by-token/item-access-by-token-view.component.spec.ts b/src/app/item-page/access-by-token/item-access-by-token-view.component.spec.ts new file mode 100644 index 0000000000..78e4544b6c --- /dev/null +++ b/src/app/item-page/access-by-token/item-access-by-token-view.component.spec.ts @@ -0,0 +1,213 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { + of as observableOf, + of, +} from 'rxjs'; + +import { + APP_CONFIG, + APP_DATA_SERVICES_MAP, +} from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { Item } from '../../core/shared/item.model'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { ITEM_REQUEST } from '../../core/shared/item-request.resource-type'; +import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { ErrorComponent } from '../../shared/error/error.component'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { ThemedResultsBackButtonComponent } from '../../shared/results-back-button/themed-results-back-button.component'; +import { RouterLinkDirectiveStub } from '../../shared/testing/router-link-directive.stub'; +import { ViewTrackerComponent } from '../../statistics/angulartics/dspace/view-tracker.component'; +import { ThemedItemAlertsComponent } from '../alerts/themed-item-alerts.component'; +import { CollectionsComponent } from '../field-components/collections/collections.component'; +import { ThemedFullFileSectionComponent } from '../full/field-components/file-section/themed-full-file-section.component'; +import { ThemedMediaViewerComponent } from '../media-viewer/themed-media-viewer.component'; +import { MiradorViewerComponent } from '../mirador-viewer/mirador-viewer.component'; +import { ThemedFileSectionComponent } from '../simple/field-components/file-section/themed-file-section.component'; +import { ThemedMetadataRepresentationListComponent } from '../simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { ItemVersionsComponent } from '../versions/item-versions.component'; +import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; +import { ItemSecureFileDownloadLinkComponent } from './field-components/file-download-link/item-secure-file-download-link.component'; +import { ItemSecureFileSectionComponent } from './field-components/file-section/item-secure-file-section.component'; +import { ItemSecureMediaViewerComponent } from './field-components/media-viewer/item-secure-media-viewer.component'; +import { ItemAccessByTokenViewComponent } from './item-access-by-token-view.component'; + + +describe('ItemAccessByTokenViewComponent', () => { + let authorizationService: AuthorizationDataService; + let itemRequestDataService: ItemRequestDataService; + let bitstream: Bitstream; + let item: Item; + let itemRequest: ItemRequest; + let component: ItemAccessByTokenViewComponent; + let fixture: ComponentFixture; + let routeStub: any; + + function init() { + itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { + canDownload: observableOf(true), + }); + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUuid', + }); + item = Object.assign(new Item(), { + uuid: 'itemUuid', + metadata: { + 'dspace.entity.type': [ + { + value: 'Publication', + }, + ], + }, + _links: { + self: { href: 'obj-selflink' }, + }, + }); + routeStub = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject(item), + }), + children: [], + }; + + const mockItemRequest: ItemRequest = Object.assign(new ItemRequest(), { + + }); + itemRequest = Object.assign(new ItemRequest(), + { + itemId: item.uuid, + bitstreamId: bitstream.uuid, + allfiles: false, + requestEmail: 'user@name.org', + requestName: 'User Name', + requestMessage: 'I would like to request a copy', + accessPeriod: 3600, + decisionDate: new Date().toISOString(), + token: 'test-token', + type: ITEM_REQUEST, + requestDate: new Date().toISOString(), + accessToken: 'test-token', + expires: null, + acceptRequest: true, + }); + } + + function initTestbed() { + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), ItemSecureFileDownloadLinkComponent, + RouterLinkDirectiveStub, + ], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: RouterLinkDirectiveStub }, + { provide: ItemRequestDataService, useValue: itemRequestDataService }, + provideMockStore(), + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + { provide: APP_CONFIG, useValue: environment }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(ItemAccessByTokenViewComponent, { + remove: { + imports: [ + ErrorComponent, + ThemedLoadingComponent, + ThemedFullFileSectionComponent, + CollectionsComponent, + ItemVersionsComponent, + DsoEditMenuComponent, + ItemVersionsNoticeComponent, + ViewTrackerComponent, + ThemedItemAlertsComponent, + ItemSecureFileSectionComponent, + MiradorViewerComponent, + ThemedFileSectionComponent, + ThemedMediaViewerComponent, + ThemedMetadataRepresentationListComponent, + ThemedResultsBackButtonComponent, + ItemSecureMediaViewerComponent, + ], + }, + }).compileComponents(); + } + + const mockItem = Object.assign(new Item(), { + uuid: 'test-item-uuid', + id: 'test-item-id', + }); + + + + + beforeEach(waitForAsync(() => { + init(); + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true), + }); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ItemAccessByTokenViewComponent); + component = fixture.componentInstance; + component.object = item; + component.itemRequest$ = of(itemRequest); + component.itemRequestSubject.next(itemRequest); + fixture.detectChanges(); + }); + + describe('Component and inputs initialised properly', () => { + it('should initialize with valid ItemRequest input', () => { + //component.itemRequestSubject.next(itemRequest); + component.itemRequest$.subscribe(request => { + expect(request).toBeDefined(); + expect(request.accessPeriod).toBe(3600); + expect(request.token).toBe('test-token'); + expect(request.requestName).toBe('User Name'); + expect(request.requestEmail).toBe('user@name.org'); + expect(request.requestMessage).toBe('I would like to request a copy'); + expect(request.allfiles).toBe(false); + expect(request.bitstreamId).toBe(bitstream.uuid); + expect(request.acceptRequest).toBe(true); + }); + }); + }); + + describe('getAccessPeriodEndDate', () => { + it('should calculate correct end date based on decision date and access period', () => { + const testDecisionDate = '2024-01-01T00:00:00Z'; + const testAccessPeriod = 3600; + + const testRequest = { + ...itemRequest, + decisionDate: testDecisionDate, + accessPeriod: testAccessPeriod, + }; + component.itemRequest$ = of(testRequest); + component.itemRequestSubject.next(testRequest); + const expectedDate = new Date(testDecisionDate); + expectedDate.setUTCSeconds(expectedDate.getUTCSeconds() + testAccessPeriod); + + expect(component.getAccessPeriodEndDate()).toEqual(expectedDate); + }); + + it('should return undefined when access period is 0', () => { + component.itemRequestSubject.next({ ...itemRequest, accessPeriod: 0 }); + expect(component.getAccessPeriodEndDate()).toBeUndefined(); + }); + }); +}); + diff --git a/src/app/item-page/access-by-token/item-access-by-token-view.component.ts b/src/app/item-page/access-by-token/item-access-by-token-view.component.ts new file mode 100644 index 0000000000..61f7679cfc --- /dev/null +++ b/src/app/item-page/access-by-token/item-access-by-token-view.component.ts @@ -0,0 +1,128 @@ +import { + AsyncPipe, + KeyValuePipe, + NgForOf, + NgIf, +} from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { + Router, + RouterLink, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, +} from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { RouteService } from '../../core/services/route.service'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { hasValue } from '../../shared/empty.util'; +import { ErrorComponent } from '../../shared/error/error.component'; +import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { MetadataFieldWrapperComponent } from '../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; +import { ThemedResultsBackButtonComponent } from '../../shared/results-back-button/themed-results-back-button.component'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { ViewTrackerComponent } from '../../statistics/angulartics/dspace/view-tracker.component'; +import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component'; +import { ThemedItemAlertsComponent } from '../alerts/themed-item-alerts.component'; +import { CollectionsComponent } from '../field-components/collections/collections.component'; +import { ThemedFullFileSectionComponent } from '../full/field-components/file-section/themed-full-file-section.component'; +import { ThemedMediaViewerComponent } from '../media-viewer/themed-media-viewer.component'; +import { MiradorViewerComponent } from '../mirador-viewer/mirador-viewer.component'; +import { ThemedFileSectionComponent } from '../simple/field-components/file-section/themed-file-section.component'; +import { ItemPageAbstractFieldComponent } from '../simple/field-components/specific-field/abstract/item-page-abstract-field.component'; +import { ItemPageDateFieldComponent } from '../simple/field-components/specific-field/date/item-page-date-field.component'; +import { GenericItemPageFieldComponent } from '../simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ThemedItemPageTitleFieldComponent } from '../simple/field-components/specific-field/title/themed-item-page-field.component'; +import { ItemPageUriFieldComponent } from '../simple/field-components/specific-field/uri/item-page-uri-field.component'; +import { ItemComponent } from '../simple/item-types/shared/item.component'; +import { ThemedMetadataRepresentationListComponent } from '../simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { ItemVersionsComponent } from '../versions/item-versions.component'; +import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; +import { ItemSecureFileSectionComponent } from './field-components/file-section/item-secure-file-section.component'; +import { ItemSecureMediaViewerComponent } from './field-components/media-viewer/item-secure-media-viewer.component'; + +@Component({ + selector: 'ds-item-access-by-token-view', + styleUrls: ['./item-access-by-token-view.component.scss'], + templateUrl: './item-access-by-token-view.component.html', + standalone: true, + imports: [ + ErrorComponent, + ThemedLoadingComponent, + TranslateModule, + ThemedFullFileSectionComponent, + CollectionsComponent, + ItemVersionsComponent, + NgIf, + NgForOf, + AsyncPipe, + KeyValuePipe, + RouterLink, + ThemedItemPageTitleFieldComponent, + DsoEditMenuComponent, + ItemVersionsNoticeComponent, + ViewTrackerComponent, + ThemedItemAlertsComponent, + VarDirective, + ItemSecureFileSectionComponent, + GenericItemPageFieldComponent, + ItemPageAbstractFieldComponent, + ItemPageDateFieldComponent, + ItemPageUriFieldComponent, + MetadataFieldWrapperComponent, + MiradorViewerComponent, + ThemedFileSectionComponent, + ThemedMediaViewerComponent, + ThemedMetadataRepresentationListComponent, + ThemedResultsBackButtonComponent, + ThemedThumbnailComponent, + ItemSecureMediaViewerComponent, + //ItemPageTitleFieldComponent, + //ThumbnailComponent, + //MetadataRepresentationListComponent, + ], +}) +export class ItemAccessByTokenViewComponent extends ItemComponent implements OnInit { + + @Input() itemRequest$: Observable; + itemRequestSubject = new BehaviorSubject(null); + expiryDate: Date; + + constructor( + protected routeService: RouteService, + protected router: Router, + ) { + super(routeService, router); + } + + protected readonly hasValue = hasValue; + + ngOnInit(): void { + this.itemRequest$.pipe( + filter(request => hasValue(request)), + ).subscribe(request => { + this.itemRequestSubject.next(request); + super.ngOnInit(); + }); + + + } + + getAccessPeriodEndDate(): Date { + const request = this.itemRequestSubject.getValue(); + // Set expiry, if not 0 + if (hasValue(request) && request.accessPeriod > 0) { + const date = new Date(request.decisionDate); + date.setUTCSeconds(date.getUTCSeconds() + request.accessPeriod); + return date; + } + } +} diff --git a/src/app/shared/testing/router-link-directive.stub.ts b/src/app/shared/testing/router-link-directive.stub.ts index b3e2b09fb3..35d668c1bf 100644 --- a/src/app/shared/testing/router-link-directive.stub.ts +++ b/src/app/shared/testing/router-link-directive.stub.ts @@ -11,4 +11,5 @@ import { }) export class RouterLinkDirectiveStub { @Input() routerLink: any; + @Input() queryParams: any; } From 0de6481c242a5c1198e676802f7aa1cd348f6de7 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 14:58:46 +0100 Subject: [PATCH 04/25] Request-a-copy improv: Routing and module changes --- src/app/app-routing-paths.ts | 47 ++++++++++++++++++++ src/app/item-page/item-page-routes.ts | 6 +++ src/app/item-page/item-page-routing-paths.ts | 2 + src/themes/custom/lazy-theme.module.ts | 6 +++ 4 files changed, 61 insertions(+) diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 7d202f16e9..3f0b806a2c 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -34,6 +34,48 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st }, }; } +export function getBitstreamDownloadWithAccessTokenRoute(bitstream, accessToken): { routerLink: string, queryParams: any } { + const url = new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(); + const options = { + routerLink: url, + queryParams: {}, + }; + if (hasValue(accessToken)) { + options.queryParams = { accessToken: accessToken }; + } + return options; +} +/** + * Get an access token request route for a user to access approved bitstreams using a supplied access token + * @param item_uuid item UUID + * @param accessToken access token (generated by backend) + */ +export function getAccessTokenRequestRoute(item_uuid, accessToken): { routerLink: string, queryParams: any } { + const url = new URLCombiner(getItemModuleRoute(), item_uuid, getAccessByTokenModulePath()).toString(); + const options = { + routerLink: url, + queryParams: { + accessToken: (hasValue(accessToken) ? accessToken : undefined), + }, + }; + return options; +} +/** + * Get an access token request route for a user to access approved bitstreams using a supplied access token + * @param item_uuid item UUID + * @param accessToken access token (generated by backend) + */ +export function getAccessTokenRequestFileRoute(item_uuid, accessToken): { routerLink: string, queryParams: any } { + const url = new URLCombiner(getItemModuleRoute(), item_uuid, ACCESS_BY_TOKEN_MODULE_PATH).toString(); + const options = { + routerLink: url, + queryParams: { + accessToken: (hasValue(accessToken) ? accessToken : undefined), + }, + }; + return options; +} +export const COAR_NOTIFY_SUPPORT = 'coar-notify-support'; export const HOME_PAGE_PATH = 'home'; @@ -128,6 +170,11 @@ export function getRequestCopyModulePath() { return `/${REQUEST_COPY_MODULE_PATH}`; } +export const ACCESS_BY_TOKEN_MODULE_PATH = 'access-by-token'; +export function getAccessByTokenModulePath() { + return `/${ACCESS_BY_TOKEN_MODULE_PATH}`; +} + export const HEALTH_PAGE_PATH = 'health'; export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions'; diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index 854d66fabe..e6d9979786 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -6,11 +6,13 @@ import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.reso import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { MenuItemType } from '../shared/menu/menu-item-type.model'; +import { ItemAccessByTokenPageComponent } from './access-by-token/item-access-by-token-page.component'; import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { itemPageResolver } from './item-page.resolver'; import { + ITEM_ACCESS_BY_TOKEN_PATH, ITEM_EDIT_PATH, ORCID_PATH, UPLOAD_BITSTREAM_PATH, @@ -64,6 +66,10 @@ export const ROUTES: Route[] = [ component: OrcidPageComponent, canActivate: [authenticatedGuard, orcidPageGuard], }, + { + path: ITEM_ACCESS_BY_TOKEN_PATH, + component: ItemAccessByTokenPageComponent, + }, ], data: { menu: { diff --git a/src/app/item-page/item-page-routing-paths.ts b/src/app/item-page/item-page-routing-paths.ts index cded1dd74e..7f414b9c1a 100644 --- a/src/app/item-page/item-page-routing-paths.ts +++ b/src/app/item-page/item-page-routing-paths.ts @@ -51,3 +51,5 @@ export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory'; export const ITEM_VERSION_PATH = 'version'; export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; export const ORCID_PATH = 'orcid'; + +export const ITEM_ACCESS_BY_TOKEN_PATH = 'access-by-token'; diff --git a/src/themes/custom/lazy-theme.module.ts b/src/themes/custom/lazy-theme.module.ts index cd677847ce..2a66d22235 100644 --- a/src/themes/custom/lazy-theme.module.ts +++ b/src/themes/custom/lazy-theme.module.ts @@ -14,6 +14,9 @@ import { StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; +import { ItemSecureFileDownloadLinkComponent } from '../../app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component'; +import { ItemSecureFileSectionComponent } from '../../app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component'; +import { ItemAccessByTokenPageComponent } from '../../app/item-page/access-by-token/item-access-by-token-page.component'; import { RootModule } from '../../app/root.module'; import { SearchResultsSkeletonComponent } from '../../app/shared/search/search-results/search-results-skeleton/search-results-skeleton.component'; import { MetadataImportPageComponent } from './app/admin/admin-import-metadata-page/metadata-import-page.component'; @@ -133,6 +136,9 @@ const DECLARATIONS = [ CollectionPageComponent, ItemPageComponent, FullItemPageComponent, + ItemAccessByTokenPageComponent, + ItemSecureFileSectionComponent, + ItemSecureFileDownloadLinkComponent, LoginPageComponent, LogoutPageComponent, CreateProfileComponent, From 60bbcf342021fe8175fb3ad74d90245c07b1105d Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 14:59:40 +0100 Subject: [PATCH 05/25] Request-a-copy improv: Changes to bitstream page to support tests --- .../bitstream-download-page.component.spec.ts | 8 +-- .../bitstream-download-page.component.ts | 56 +++++++++++++++---- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts index 0cc293c6f7..b32510cba7 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts @@ -73,16 +73,16 @@ describe('BitstreamDownloadPageComponent', () => { self: { href: 'bitstream-self-link' }, }, }); - activatedRoute = { data: observableOf({ - bitstream: createSuccessfulRemoteDataObject( - bitstream, - ), + bitstream: createSuccessfulRemoteDataObject(bitstream), }), params: observableOf({ id: 'testid', }), + queryParams: observableOf({ + accessToken: undefined, + }), }; router = jasmine.createSpyObj('router', ['navigateByUrl']); diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts index ee329df16e..9a4ee2df97 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import { ActivatedRoute, + Params, Router, } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; @@ -83,11 +84,16 @@ export class BitstreamDownloadPageComponent implements OnInit { } ngOnInit(): void { + const accessToken$: Observable = this.route.queryParams.pipe( + map((queryParams: Params) => queryParams?.accessToken || null), + take(1), + ); this.bitstreamRD$ = this.route.data.pipe( map((data) => data.bitstream)); this.bitstream$ = this.bitstreamRD$.pipe( + // TODO: this redirect was commented out earlier... redirectOn4xx(this.router, this.auth), getRemoteDataPayload(), ); @@ -95,12 +101,13 @@ export class BitstreamDownloadPageComponent implements OnInit { this.bitstream$.pipe( switchMap((bitstream: Bitstream) => { const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined); + // TODO isAuthorizedByToken check here so we already know if this token is going to be valid? const isLoggedIn$ = this.auth.isAuthenticated(); - return observableCombineLatest([isAuthorized$, isLoggedIn$, observableOf(bitstream)]); + return observableCombineLatest([isAuthorized$, isLoggedIn$, accessToken$, observableOf(bitstream)]); }), - filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)), + filter(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => (hasValue(isAuthorized) && hasValue(isLoggedIn)) || hasValue(accessToken)), take(1), - switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => { + switchMap(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => { if (isAuthorized && isLoggedIn) { return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe( filter((fileLink) => hasValue(fileLink)), @@ -108,20 +115,49 @@ export class BitstreamDownloadPageComponent implements OnInit { map((fileLink) => { return [isAuthorized, isLoggedIn, bitstream, fileLink]; })); + } else if (hasValue(accessToken)) { + // We aren't authorized or logged in, but we might have temp access via the access token + console.log('RETRIEVE WITH ACCESS TOKEN'); + console.log('BUT - we dont want to retrieve the link with access token eh bro'); + // return this.fileService.retrieveFileDownloadLinkWithAccessToken(bitstream._links.content.href, accessToken).pipe( + // filter((fileLink) => hasValue(fileLink)), + // take(1), + // map((fileLink) => { + // return [isAuthorized, isLoggedIn, bitstream, fileLink]; + // })); + return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]]; } else { return [[isAuthorized, isLoggedIn, bitstream, '']]; } }), - ).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => { + ).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]: [boolean, boolean, Bitstream, string, string]) => { + // if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) { + // this.hardRedirectService.redirect(fileLink); + // } else if (isAuthorized && !isLoggedIn) { + // this.hardRedirectService.redirect(bitstream._links.content.href); + // } else if (!isAuthorized && isLoggedIn) { + // this.router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true}); + // } else if (!isAuthorized && !isLoggedIn) { + // this.auth.setRedirectUrl(this.router.url); + // this.router.navigateByUrl('login'); + // } + if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) { this.hardRedirectService.redirect(fileLink); - } else if (isAuthorized && !isLoggedIn) { + } else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) { this.hardRedirectService.redirect(bitstream._links.content.href); - } else if (!isAuthorized && isLoggedIn) { - this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true }); - } else if (!isAuthorized && !isLoggedIn) { - this.auth.setRedirectUrl(this.router.url); - this.router.navigateByUrl('login'); + } else if (!isAuthorized) { + // Either we have an access token, or we are logged in, or we are not logged in. + // For now, the access token does not care if we are logged in or not. + if (hasValue(accessToken)) { + this.hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken); + // this.router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true}); + } else if (isLoggedIn) { + this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true }); + } else if (!isLoggedIn) { + this.auth.setRedirectUrl(this.router.url); + this.router.navigateByUrl('login'); + } } }); } From 0c58a5bf0599fde6f0dfa958cc1338a10b478b5a Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Mar 2025 11:26:57 +0100 Subject: [PATCH 06/25] Request-a-copy: Refactor for angular control flow changes --- .../data/item-request-data.service.spec.ts | 126 ++++++++++++++- .../core/data/item-request-data.service.ts | 144 +++++++++++++++++- src/app/core/shared/item-request.model.ts | 10 ++ ...tstream-request-a-copy-page.component.html | 8 + ...ream-request-a-copy-page.component.spec.ts | 35 ++++- ...bitstream-request-a-copy-page.component.ts | 45 +++++- .../email-request-copy.component.html | 45 +++++- .../email-request-copy.component.spec.ts | 1 + .../email-request-copy.component.ts | 40 ++++- .../themed-email-request-copy.component.ts | 13 +- .../grant-deny-request-copy.component.html | 62 +++++--- .../grant-request-copy.component.html | 33 +++- .../grant-request-copy.component.spec.ts | 14 +- .../grant-request-copy.component.ts | 65 +++++++- 14 files changed, 581 insertions(+), 60 deletions(-) diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts index 68577ae6e2..b9cfe689fa 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -1,10 +1,17 @@ +import { HttpHeaders } from '@angular/common/http'; import { of as observableOf } from 'rxjs'; import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { MockBitstream1 } from '../../shared/mocks/item.mock'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemRequest } from '../shared/item-request.model'; +import { ConfigurationDataService } from './configuration-data.service'; +import { AuthorizationDataService } from './feature-authorization/authorization-data.service'; +import { FeatureID } from './feature-authorization/feature-id'; +import { FindListOptions } from './find-list-options.model'; import { ItemRequestDataService } from './item-request-data.service'; import { PostRequest } from './request.models'; import { RequestService } from './request.service'; @@ -16,12 +23,36 @@ describe('ItemRequestDataService', () => { let requestService: RequestService; let rdbService: RemoteDataBuildService; let halService: HALEndpointService; + let configService: ConfigurationDataService; + let authorizationDataService: AuthorizationDataService; const restApiEndpoint = 'rest/api/endpoint/'; const requestId = 'request-id'; let itemRequest: ItemRequest; beforeEach(() => { + configService = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']); + (configService.findByPropertyName as jasmine.Spy).and.callFake((propertyName: string) => { + switch (propertyName) { + case 'request.item.create.captcha': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.create.captcha', + values: ['true'], + })); + case 'request.item.grant.link.period': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.grant.link.period', + values: ['3600', '7200', '86400'], + })); + default: + return createSuccessfulRemoteDataObject$(new ConfigurationProperty()); + } + }); + + + authorizationDataService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(false), + }); itemRequest = Object.assign(new ItemRequest(), { token: 'item-request-token', }); @@ -36,13 +67,42 @@ describe('ItemRequestDataService', () => { getEndpoint: observableOf(restApiEndpoint), }); - service = new ItemRequestDataService(requestService, rdbService, null, halService); + service = new ItemRequestDataService(requestService, rdbService, null, halService, configService, authorizationDataService); + }); + + describe('searchBy', () => { + it('should use searchData to perform search operations', () => { + const searchMethod = 'testMethod'; + const options = new FindListOptions(); + + const searchDataSpy = spyOn((service as any).searchData, 'searchBy').and.returnValue(observableOf(null)); + + service.searchBy(searchMethod, options); + + expect(searchDataSpy).toHaveBeenCalledWith( + searchMethod, + options, + undefined, + undefined, + ); + }); }); describe('requestACopy', () => { it('should send a POST request containing the provided item request', (done) => { - service.requestACopy(itemRequest).subscribe(() => { - expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest)); + const captchaPayload = 'payload'; + service.requestACopy(itemRequest, captchaPayload).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith( + new PostRequest( + requestId, + restApiEndpoint, + itemRequest, + { + headers: new HttpHeaders().set('x-captcha-payload', captchaPayload), + }, + ), + false, + ); done(); }); }); @@ -59,11 +119,16 @@ describe('ItemRequestDataService', () => { service.grant(itemRequest.token, email, true).subscribe(() => { expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ method: RestRequestMethod.PUT, + href: `${restApiEndpoint}/${itemRequest.token}`, body: JSON.stringify({ acceptRequest: true, responseMessage: email.message, subject: email.subject, suggestOpenAccess: true, + accessPeriod: 0, + }), + options: jasmine.objectContaining({ + headers: jasmine.any(HttpHeaders), }), })); done(); @@ -82,15 +147,70 @@ describe('ItemRequestDataService', () => { service.deny(itemRequest.token, email).subscribe(() => { expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ method: RestRequestMethod.PUT, + href: `${restApiEndpoint}/${itemRequest.token}`, body: JSON.stringify({ acceptRequest: false, responseMessage: email.message, subject: email.subject, suggestOpenAccess: false, + accessPeriod: 0, + }), + options: jasmine.objectContaining({ + headers: jasmine.any(HttpHeaders), }), })); done(); }); }); }); + + describe('requestACopy', () => { + it('should send a POST request containing the provided item request', (done) => { + const captchaPayload = 'payload'; + service.requestACopy(itemRequest, captchaPayload).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith( + new PostRequest( + requestId, + restApiEndpoint, + itemRequest, + { + headers: new HttpHeaders().set('x-captcha-payload', captchaPayload), + }, + ), + false, + ); + done(); + }); + }); + }); + + describe('getConfiguredAccessPeriods', () => { + it('should return parsed integer values from config', () => { + service.getConfiguredAccessPeriods().subscribe(periods => { + expect(periods).toEqual([3600, 7200, 86400]); + }); + }); + }); + describe('isProtectedByCaptcha', () => { + it('should return true when config value is "true"', () => { + const mockConfigProperty = { + name: 'request.item.create.captcha', + values: ['true'], + } as ConfigurationProperty; + service.isProtectedByCaptcha().subscribe(result => { + expect(result).toBe(true); + }); + }); + }); + + describe('canDownload', () => { + it('should check authorization for bitstream download', () => { + service.canDownload(MockBitstream1).subscribe(result => { + expect(authorizationDataService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanDownload, MockBitstream1.self); + expect(result).toBe(false); + }); + }); + }); + + }); diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index 5c85ed1471..db6221572f 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -13,14 +13,27 @@ import { hasValue, isNotEmpty, } from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemRequest } from '../shared/item-request.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { sendRequest } from '../shared/request.operators'; import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { ConfigurationDataService } from './configuration-data.service'; +import { AuthorizationDataService } from './feature-authorization/authorization-data.service'; +import { FeatureID } from './feature-authorization/feature-id'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { PostRequest, @@ -34,14 +47,21 @@ import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', }) -export class ItemRequestDataService extends IdentifiableDataService { +export class ItemRequestDataService extends IdentifiableDataService implements SearchData { + + // TODO: This is only public for access by the test class - smell? + private searchData: SearchDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected configService: ConfigurationDataService, + protected authorizationService: AuthorizationDataService, ) { super('itemrequests', requestService, rdbService, objectCache, halService); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } getItemRequestEndpoint(): Observable { @@ -61,17 +81,26 @@ export class ItemRequestDataService extends IdentifiableDataService /** * Request a copy of an item * @param itemRequest + * @param captchaPayload payload of captcha verification */ - requestACopy(itemRequest: ItemRequest): Observable> { + requestACopy(itemRequest: ItemRequest, captchaPayload: string): Observable> { const requestId = this.requestService.generateRequestId(); const href$ = this.getItemRequestEndpoint(); + // Inject captcha payload into headers + const options: HttpOptions = Object.create({}); + if (captchaPayload) { + let headers = new HttpHeaders(); + headers = headers.set('x-captcha-payload', captchaPayload); + options.headers = headers; + } + href$.pipe( find((href: string) => hasValue(href)), map((href: string) => { - const request = new PostRequest(requestId, href, itemRequest); - this.requestService.send(request); + const request = new PostRequest(requestId, href, itemRequest, options); + this.requestService.send(request, false); }), ).subscribe(); @@ -94,9 +123,10 @@ export class ItemRequestDataService extends IdentifiableDataService * @param token Token of the {@link ItemRequest} * @param email Email to send back to the user requesting the item * @param suggestOpenAccess Whether or not to suggest the item to become open access + * @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments) */ - grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable> { - return this.process(token, email, true, suggestOpenAccess); + grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod = 0): Observable> { + return this.process(token, email, true, suggestOpenAccess, accessPeriod); } /** @@ -105,8 +135,9 @@ export class ItemRequestDataService extends IdentifiableDataService * @param email Email to send back to the user requesting the item * @param grant Grant or deny the request (true = grant, false = deny) * @param suggestOpenAccess Whether or not to suggest the item to become open access + * @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments) */ - process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable> { + process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod = 0): Observable> { const requestId = this.requestService.generateRequestId(); this.getItemRequestEndpointByToken(token).pipe( @@ -121,6 +152,7 @@ export class ItemRequestDataService extends IdentifiableDataService responseMessage: email.message, subject: email.subject, suggestOpenAccess, + accessPeriod: accessPeriod, }), options); }), sendRequest(this.requestService), @@ -128,4 +160,102 @@ export class ItemRequestDataService extends IdentifiableDataService return this.rdbService.buildFromRequestUUID(requestId); } + + // TODO: Remove this, after discussion about implications and compare to bitstream data service byItemHandle + // Reviewers may ask that we instead just wrap the REST response in pagination even though we only expect one obj + /** + * Get a sanitized item request using the searchBy method and the access token sent to the original requester. + * + * @param accessToken access token contained in the secure link sent to a requester + */ + getSanitizedRequestByAccessTokenPaged(accessToken: string): Observable>> { + // We only expect / want one result as access tokens are unique + const findListOptions = Object.assign({}, new FindListOptions(), { + elementsPerPage: 1, + currentPage: 1, + searchParams: [ + new RequestParam('accessToken', accessToken), + ], + }); + // Pipe the paginated searchBy results and return a single item request + return this.searchBy('byAccessToken', findListOptions); + } + + /** + * Get a sanitized item request using the searchBy method and the access token sent to the original requester. + * + * @param accessToken access token contained in the secure link sent to a requester + */ + getSanitizedRequestByAccessToken(accessToken: string): Observable> { + const findListOptions = Object.assign({}, new FindListOptions(), { + searchParams: [ + new RequestParam('accessToken', accessToken), + ], + }); + const hrefObs = this.getSearchByHref( + 'byAccessToken', + findListOptions, + ); + + return this.searchData.findByHref( + hrefObs, + ); + } + + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale); + } + + /** + * Get configured access periods (in seconds) to populate the dropdown in the item request approval form + * if the 'send secure link' feature is configured. + * Expects integer values, conversion to number is done in this processing + */ + getConfiguredAccessPeriods(): Observable { + return this.configService.findByPropertyName('request.item.grant.link.period').pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []), + map((values) => values.map(value => parseInt(value, 10))), + ); + } + + /** + * Is the request copy form protected by a captcha? This will be used to decide whether to render the captcha + * component in bitstream-request-a-copy-page component + */ + isProtectedByCaptcha(): Observable { + return this.configService.findByPropertyName('request.item.create.captcha').pipe( + getFirstCompletedRemoteData(), + map((rd) => { + if (rd.hasSucceeded) { + return rd.payload.values.length > 0 && rd.payload.values[0] === 'true'; + } else { + return false; + } + })); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Authorization check to see if the user already has download access to the given bitstream. + * Wrapped in this service to give it a central place and make it easy to mock for testing. + * + * @param bitstream The bitstream to be downloaded + * @return {Observable} true if user may download, false if not + */ + canDownload(bitstream: Bitstream): Observable { + return this.authorizationService.isAuthorized(FeatureID.CanDownload, bitstream?.self); + } } diff --git a/src/app/core/shared/item-request.model.ts b/src/app/core/shared/item-request.model.ts index 5a4f912363..8fa3398d24 100644 --- a/src/app/core/shared/item-request.model.ts +++ b/src/app/core/shared/item-request.model.ts @@ -80,6 +80,16 @@ export class ItemRequest implements CacheableObject { */ @autoserialize bitstreamId: string; + /** + * Access token of the request (read-only) + */ + @autoserialize + accessToken: string; + /** + * Access period of the request + */ + @autoserialize + accessPeriod: number; /** * The {@link HALLink}s for this ItemRequest diff --git a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html index 4e40883602..1f39afbdcf 100644 --- a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html +++ b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html @@ -84,6 +84,14 @@ + + @if (!!(captchaEnabled$ | async)) { +
+ + +
+ } +
diff --git a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.spec.ts b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.spec.ts index 5a5a8e6fca..7456ec6200 100644 --- a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.spec.ts +++ b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.spec.ts @@ -16,19 +16,27 @@ import { ActivatedRoute, Router, } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; import { AuthService } from '../../../core/auth/auth.service'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { RestResponse } from '../../../core/cache/response.models'; import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { ItemRequestDataService } from '../../../core/data/item-request-data.service'; +import { RequestService } from '../../../core/data/request.service'; +import { RequestEntry } from '../../../core/data/request-entry.model'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { Item } from '../../../core/shared/item.model'; +import { ITEM } from '../../../core/shared/item.resource-type'; import { ItemRequest } from '../../../core/shared/item-request.model'; import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { createFailedRemoteDataObject$, @@ -39,6 +47,9 @@ import { NotificationsServiceStub } from '../../../shared/testing/notifications- import { RouterStub } from '../../../shared/testing/router.stub'; import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page.component'; +const mockDataServiceMap: any = new Map([ + [ITEM.value, () => import('../../../shared/testing/test-data-service.mock').then(m => m.TestDataService)], +]); describe('BitstreamRequestACopyPageComponent', () => { let component: BitstreamRequestACopyPageComponent; @@ -48,10 +59,11 @@ describe('BitstreamRequestACopyPageComponent', () => { let authorizationService: AuthorizationDataService; let activatedRoute; let router; - let itemRequestDataService; + let itemRequestDataService: ItemRequestDataService; let notificationsService; let location; let bitstreamDataService; + let requestService; let item: Item; let bitstream: Bitstream; @@ -75,8 +87,20 @@ describe('BitstreamRequestACopyPageComponent', () => { itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { requestACopy: createSuccessfulRemoteDataObject$({}), + isProtectedByCaptcha: observableOf(true), }); + requestService = Object.assign(getMockRequestService(), { + getByHref(requestHref: string) { + const responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'OK'); + return observableOf(responseCacheEntry); + }, + removeByHrefSubstring(href: string) { + // Do nothing + }, + }) as RequestService; + location = jasmine.createSpyObj('location', { back: {}, }); @@ -124,6 +148,9 @@ describe('BitstreamRequestACopyPageComponent', () => { { provide: NotificationsService, useValue: notificationsService }, { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: BitstreamDataService, useValue: bitstreamDataService }, + { provide: Store, useValue: provideMockStore() }, + { provide: RequestService, useValue: requestService }, + { provide: APP_DATA_SERVICES_MAP, useValue: mockDataServiceMap }, ], }) .compileComponents(); @@ -246,6 +273,7 @@ describe('BitstreamRequestACopyPageComponent', () => { component.email.patchValue('user@name.org'); component.allfiles.patchValue('false'); component.message.patchValue('I would like to request a copy'); + component.captchaPayload.patchValue('payload'); component.onSubmit(); const itemRequest = Object.assign(new ItemRequest(), @@ -258,7 +286,7 @@ describe('BitstreamRequestACopyPageComponent', () => { requestMessage: 'I would like to request a copy', }); - expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest, 'payload'); expect(notificationsService.success).toHaveBeenCalled(); expect(location.back).toHaveBeenCalled(); }); @@ -280,6 +308,7 @@ describe('BitstreamRequestACopyPageComponent', () => { component.email.patchValue('user@name.org'); component.allfiles.patchValue('false'); component.message.patchValue('I would like to request a copy'); + component.captchaPayload.patchValue('payload'); component.onSubmit(); const itemRequest = Object.assign(new ItemRequest(), @@ -292,7 +321,7 @@ describe('BitstreamRequestACopyPageComponent', () => { requestMessage: 'I would like to request a copy', }); - expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest, 'payload'); expect(notificationsService.error).toHaveBeenCalled(); expect(location.back).not.toHaveBeenCalled(); }); diff --git a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts index dfbcef4111..9d4975f9bb 100644 --- a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts +++ b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts @@ -1,9 +1,13 @@ +import 'altcha'; + import { AsyncPipe, Location, } from '@angular/common'; import { + ChangeDetectorRef, Component, + CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit, } from '@angular/core'; @@ -46,6 +50,7 @@ import { BitstreamDataService } from '../../../core/data/bitstream-data.service' import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { ItemRequestDataService } from '../../../core/data/item-request-data.service'; +import { ProofOfWorkCaptchaDataService } from '../../../core/data/proof-of-work-captcha-data.service'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { Item } from '../../../core/shared/item.model'; @@ -60,7 +65,9 @@ import { isNotEmpty, } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { VarDirective } from '../../../shared/utils/var.directive'; import { getItemPageRoute } from '../../item-page-routing-paths'; +import { AltchaCaptchaComponent } from './altcha-captcha.component'; @Component({ selector: 'ds-bitstream-request-a-copy-page', @@ -71,7 +78,10 @@ import { getItemPageRoute } from '../../item-page-routing-paths'; AsyncPipe, ReactiveFormsModule, BtnDisabledDirective, + VarDirective, + AltchaCaptchaComponent, ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], standalone: true, }) /** @@ -92,6 +102,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { bitstream: Bitstream; bitstreamName: string; + // Captcha settings + captchaEnabled$: Observable; + challengeHref$: Observable; + constructor(private location: Location, private translateService: TranslateService, private route: ActivatedRoute, @@ -103,6 +117,8 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { private notificationsService: NotificationsService, private dsoNameService: DSONameService, private bitstreamService: BitstreamDataService, + private captchaService: ProofOfWorkCaptchaDataService, + private changeDetectorRef: ChangeDetectorRef, ) { } @@ -117,8 +133,15 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { }), allfiles: new UntypedFormControl(''), message: new UntypedFormControl(''), + // Payload here is initialised as "required", but this validator will be cleared + // if the config property comes back as 'captcha not enabled' + captchaPayload: new UntypedFormControl('', { + validators: [Validators.required], + }), }); + this.captchaEnabled$ = this.itemRequestDataService.isProtectedByCaptcha(); + this.challengeHref$ = this.captchaService.getChallengeHref(); this.item$ = this.route.data.pipe( map((data) => data.dso), @@ -172,6 +195,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { return this.requestCopyForm.get('allfiles'); } + get captchaPayload() { + return this.requestCopyForm.get('captchaPayload'); + } + /** * Initialise the form values based on the current user. */ @@ -185,6 +212,17 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { this.bitstream$.pipe(take(1)).subscribe((bitstream) => { this.requestCopyForm.patchValue({ allfiles: 'false' }); }); + this.subs.push(this.captchaEnabled$.pipe( + take(1), + ).subscribe((enabled) => { + if (!enabled) { + // Captcha not required? Clear validators to allow the form to be submitted normally + this.requestCopyForm.get('captchaPayload').clearValidators(); + this.requestCopyForm.get('captchaPayload').reset(); + this.requestCopyForm.updateValueAndValidity(); + } + this.changeDetectorRef.detectChanges(); + })); } /** @@ -218,8 +256,9 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { itemRequest.requestEmail = this.email.value; itemRequest.requestName = this.name.value; itemRequest.requestMessage = this.message.value; + const captchaPayloadString: string = this.captchaPayload.value; - this.itemRequestDataService.requestACopy(itemRequest).pipe( + this.itemRequestDataService.requestACopy(itemRequest, captchaPayloadString).pipe( getFirstCompletedRemoteData(), ).subscribe((rd) => { if (rd.hasSucceeded) { @@ -231,6 +270,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { }); } + handlePayload(event): void { + this.requestCopyForm.patchValue({ captchaPayload: event }); + } + ngOnDestroy(): void { if (hasValue(this.subs)) { this.subs.forEach((sub) => { diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.html b/src/app/request-copy/email-request-copy/email-request-copy.component.html index 50e3e73470..473d976d0d 100644 --- a/src/app/request-copy/email-request-copy/email-request-copy.component.html +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.html @@ -1,7 +1,8 @@
- + @if (!subject || subject.length === 0) {
{{ 'grant-deny-request-copy.email.subject.empty' | translate }} @@ -12,18 +13,46 @@
+ + @if (hasValue(validAccessPeriods) && validAccessPeriods.length > 0) { +
+ +
+ + + +
+ @for (accessPeriod of validAccessPeriods; track accessPeriod) { + + } +
+
+
+ }
diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts index 2e84a9a9c1..676f987c0f 100644 --- a/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts @@ -45,6 +45,7 @@ describe('EmailRequestCopyComponent', () => { spyOn(component.send, 'emit').and.stub(); component.subject = 'test-subject'; component.message = 'test-message'; + component.validAccessPeriods = [0]; component.submit(); expect(component.send.emit).toHaveBeenCalledWith(new RequestCopyEmail('test-subject', 'test-message')); }); diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.ts index fc9d42aa60..bb455f7fca 100644 --- a/src/app/request-copy/email-request-copy/email-request-copy.component.ts +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.ts @@ -1,3 +1,5 @@ +import 'altcha'; + import { Location, NgClass, @@ -6,30 +8,33 @@ import { Component, EventEmitter, Input, + OnInit, Output, } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; +import { hasValue } from '../../shared/empty.util'; import { RequestCopyEmail } from './request-copy-email.model'; - @Component({ selector: 'ds-base-email-request-copy', styleUrls: ['./email-request-copy.component.scss'], templateUrl: './email-request-copy.component.html', standalone: true, - imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective], + imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective, NgbDropdownModule], }) /** * A form component for an email to send back to the user requesting an item */ -export class EmailRequestCopyComponent { +export class EmailRequestCopyComponent implements OnInit { /** * Event emitter for sending the email */ @Output() send: EventEmitter = new EventEmitter(); + @Output() selectedAccessPeriod: EventEmitter = new EventEmitter(); /** * The subject of the email @@ -41,9 +46,28 @@ export class EmailRequestCopyComponent { */ @Input() message: string; + /** + * A list of valid access periods to render in a drop-down menu + */ + @Input() validAccessPeriods: number[] = []; + + /** + * The selected access period + */ + accessPeriod = 0; + + protected readonly hasValue = hasValue; + constructor(protected location: Location) { } + ngOnInit(): void { + // If access periods are present, set the default to the first in the array + if (hasValue(this.validAccessPeriods) && this.validAccessPeriods.length > 0) { + this.selectAccessPeriod(this.validAccessPeriods[0]); + } + } + /** * Submit the email */ @@ -57,4 +81,14 @@ export class EmailRequestCopyComponent { return() { this.location.back(); } + + /** + * Update the access period when a dropdown menu button is clicked for a value + * @param accessPeriod + */ + selectAccessPeriod(accessPeriod: number) { + this.accessPeriod = accessPeriod; + this.selectedAccessPeriod.emit(accessPeriod); + } + } diff --git a/src/app/request-copy/email-request-copy/themed-email-request-copy.component.ts b/src/app/request-copy/email-request-copy/themed-email-request-copy.component.ts index f641045ef2..a767704f55 100644 --- a/src/app/request-copy/email-request-copy/themed-email-request-copy.component.ts +++ b/src/app/request-copy/email-request-copy/themed-email-request-copy.component.ts @@ -25,6 +25,11 @@ export class ThemedEmailRequestCopyComponent extends ThemedComponent = new EventEmitter(); + /** + * Event emitter for a selected / changed access period + */ + @Output() selectedAccessPeriod: EventEmitter = new EventEmitter(); + /** * The subject of the email */ @@ -35,7 +40,13 @@ export class ThemedEmailRequestCopyComponent extends ThemedComponent -

{{'grant-deny-request-copy.header' | translate}}

+

{{ 'grant-deny-request-copy.header' | translate }}

+ @if (itemRequestRD && itemRequestRD.hasSucceeded) {
- @if (!itemRequestRD.payload.decisionDate) { + + @if (!itemRequestRD.payload.decisionDate || (itemRequestRD.payload.acceptRequest === true && itemRequestRD.payload.accessToken)) {
-

-

{{'grant-deny-request-copy.intro2' | translate}}

+

+

{{ 'grant-deny-request-copy.intro2' | translate }}

+ @if (itemRequestRD.payload.decisionDate) { +

{{ 'grant-deny-request-copy.previous-decision' | translate }}

+ }
- - {{'grant-deny-request-copy.grant' | translate }} - - - {{'grant-deny-request-copy.deny' | translate }} - + + @if (!itemRequestRD.payload.decisionDate) { + + {{ 'grant-deny-request-copy.grant' | translate }} + + + + {{ 'grant-deny-request-copy.deny' | translate }} + + } + @if (itemRequestRD.payload.decisionDate && itemRequestRD.payload.acceptRequest === true && itemRequestRD.payload.accessToken) { + + {{ 'grant-deny-request-copy.revoke' | translate }} + + }
- } - @if (itemRequestRD.payload.decisionDate) { -
-

{{'grant-deny-request-copy.processed' | translate}}

-

- {{'grant-deny-request-copy.home-page' | translate}} -

-
+ + @if (itemRequestRD.payload.decisionDate && (itemRequestRD.payload.acceptRequest === false || !itemRequestRD.payload.accessToken)) { +
+

{{ 'grant-deny-request-copy.processed' | translate }}

+

+ {{ 'grant-deny-request-copy.home-page' | translate }} +

+
+ } }
} diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.html b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html index c85f9005b6..715008e746 100644 --- a/src/app/request-copy/grant-request-copy/grant-request-copy.component.html +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html @@ -1,14 +1,37 @@
-

{{'grant-request-copy.header' | translate}}

+

{{ 'grant-request-copy.header' | translate }}

@if (itemRequestRD && itemRequestRD.hasSucceeded) {
-

{{'grant-request-copy.intro' | translate}}

- + +

{{ 'grant-request-copy.intro.' + (sendAsAttachment ? 'attachment' : 'link') | translate }}

+ + @if (!sendAsAttachment && hasValue(previewLink)) { +
+

{{ 'grant-request-copy.intro.link.preview' | translate }} + + {{ previewLink }} + +

+
+ } + + +

{{ 'grant-deny-request-copy.email.permissions.info' | translate }}

- - + +
diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts index e9865d4dc3..7d5e92e87e 100644 --- a/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts @@ -20,6 +20,7 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { ItemDataService } from '../../core/data/item-data.service'; import { ItemRequestDataService } from '../../core/data/item-request-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; import { Item } from '../../core/shared/item.model'; import { ItemRequest } from '../../core/shared/item-request.model'; import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; @@ -46,6 +47,7 @@ describe('GrantRequestCopyComponent', () => { let itemDataService: ItemDataService; let itemRequestService: ItemRequestDataService; let notificationsService: NotificationsService; + let hardRedirectService: HardRedirectService; let itemRequest: ItemRequest; let user: EPerson; @@ -90,7 +92,6 @@ describe('GrantRequestCopyComponent', () => { ], }, }); - router = jasmine.createSpyObj('router', { navigateByUrl: jasmine.createSpy('navigateByUrl'), }); @@ -106,11 +107,17 @@ describe('GrantRequestCopyComponent', () => { itemDataService = jasmine.createSpyObj('itemDataService', { findById: createSuccessfulRemoteDataObject$(item), }); - itemRequestService = jasmine.createSpyObj('itemRequestService', { + itemRequestService = jasmine.createSpyObj('ItemRequestDataService', { + getSanitizedRequestByAccessToken: observableOf(createSuccessfulRemoteDataObject(itemRequest)), grant: createSuccessfulRemoteDataObject$(itemRequest), + getConfiguredAccessPeriods: observableOf([3600, 7200, 14400]), // Common access periods in seconds + }); + + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(user), }); notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); - return TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), GrantRequestCopyComponent, VarDirective], providers: [ @@ -121,6 +128,7 @@ describe('GrantRequestCopyComponent', () => { { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: ItemRequestDataService, useValue: itemRequestService }, { provide: NotificationsService, useValue: notificationsService }, + { provide: HardRedirectService, useValue: hardRedirectService }, { provide: ThemeService, useValue: getMockThemeService() }, ], schemas: [NO_ERRORS_SCHEMA], diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts index 4a71226919..1455875b4e 100644 --- a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts @@ -1,4 +1,8 @@ -import { AsyncPipe } from '@angular/common'; +import { + AsyncPipe, + CommonModule, + NgClass, +} from '@angular/common'; import { Component, OnInit, @@ -7,6 +11,7 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, + RouterLink, } from '@angular/router'; import { TranslateModule, @@ -16,17 +21,21 @@ import { Observable } from 'rxjs'; import { map, switchMap, + tap, } from 'rxjs/operators'; +import { getAccessTokenRequestRoute } from '../../app-routing-paths'; import { AuthService } from '../../core/auth/auth.service'; import { ItemRequestDataService } from '../../core/data/item-request-data.service'; import { RemoteData } from '../../core/data/remote-data'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { ItemRequest } from '../../core/shared/item-request.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, } from '../../core/shared/operators'; +import { hasValue } from '../../shared/empty.util'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { VarDirective } from '../../shared/utils/var.directive'; @@ -38,7 +47,7 @@ import { ThemedEmailRequestCopyComponent } from '../email-request-copy/themed-em styleUrls: ['./grant-request-copy.component.scss'], templateUrl: './grant-request-copy.component.html', standalone: true, - imports: [VarDirective, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule], + imports: [CommonModule, VarDirective, NgIf, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule, RouterLink, NgClass], }) /** * Component for granting an item request @@ -59,11 +68,39 @@ export class GrantRequestCopyComponent implements OnInit { message$: Observable; /** - * Whether or not the item should be open access, to avoid future requests + * Whether the item should be open access, to avoid future requests * Defaults to false */ suggestOpenAccess = false; + /** + * A list of integers determining valid access periods in seconds + */ + validAccessPeriods$: Observable; + + /** + * The currently selected access period + */ + accessPeriod: any = 0; + + /** + * Will this email attach file(s) directly, or send a secure link with an access token to provide temporary access? + * This will be false if the access token is populated, since the configuration and min file size checks are + * done at the time of request creation, with a default of true. + */ + sendAsAttachment = true; + + /** + * Preview link to be sent to a request applicant + */ + previewLinkOptions: { + routerLink: string, + queryParams: any, + }; + previewLink: string; + + protected readonly hasValue = hasValue; + constructor( private router: Router, private route: ActivatedRoute, @@ -71,17 +108,33 @@ export class GrantRequestCopyComponent implements OnInit { private translateService: TranslateService, private itemRequestService: ItemRequestDataService, private notificationsService: NotificationsService, + private hardRedirectService: HardRedirectService, ) { } ngOnInit(): void { + // Get item request data via the router (async) this.itemRequestRD$ = this.route.data.pipe( map((data) => data.request as RemoteData), getFirstCompletedRemoteData(), + tap((rd) => { + // If an access token is present then the backend has checked configuration and file sizes + // and appropriately created a token to use with a secure link instead of attaching file directly + if (rd.hasSucceeded && hasValue(rd.payload.accessToken)) { + this.sendAsAttachment = false; + this.previewLinkOptions = getAccessTokenRequestRoute(rd.payload.itemId, rd.payload.accessToken); + this.previewLink = this.hardRedirectService.getCurrentOrigin() + + this.previewLinkOptions.routerLink + '?accessToken=' + rd.payload.accessToken; + } + }), redirectOn4xx(this.router, this.authService), ); + // Get configured access periods + this.validAccessPeriods$ = this.itemRequestService.getConfiguredAccessPeriods(); + + // Get the subject line of the email this.subject$ = this.translateService.get('grant-request-copy.email.subject'); } @@ -92,7 +145,7 @@ export class GrantRequestCopyComponent implements OnInit { grant(email: RequestCopyEmail) { this.itemRequestRD$.pipe( getFirstSucceededRemoteDataPayload(), - switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess)), + switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess, this.accessPeriod)), getFirstCompletedRemoteData(), ).subscribe((rd) => { if (rd.hasSucceeded) { @@ -104,4 +157,8 @@ export class GrantRequestCopyComponent implements OnInit { }); } + selectAccessPeriod(accessPeriod: number) { + this.accessPeriod = accessPeriod; + } + } From e736bbb9ec3f36fcb811d98da9da608470ed6009 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 15:00:17 +0100 Subject: [PATCH 07/25] Request-a-copy improv: Altcha recaptcha component and service --- .../proof-of-work-captcha-data.service.ts | 25 ++++++++++ .../altcha-captcha.component.html | 6 +++ .../altcha-captcha.component.spec.ts | 46 +++++++++++++++++ .../altcha-captcha.component.ts | 50 +++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/app/core/data/proof-of-work-captcha-data.service.ts create mode 100644 src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.html create mode 100644 src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.spec.ts create mode 100644 src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts diff --git a/src/app/core/data/proof-of-work-captcha-data.service.ts b/src/app/core/data/proof-of-work-captcha-data.service.ts new file mode 100644 index 0000000000..1e1f886d36 --- /dev/null +++ b/src/app/core/data/proof-of-work-captcha-data.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +@Injectable({ providedIn: 'root' }) +export class ProofOfWorkCaptchaDataService { + + private linkPath = 'captcha'; + + constructor( + private halService: HALEndpointService) { + } + + public getChallengeHref(): Observable { + return this.getEndpoint().pipe( + map((endpoint) => endpoint + '/challenge'), + ); + } + + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.html b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.html new file mode 100644 index 0000000000..c164c99b36 --- /dev/null +++ b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.html @@ -0,0 +1,6 @@ + diff --git a/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.spec.ts b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.spec.ts new file mode 100644 index 0000000000..98a2fe1586 --- /dev/null +++ b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.spec.ts @@ -0,0 +1,46 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AltchaCaptchaComponent } from './altcha-captcha.component'; + +describe('AltchaCaptchaComponent', () => { + let component: AltchaCaptchaComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + AltchaCaptchaComponent, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AltchaCaptchaComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create component successfully', () => { + expect(component).toBeTruthy(); + }); + + it('should emit payload when verification is successful', () => { + const testPayload = 'test-payload'; + const payloadSpy = jasmine.createSpy('payloadSpy'); + component.payload.subscribe(payloadSpy); + + const event = new CustomEvent('statechange', { + detail: { + state: 'verified', + payload: testPayload, + }, + }); + + document.querySelector('#altcha-widget').dispatchEvent(event); + + expect(payloadSpy).toHaveBeenCalledWith(testPayload); + }); +}); diff --git a/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts new file mode 100644 index 0000000000..9740068d67 --- /dev/null +++ b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts @@ -0,0 +1,50 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + CUSTOM_ELEMENTS_SCHEMA, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + +import { VarDirective } from '../../../shared/utils/var.directive'; + +@Component({ + selector: 'ds-altcha-captcha', + templateUrl: './altcha-captcha.component.html', + imports: [ + TranslateModule, + RouterLink, + AsyncPipe, + ReactiveFormsModule, + NgIf, + VarDirective, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + standalone: true, +}) +export class AltchaCaptchaComponent implements OnInit { + + @Input() challengeUrl: string; + @Input() autoload: string; + @Input() debug: boolean; + @Output() payload = new EventEmitter; + + ngOnInit(): void { + document.querySelector('#altcha-widget').addEventListener('statechange', (ev: any) => { + // state can be: unverified, verifying, verified, error + if (ev.detail.state === 'verified') { + // payload contains base64 encoded data for the server + this.payload.emit(ev.detail.payload); + } + }); + } + +} From 585347b1a2f0b3fe98bdd2624c833e93c65ad782 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 15:00:25 +0100 Subject: [PATCH 08/25] Request-a-copy improv: English i18n --- src/assets/i18n/en.json5 | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b712546050..82dc0092e8 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -998,6 +998,10 @@ "bitstream-request-a-copy.submit.error": "Something went wrong with submitting the item request.", + "bitstream-request-a-copy.access-by-token.warning": "You are viewing this item with the secure access link provided to you by the author or repository staff. It is important not to share this link to unauthorised users.", + + "bitstream-request-a-copy.access-by-token.expiry-label": "Access provided by this link will expire on", + "browse.back.all-results": "All browse results", "browse.comcol.by.author": "By Author", @@ -2034,7 +2038,9 @@ "form.repeatable.sort.tip": "Drop the item in the new position", - "grant-deny-request-copy.deny": "Don't send copy", + "grant-deny-request-copy.deny": "Deny access request", + + "grant-deny-request-copy.revoke": "Revoke access", "grant-deny-request-copy.email.back": "Back", @@ -2052,7 +2058,7 @@ "grant-deny-request-copy.email.subject.empty": "Please enter a subject", - "grant-deny-request-copy.grant": "Send copy", + "grant-deny-request-copy.grant": "Grant access request", "grant-deny-request-copy.header": "Document copy request", @@ -2062,6 +2068,8 @@ "grant-deny-request-copy.intro2": "After choosing an option, you will be presented with a suggested email reply which you may edit.", + "grant-deny-request-copy.previous-decision": "This request was previously granted with a secure access token. You may revoke this access now to immediately invalidate the access token", + "grant-deny-request-copy.processed": "This request has already been processed. You can use the button below to get back to the home page.", "grant-request-copy.email.subject": "Request copy of document", @@ -2070,10 +2078,26 @@ "grant-request-copy.header": "Grant document copy request", - "grant-request-copy.intro": "A message will be sent to the applicant of the request. The requested document(s) will be attached.", + "grant-request-copy.intro.attachment": "A message will be sent to the applicant of the request. The requested document(s) will be attached.", + + "grant-request-copy.intro.link": "A message will be sent to the applicant of the request. A secure link providing access to the requested document(s) will be attached. The link will provide access for the duration of time selected in the \"Access Period\" menu below.", + + "grant-request-copy.intro.link.preview": "Below is a preview of the link that will be sent to the applicant:", "grant-request-copy.success": "Successfully granted item request", + "grant-request-copy.access-period.header": "Access period", + + "grant-request-copy.access-period.120": "2 minutes", + + "grant-request-copy.access-period.240": "4 minutes", + + "grant-request-copy.access-period.86400": "1 day", + + "grant-request-copy.access-period.604800": "7 days", + + "grant-request-copy.access-period.0": "Forever", + "health.breadcrumbs": "Health", "health-page.heading": "Health", From bff56621166324380e014789c37ea8e357e5b02a Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 15:00:47 +0100 Subject: [PATCH 09/25] Request-a-copy improv: Add altcha dependency to package.json --- package-lock.json | 6 ++++++ package.json | 1 + 2 files changed, 7 insertions(+) diff --git a/package-lock.json b/package-lock.json index 27a9b18050..f30e4faa7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@ngrx/store": "^18.1.1", "@ngx-translate/core": "^16.0.3", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", + "altcha": "^0.2.2", "angulartics2": "^12.2.0", "axios": "^1.7.9", "bootstrap": "^5.3", @@ -8895,6 +8896,11 @@ "ajv": "^8.8.2" } }, + "node_modules/altcha": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/altcha/-/altcha-0.2.3.tgz", + "integrity": "sha512-7ZYS70TdzpXrV5qcRNnkut5DOjHLrU+DzTlftts9qYAjGaA/7RouHPmc2cKJrAl87jsvvIrL2u9RVsrH1ty1tQ==" + }, "node_modules/angulartics2": { "version": "12.2.1", "resolved": "https://registry.npmjs.org/angulartics2/-/angulartics2-12.2.1.tgz", diff --git a/package.json b/package.json index c289171423..e005779c67 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@ngrx/store": "^18.1.1", "@ngx-translate/core": "^16.0.3", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", + "altcha": "^0.2.2", "angulartics2": "^12.2.0", "axios": "^1.7.9", "bootstrap": "^5.3", From e928eab96becba0806688e302ded2894d4fd1c1e Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Mon, 17 Feb 2025 13:05:46 +0100 Subject: [PATCH 10/25] Request-a-copy improv: altcha fixes --- src/app/core/data/eperson-registration.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 90a3fab83a..23764d007a 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -81,6 +81,7 @@ export class EpersonRegistrationService { let headers = new HttpHeaders(); if (captchaToken) { headers = headers.append('x-recaptcha-token', captchaToken); + headers = headers.append('x-captcha-version', captchaToken); } options.headers = headers; From c9c2a774ea0af58a98753736fd3f164874e5432a Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Mon, 17 Feb 2025 13:27:38 +0100 Subject: [PATCH 11/25] Request-a-copy: Merge recaptcha headers to generic x-captcha-payload --- src/app/core/data/eperson-registration.service.spec.ts | 2 +- src/app/core/data/eperson-registration.service.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index a60cef121a..b51deddc51 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -95,7 +95,7 @@ describe('EpersonRegistrationService', () => { const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken'); let headers = new HttpHeaders(); const options: HttpOptions = Object.create({}); - headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken'); + headers = headers.append('x-captcha-payload', 'afreshcaptchatoken'); options.headers = headers; expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options)); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 23764d007a..931d7e5e3f 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -67,7 +67,7 @@ export class EpersonRegistrationService { /** * Register a new email address * @param email - * @param captchaToken the value of x-recaptcha-token header + * @param captchaToken the value of x-captcha-payload header */ registerEmail(email: string, captchaToken: string = null, type?: string): Observable> { const registration = new Registration(); @@ -80,8 +80,7 @@ export class EpersonRegistrationService { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); if (captchaToken) { - headers = headers.append('x-recaptcha-token', captchaToken); - headers = headers.append('x-captcha-version', captchaToken); + headers = headers.append('x-captcha-payload', captchaToken); } options.headers = headers; From ce93b847d2984dfd6772b011295ea2f9b1adc0ac Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 6 Mar 2025 16:06:47 +0100 Subject: [PATCH 12/25] Request-a-copy: Code cleanup and comments --- src/app/app-routing-paths.ts | 23 +++++++------------ .../bitstream-download-page.component.ts | 22 ------------------ .../altcha-captcha.component.ts | 13 ++++++++++- 3 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 3f0b806a2c..4ef99559a8 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -34,12 +34,19 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st }, }; } + +/** + * Get a bitstream download route with an access token (to provide direct access to a user) added as a query parameter + * @param bitstream the bitstream to download + * @param accessToken the access token, which should match an access_token in the requestitem table + */ export function getBitstreamDownloadWithAccessTokenRoute(bitstream, accessToken): { routerLink: string, queryParams: any } { const url = new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(); const options = { routerLink: url, queryParams: {}, }; + // Only add the access token if it is not empty, otherwise keep valid empty query parameters if (hasValue(accessToken)) { options.queryParams = { accessToken: accessToken }; } @@ -60,21 +67,7 @@ export function getAccessTokenRequestRoute(item_uuid, accessToken): { routerLink }; return options; } -/** - * Get an access token request route for a user to access approved bitstreams using a supplied access token - * @param item_uuid item UUID - * @param accessToken access token (generated by backend) - */ -export function getAccessTokenRequestFileRoute(item_uuid, accessToken): { routerLink: string, queryParams: any } { - const url = new URLCombiner(getItemModuleRoute(), item_uuid, ACCESS_BY_TOKEN_MODULE_PATH).toString(); - const options = { - routerLink: url, - queryParams: { - accessToken: (hasValue(accessToken) ? accessToken : undefined), - }, - }; - return options; -} + export const COAR_NOTIFY_SUPPORT = 'coar-notify-support'; export const HOME_PAGE_PATH = 'home'; diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts index 9a4ee2df97..23117c1bac 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts @@ -93,7 +93,6 @@ export class BitstreamDownloadPageComponent implements OnInit { map((data) => data.bitstream)); this.bitstream$ = this.bitstreamRD$.pipe( - // TODO: this redirect was commented out earlier... redirectOn4xx(this.router, this.auth), getRemoteDataPayload(), ); @@ -101,7 +100,6 @@ export class BitstreamDownloadPageComponent implements OnInit { this.bitstream$.pipe( switchMap((bitstream: Bitstream) => { const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined); - // TODO isAuthorizedByToken check here so we already know if this token is going to be valid? const isLoggedIn$ = this.auth.isAuthenticated(); return observableCombineLatest([isAuthorized$, isLoggedIn$, accessToken$, observableOf(bitstream)]); }), @@ -116,32 +114,12 @@ export class BitstreamDownloadPageComponent implements OnInit { return [isAuthorized, isLoggedIn, bitstream, fileLink]; })); } else if (hasValue(accessToken)) { - // We aren't authorized or logged in, but we might have temp access via the access token - console.log('RETRIEVE WITH ACCESS TOKEN'); - console.log('BUT - we dont want to retrieve the link with access token eh bro'); - // return this.fileService.retrieveFileDownloadLinkWithAccessToken(bitstream._links.content.href, accessToken).pipe( - // filter((fileLink) => hasValue(fileLink)), - // take(1), - // map((fileLink) => { - // return [isAuthorized, isLoggedIn, bitstream, fileLink]; - // })); return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]]; } else { return [[isAuthorized, isLoggedIn, bitstream, '']]; } }), ).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]: [boolean, boolean, Bitstream, string, string]) => { - // if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) { - // this.hardRedirectService.redirect(fileLink); - // } else if (isAuthorized && !isLoggedIn) { - // this.hardRedirectService.redirect(bitstream._links.content.href); - // } else if (!isAuthorized && isLoggedIn) { - // this.router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true}); - // } else if (!isAuthorized && !isLoggedIn) { - // this.auth.setRedirectUrl(this.router.url); - // this.router.navigateByUrl('login'); - // } - if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) { this.hardRedirectService.redirect(fileLink); } else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) { diff --git a/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts index 9740068d67..a9d5fd77f3 100644 --- a/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts +++ b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts @@ -30,11 +30,22 @@ import { VarDirective } from '../../../shared/utils/var.directive'; schemas: [CUSTOM_ELEMENTS_SCHEMA], standalone: true, }) + +/** + * Component that renders the ALTCHA captcha widget. GDPR-compliant, no cookies, proof-of-work based anti-spam captcha. + * See: https://altcha.org/ + * + * Once the proof of work is verified, the final payload is emitted to the parent component for inclusion in the form submission. + */ export class AltchaCaptchaComponent implements OnInit { + // Challenge URL, to query the backend (or other remote) for a challenge @Input() challengeUrl: string; - @Input() autoload: string; + // Whether / how to autoload the widget, e.g. 'onload', 'onsubmit', 'onfocus', 'off' + @Input() autoload = 'onload'; + // Whether to debug altcha activity to the javascript console @Input() debug: boolean; + // The final calculated payload (containing, challenge, salt, number) to be sent with the protected form submission for validation @Output() payload = new EventEmitter; ngOnInit(): void { From 58d0e7f513a4459bca8cc33c00115c5c4b9171bd Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 6 Mar 2025 19:03:55 +0100 Subject: [PATCH 13/25] Request-a-copy: Using a resolver to grab the RequestItem --- src/app/core/auth/access-token.resolver.ts | 46 ++++++++++++++++++++++ src/app/item-page/item-page-routes.ts | 7 +++- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/app/core/auth/access-token.resolver.ts diff --git a/src/app/core/auth/access-token.resolver.ts b/src/app/core/auth/access-token.resolver.ts new file mode 100644 index 0000000000..f29b047cfd --- /dev/null +++ b/src/app/core/auth/access-token.resolver.ts @@ -0,0 +1,46 @@ +import { inject } from '@angular/core'; +import { + ResolveFn, + Router, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { getForbiddenRoute } from '../../app-routing-paths'; +import { hasValue } from '../../shared/empty.util'; +import { ItemRequestDataService } from '../data/item-request-data.service'; +import { redirectOn4xx } from '../shared/authorized.operators'; +import { ItemRequest } from '../shared/item-request.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../shared/operators'; +import { AuthService } from './auth.service'; + +export const accessTokenResolver: ResolveFn = ( + route, + state, + router: Router = inject(Router), + authService: AuthService = inject(AuthService), + itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService), +): Observable => { + const accessToken = route.queryParams.accessToken; + if ( !hasValue(accessToken) ) { + return null; + } + // Get + return itemRequestDataService.getSanitizedRequestByAccessToken(accessToken).pipe( + getFirstCompletedRemoteData(), + redirectOn4xx(router, authService), + getFirstSucceededRemoteDataPayload(), + tap(request => { + if (!hasValue(request)) { + console.dir('no request found for access token', accessToken); + router.navigateByUrl(getForbiddenRoute()); + } + console.dir('found request: ', request); + console.dir(request); + return request; + }), + ); +}; diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index e6d9979786..12ddcba701 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -6,7 +6,6 @@ import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.reso import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { MenuItemType } from '../shared/menu/menu-item-type.model'; -import { ItemAccessByTokenPageComponent } from './access-by-token/item-access-by-token-page.component'; import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; @@ -22,6 +21,7 @@ import { orcidPageGuard } from './orcid-page/orcid-page.guard'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { versionResolver } from './version-page/version.resolver'; import { VersionPageComponent } from './version-page/version-page/version-page.component'; +import { accessTokenResolver } from '../core/auth/access-token.resolver'; export const ROUTES: Route[] = [ { @@ -68,7 +68,10 @@ export const ROUTES: Route[] = [ }, { path: ITEM_ACCESS_BY_TOKEN_PATH, - component: ItemAccessByTokenPageComponent, + component: ThemedFullItemPageComponent, + resolve: { + menu: accessTokenResolver, + }, }, ], data: { From d1bcb9fb2e79bc23b887332b1ac87dccd1fd06e0 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Tue, 11 Mar 2025 12:11:26 +0100 Subject: [PATCH 14/25] Request-a-copy: Use wrapped ItemWithSupp.. and base item comps, except download link --- src/app/core/auth/access-token.resolver.ts | 19 +- .../item-with-supplementary-data.model.ts | 20 ++ .../core/shared/media-viewer-item.model.ts | 5 + ...m-secure-file-download-link.component.html | 6 +- ...tem-secure-file-download-link.component.ts | 14 +- .../item-secure-file-section.component.html | 87 ----- .../item-secure-file-section.component.scss | 5 - ...item-secure-file-section.component.spec.ts | 96 ------ .../item-secure-file-section.component.ts | 156 --------- .../item-secure-media-viewer.component.html | 35 -- .../item-secure-media-viewer.component.scss | 1 - ...item-secure-media-viewer.component.spec.ts | 160 ---------- .../item-secure-media-viewer.component.ts | 171 ---------- .../secure-media-viewer-image.component.html | 7 - .../secure-media-viewer-image.component.scss | 20 -- ...ecure-media-viewer-image.component.spec.ts | 91 ------ .../secure-media-viewer-image.component.ts | 109 ------- .../secure-media-viewer-video.component.html | 54 ---- .../secure-media-viewer-video.component.scss | 10 - ...ecure-media-viewer-video.component.spec.ts | 149 --------- .../secure-media-viewer-video.component.ts | 103 ------ .../item-access-by-token-page.component.html | 8 - .../item-access-by-token-page.component.scss | 0 ...tem-access-by-token-page.component.spec.ts | 299 ------------------ .../item-access-by-token-page.component.ts | 178 ----------- .../item-access-by-token-view.component.html | 101 ------ .../item-access-by-token-view.component.scss | 0 ...tem-access-by-token-view.component.spec.ts | 213 ------------- .../item-access-by-token-view.component.ts | 128 -------- src/app/item-page/item-page-routes.ts | 3 +- .../media-viewer-image.component.ts | 3 +- .../media-viewer-video.component.html | 2 +- .../media-viewer-video.component.ts | 14 +- .../media-viewer/media-viewer.component.ts | 13 + .../file-section/file-section.component.ts | 7 + .../item-page/simple/item-page.component.ts | 50 ++- .../file-section/file-section.component.ts | 2 + .../register-email-form.component.ts | 1 + src/themes/custom/lazy-theme.module.ts | 4 - 39 files changed, 145 insertions(+), 2199 deletions(-) create mode 100644 src/app/core/shared/item-with-supplementary-data.model.ts delete mode 100644 src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.html delete mode 100644 src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.scss delete mode 100644 src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.spec.ts delete mode 100644 src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.ts delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.html delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.scss delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.spec.ts delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.ts delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.html delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.scss delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.spec.ts delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.ts delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.html delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.scss delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.spec.ts delete mode 100644 src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.ts delete mode 100644 src/app/item-page/access-by-token/item-access-by-token-page.component.html delete mode 100644 src/app/item-page/access-by-token/item-access-by-token-page.component.scss delete mode 100644 src/app/item-page/access-by-token/item-access-by-token-page.component.spec.ts delete mode 100644 src/app/item-page/access-by-token/item-access-by-token-page.component.ts delete mode 100644 src/app/item-page/access-by-token/item-access-by-token-view.component.html delete mode 100644 src/app/item-page/access-by-token/item-access-by-token-view.component.scss delete mode 100644 src/app/item-page/access-by-token/item-access-by-token-view.component.spec.ts delete mode 100644 src/app/item-page/access-by-token/item-access-by-token-view.component.ts diff --git a/src/app/core/auth/access-token.resolver.ts b/src/app/core/auth/access-token.resolver.ts index f29b047cfd..61deb89c4b 100644 --- a/src/app/core/auth/access-token.resolver.ts +++ b/src/app/core/auth/access-token.resolver.ts @@ -17,6 +17,15 @@ import { } from '../shared/operators'; import { AuthService } from './auth.service'; +/** + * Resolve an ItemRequest based on the accessToken in the query params + * Used in item-page-routes.ts to resolve the item request for all Item page components + * @param route + * @param state + * @param router + * @param authService + * @param itemRequestDataService + */ export const accessTokenResolver: ResolveFn = ( route, state, @@ -25,21 +34,23 @@ export const accessTokenResolver: ResolveFn = ( itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService), ): Observable => { const accessToken = route.queryParams.accessToken; + // Set null object if accesstoken is empty if ( !hasValue(accessToken) ) { return null; } - // Get + // Get the item request from the server return itemRequestDataService.getSanitizedRequestByAccessToken(accessToken).pipe( getFirstCompletedRemoteData(), + // Handle authorization errors, not found errors and forbidden errors as normal redirectOn4xx(router, authService), + // Get payload of the item request getFirstSucceededRemoteDataPayload(), tap(request => { if (!hasValue(request)) { - console.dir('no request found for access token', accessToken); + // If the request is not found, redirect to 403 Forbidden router.navigateByUrl(getForbiddenRoute()); } - console.dir('found request: ', request); - console.dir(request); + // Return the resolved item request object return request; }), ); diff --git a/src/app/core/shared/item-with-supplementary-data.model.ts b/src/app/core/shared/item-with-supplementary-data.model.ts new file mode 100644 index 0000000000..76480a2650 --- /dev/null +++ b/src/app/core/shared/item-with-supplementary-data.model.ts @@ -0,0 +1,20 @@ +import { Item } from './item.model'; +import { ItemRequest } from './item-request.model'; + +/** + * This model represents an item with supplementary data, e.g. an ItemRequest object + * to help components determine how the Item or its data/bitstream should be delivered + * and presented to the users, but not part of the actual database model. + */ +export class ItemWithSupplementaryData extends Item { + /** + * An item request. This is used to determine how the item should be delivered. + * A valid accessToken is resolved to this object in the accessTokenResolver + */ + itemRequest: ItemRequest; + + constructor(itemRequest: ItemRequest) { + super(); + this.itemRequest = itemRequest; + } +} diff --git a/src/app/core/shared/media-viewer-item.model.ts b/src/app/core/shared/media-viewer-item.model.ts index 1cf4948408..dd4fafeb34 100644 --- a/src/app/core/shared/media-viewer-item.model.ts +++ b/src/app/core/shared/media-viewer-item.model.ts @@ -23,4 +23,9 @@ export class MediaViewerItem { * Incoming Bitsream thumbnail */ thumbnail: string; + + /** + * Access token, if accessed via a Request-a-Copy link + */ + accessToken: string; } diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html index 476b3f356b..d38519878e 100644 --- a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html +++ b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html @@ -1,4 +1,8 @@ - + diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts index 62b432e68a..4e60aaebe2 100644 --- a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts +++ b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts @@ -23,11 +23,13 @@ import { getBitstreamDownloadWithAccessTokenRoute, getBitstreamRequestACopyRoute, } from '../../../../app-routing-paths'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; import { ItemRequest } from '../../../../core/shared/item-request.model'; +import { ItemWithSupplementaryData } from '../../../../core/shared/item-with-supplementary-data.model'; import { hasValue, isNotEmpty, @@ -54,6 +56,9 @@ export class ItemSecureFileDownloadLinkComponent implements OnInit { */ @Input() bitstream: Bitstream; + /** + * Item that owns the linked bitstream + */ @Input() item: Item; /** @@ -66,15 +71,14 @@ export class ItemSecureFileDownloadLinkComponent implements OnInit { */ @Input() isBlank = false; - @Input() itemRequest: ItemRequest; - @Input() enableRequestACopy = true; bitstreamPath$: Observable<{ routerLink: string, queryParams: any, }>; - + // ItemRequest object with access token, expiry, etc. + itemRequest: ItemRequest; // authorized to download normally canDownload$: Observable; // authorized to download with token @@ -84,6 +88,7 @@ export class ItemSecureFileDownloadLinkComponent implements OnInit { constructor( private authorizationService: AuthorizationDataService, + protected dsoNameService: DSONameService, ) { } @@ -92,6 +97,9 @@ export class ItemSecureFileDownloadLinkComponent implements OnInit { * (for a given bitstream), and ability to request a copy of a bitstream. */ ngOnInit() { + if (this.item instanceof ItemWithSupplementaryData) { + this.itemRequest = this.item.itemRequest; + } this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); this.canDownloadWithToken$ = observableOf(this.itemRequest ? (this.itemRequest.allfiles !== false || this.itemRequest.bitstreamId === this.bitstream.uuid) : false); this.canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); diff --git a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.html b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.html deleted file mode 100644 index 4792f2657d..0000000000 --- a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.html +++ /dev/null @@ -1,87 +0,0 @@ - -
-
-

{{"item.page.filesection.original.bundle" | translate}}

- - - -
-
- -
-
-
-
{{"item.page.filesection.name" | translate}}
-
{{ dsoNameService.getName(file) }}
- -
{{"item.page.filesection.size" | translate}}
-
{{(file.sizeBytes) | dsFileSize }}
- - -
{{"item.page.filesection.format" | translate}}
-
{{(file.format | async)?.payload?.description}}
- - -
{{"item.page.filesection.description" | translate}}
-
{{file.firstMetadataValue("dc.description")}}
-
-
-
-
- - - - - {{"item.page.filesection.download" | translate}} - -
-
-
-
-
-
-
-

{{"item.page.filesection.license.bundle" | translate}}

- - - -
-
- -
-
-
-
{{"item.page.filesection.name" | translate}}
-
{{ dsoNameService.getName(file) }}
- -
{{"item.page.filesection.size" | translate}}
-
{{(file.sizeBytes) | dsFileSize }}
- -
{{"item.page.filesection.format" | translate}}
-
{{(file.format | async)?.payload?.description}}
- - -
{{"item.page.filesection.description" | translate}}
-
{{file.firstMetadataValue("dc.description")}}
-
-
-
- - {{"item.page.filesection.download" | translate}} - -
-
-
-
-
-
diff --git a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.scss b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.scss deleted file mode 100644 index 5384f90cec..0000000000 --- a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -@media screen and (min-width: map-get($grid-breakpoints, md)) { - dt { - text-align: right; - } -} diff --git a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.spec.ts b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.spec.ts deleted file mode 100644 index 557eb06b86..0000000000 --- a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideMockStore } from '@ngrx/store/testing'; -import { - TranslateLoader, - TranslateModule, -} from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; -import { APP_CONFIG } from 'src/config/app-config.interface'; -import { environment } from 'src/environments/environment'; - -import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; -import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; -import { MockBitstreamFormat1 } from '../../../../shared/mocks/item.mock'; -import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; -import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; -import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; -import { createPaginatedList } from '../../../../shared/testing/utils.test'; -import { FileSizePipe } from '../../../../shared/utils/file-size-pipe'; -import { VarDirective } from '../../../../shared/utils/var.directive'; -import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; -import { ItemSecureFileDownloadLinkComponent } from '../file-download-link/item-secure-file-download-link.component'; -import { ItemSecureFileSectionComponent } from './item-secure-file-section.component'; - -describe('FullFileSectionComponent', () => { - let comp: ItemSecureFileSectionComponent; - let fixture: ComponentFixture; - - const mockBitstream: Bitstream = Object.assign(new Bitstream(), { - sizeBytes: 10201, - content: 'test-content-url', - format: observableOf(MockBitstreamFormat1), - bundleName: 'ORIGINAL', - id: 'test-id', - _links: { - self: { href: 'test-href' }, - content: { href: 'test-content-href' }, - }, - }); - - const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { - findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([mockBitstream, mockBitstream, mockBitstream])), - }); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock, - }, - }), - BrowserAnimationsModule, - ItemSecureFileSectionComponent, - VarDirective, - FileSizePipe, - MetadataFieldWrapperComponent, - ], - providers: [ - provideMockStore(), - { provide: BitstreamDataService, useValue: bitstreamDataService }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() }, - { provide: PaginationService, useValue: new PaginationServiceStub() }, - { provide: APP_CONFIG, useValue: environment }, - ], - schemas: [NO_ERRORS_SCHEMA], - }).overrideComponent(ItemSecureFileSectionComponent, { - remove: { imports: [PaginationComponent, MetadataFieldWrapperComponent,ItemSecureFileDownloadLinkComponent, ThemedThumbnailComponent, ThemedFileDownloadLinkComponent] }, - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ItemSecureFileSectionComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); - }); - - describe('when the full file section gets loaded with bitstreams available', () => { - it('should contain a list with bitstreams', () => { - const fileSection = fixture.debugElement.queryAll(By.css('.file-section')); - expect(fileSection.length).toEqual(6); - }); - }); -}); diff --git a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.ts b/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.ts deleted file mode 100644 index de1602a7f7..0000000000 --- a/src/app/item-page/access-by-token/field-components/file-section/item-secure-file-section.component.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - Inject, - Input, - OnDestroy, - OnInit, -} from '@angular/core'; -import { - TranslateModule, - TranslateService, -} from '@ngx-translate/core'; -import { Observable } from 'rxjs'; -import { - switchMap, - tap, -} from 'rxjs/operators'; -import { - APP_CONFIG, - AppConfig, -} from 'src/config/app-config.interface'; - -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; -import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; -import { PaginatedList } from '../../../../core/data/paginated-list.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { Item } from '../../../../core/shared/item.model'; -import { ItemRequest } from '../../../../core/shared/item-request.model'; -import { - hasValue, - isEmpty, -} from '../../../../shared/empty.util'; -import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; -import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; -import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; -import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; -import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; -import { FileSizePipe } from '../../../../shared/utils/file-size-pipe'; -import { followLink } from '../../../../shared/utils/follow-link-config.model'; -import { VarDirective } from '../../../../shared/utils/var.directive'; -import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; -import { FileSectionComponent } from '../../../simple/field-components/file-section/file-section.component'; -import { ItemSecureFileDownloadLinkComponent } from '../file-download-link/item-secure-file-download-link.component'; - -/** - * This component renders the file section of the item - * inside a 'ds-metadata-field-wrapper' component. - */ - -@Component({ - selector: 'ds-item-secure-full-file-section', - styleUrls: ['./item-secure-file-section.component.scss'], - templateUrl: './item-secure-file-section.component.html', - standalone: true, - imports: [ - ItemSecureFileDownloadLinkComponent, - CommonModule, - ThemedFileDownloadLinkComponent, - MetadataFieldWrapperComponent, - ThemedLoadingComponent, - TranslateModule, - FileSizePipe, - VarDirective, - PaginationComponent, - ThemedThumbnailComponent, - ], -}) -export class ItemSecureFileSectionComponent extends FileSectionComponent implements OnDestroy, OnInit { - - @Input() item: Item; - @Input() itemRequest: ItemRequest; - - label: string; - - originals$: Observable>>; - licenses$: Observable>>; - - originalOptions = Object.assign(new PaginationComponentOptions(), { - id: 'obo', - currentPage: 1, - pageSize: this.appConfig.item.bitstream.pageSize, - }); - - licenseOptions = Object.assign(new PaginationComponentOptions(), { - id: 'lbo', - currentPage: 1, - pageSize: this.appConfig.item.bitstream.pageSize, - }); - - constructor( - bitstreamDataService: BitstreamDataService, - protected notificationsService: NotificationsService, - protected translateService: TranslateService, - protected paginationService: PaginationService, - public dsoNameService: DSONameService, - @Inject(APP_CONFIG) protected appConfig: AppConfig, - ) { - super(bitstreamDataService, notificationsService, translateService, dsoNameService, appConfig); - } - - ngOnInit(): void { - this.initialize(); - } - - initialize(): void { - this.originals$ = this.paginationService.getCurrentPagination(this.originalOptions.id, this.originalOptions).pipe( - switchMap((options: PaginationComponentOptions) => this.bitstreamDataService.findAllByItemAndBundleName( - this.item, - 'ORIGINAL', - { elementsPerPage: options.pageSize, currentPage: options.currentPage }, - true, - true, - followLink('format'), - followLink('thumbnail'), - )), - tap((rd: RemoteData>) => { - if (hasValue(rd.errorMessage)) { - this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.statusCode} ${rd.errorMessage}`); - } - }, - ), - ); - - this.licenses$ = this.paginationService.getCurrentPagination(this.licenseOptions.id, this.licenseOptions).pipe( - switchMap((options: PaginationComponentOptions) => this.bitstreamDataService.findAllByItemAndBundleName( - this.item, - 'LICENSE', - { elementsPerPage: options.pageSize, currentPage: options.currentPage }, - true, - true, - followLink('format'), - followLink('thumbnail'), - )), - tap((rd: RemoteData>) => { - if (hasValue(rd.errorMessage)) { - this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.statusCode} ${rd.errorMessage}`); - } - }, - ), - ); - - } - - hasValuesInBundle(bundle: PaginatedList) { - return hasValue(bundle) && !isEmpty(bundle.page); - } - - ngOnDestroy(): void { - this.paginationService.clearPagination(this.originalOptions.id); - this.paginationService.clearPagination(this.licenseOptions.id); - } - -} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.html b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.html deleted file mode 100644 index 6acb4299ee..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.html +++ /dev/null @@ -1,35 +0,0 @@ - - -
- - - - - - - - - -
- - - - - -
diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.scss b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.scss deleted file mode 100644 index 8b13789179..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.scss +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.spec.ts b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.spec.ts deleted file mode 100644 index afc77ddb37..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { - TranslateLoader, - TranslateModule, -} from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; - -import { AuthService } from '../../../../core/auth/auth.service'; -import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { MediaViewerItem } from '../../../../core/shared/media-viewer-item.model'; -import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; -import { AuthServiceMock } from '../../../../shared/mocks/auth.service.mock'; -import { MockBitstreamFormat1 } from '../../../../shared/mocks/item.mock'; -import { getMockThemeService } from '../../../../shared/mocks/theme-service.mock'; -import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { createPaginatedList } from '../../../../shared/testing/utils.test'; -import { ThemeService } from '../../../../shared/theme-support/theme.service'; -import { FileSizePipe } from '../../../../shared/utils/file-size-pipe'; -import { VarDirective } from '../../../../shared/utils/var.directive'; -import { ItemSecureMediaViewerComponent } from './item-secure-media-viewer.component'; - -describe('ItemSecureMediaViewerComponent', () => { - let comp: ItemSecureMediaViewerComponent; - let fixture: ComponentFixture; - - const mockBitstream: Bitstream = Object.assign(new Bitstream(), { - sizeBytes: 10201, - content: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', - format: observableOf(MockBitstreamFormat1), - bundleName: 'ORIGINAL', - _links: { - self: { - href: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', - }, - content: { - href: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', - }, - }, - id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - type: 'bitstream', - metadata: { - 'dc.title': [ - { - language: null, - value: 'test_word.docx', - }, - ], - }, - }); - - const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { - findAllByItemAndBundleName: createSuccessfulRemoteDataObject$( - createPaginatedList([mockBitstream]), - ), - }); - - const mockMediaViewerItem: MediaViewerItem = Object.assign( - new MediaViewerItem(), - { bitstream: mockBitstream, format: 'image', thumbnail: null }, - ); - - beforeEach(waitForAsync(() => { - return TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock, - }, - }), - BrowserAnimationsModule, - ItemSecureMediaViewerComponent, - VarDirective, - FileSizePipe, - MetadataFieldWrapperComponent, - ], - providers: [ - { provide: BitstreamDataService, useValue: bitstreamDataService }, - { provide: ThemeService, useValue: getMockThemeService() }, - { provide: AuthService, useValue: new AuthServiceMock() }, - ], - schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ItemSecureMediaViewerComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); - }); - - describe('when the bitstreams are loading', () => { - beforeEach(() => { - comp.mediaList$.next([mockMediaViewerItem]); - comp.mediaOptions = { - image: true, - video: true, - }; - comp.isLoading = true; - fixture.detectChanges(); - }); - - it('should call the createMediaViewerItem', () => { - const mediaItem = comp.createMediaViewerItem( - mockBitstream, - MockBitstreamFormat1, - undefined, - ); - expect(mediaItem).toBeTruthy(); - expect(mediaItem.thumbnail).toBe(null); - }); - - it('should display a loading component', () => { - const loading = fixture.debugElement.query(By.css('ds-loading')); - expect(loading.nativeElement).toBeDefined(); - }); - }); - - describe('when the bitstreams loading is failed', () => { - beforeEach(() => { - comp.mediaList$.next([]); - comp.mediaOptions = { - image: true, - video: true, - }; - comp.isLoading = false; - fixture.detectChanges(); - }); - - it('should call the createMediaViewerItem', () => { - const mediaItem = comp.createMediaViewerItem( - mockBitstream, - MockBitstreamFormat1, - undefined, - ); - expect(mediaItem).toBeTruthy(); - expect(mediaItem.thumbnail).toBe(null); - }); - - it('should display a default, thumbnail', () => { - const defaultThumbnail = fixture.debugElement.query( - By.css('ds-secure-media-viewer-image'), - ); - expect(defaultThumbnail.nativeElement).toBeDefined(); - }); - }); -}); diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.ts b/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.ts deleted file mode 100644 index fb06d0ec95..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/item-secure-media-viewer.component.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - AsyncPipe, - NgIf, -} from '@angular/common'; -import { - ChangeDetectorRef, - Component, - Input, - OnDestroy, - OnInit, -} from '@angular/core'; -import { TranslateModule } from '@ngx-translate/core'; -import { - BehaviorSubject, - Observable, - Subscription, -} from 'rxjs'; -import { - filter, - take, -} from 'rxjs/operators'; - -import { MediaViewerConfig } from '../../../../../config/media-viewer-config.interface'; -import { environment } from '../../../../../environments/environment'; -import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; -import { PaginatedList } from '../../../../core/data/paginated-list.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; -import { Item } from '../../../../core/shared/item.model'; -import { MediaViewerItem } from '../../../../core/shared/media-viewer-item.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; -import { hasValue } from '../../../../shared/empty.util'; -import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; -import { followLink } from '../../../../shared/utils/follow-link-config.model'; -import { VarDirective } from '../../../../shared/utils/var.directive'; -import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; -import { SecureMediaViewerImageComponent } from './media-viewer-image/secure-media-viewer-image.component'; -import { SecureMediaViewerVideoComponent } from './media-viewer-video/secure-media-viewer-video.component'; - -/** - * This component renders the media viewers - */ -@Component({ - selector: 'ds-item-secure-media-viewer', - templateUrl: './item-secure-media-viewer.component.html', - styleUrls: ['./item-secure-media-viewer.component.scss'], - imports: [ - AsyncPipe, - NgIf, - TranslateModule, - ThemedLoadingComponent, - VarDirective, - SecureMediaViewerVideoComponent, - SecureMediaViewerImageComponent, - ThemedThumbnailComponent, - ], - standalone: true, -}) -export class ItemSecureMediaViewerComponent implements OnDestroy, OnInit { - @Input() item: Item; - - @Input() mediaOptions: MediaViewerConfig = environment.mediaViewer; - - @Input() accessToken: string; - - mediaList$: BehaviorSubject = new BehaviorSubject([]); - - captions$: BehaviorSubject = new BehaviorSubject([]); - - isLoading = true; - - thumbnailPlaceholder = './assets/images/replacement_document.svg'; - - thumbnailsRD$: Observable>>; - - subs: Subscription[] = []; - - constructor( - protected bitstreamDataService: BitstreamDataService, - protected changeDetectorRef: ChangeDetectorRef, - ) { - } - - ngOnDestroy(): void { - this.subs.forEach((subscription: Subscription) => subscription.unsubscribe()); - } - - /** - * This method loads all the Bitstreams and Thumbnails and converts it to {@link MediaViewerItem}s - */ - ngOnInit(): void { - const types: string[] = [ - ...(this.mediaOptions.image ? ['image'] : []), - ...(this.mediaOptions.video ? ['audio', 'video'] : []), - ]; - this.thumbnailsRD$ = this.loadRemoteData('THUMBNAIL'); - this.subs.push(this.loadRemoteData('ORIGINAL').subscribe((bitstreamsRD: RemoteData>) => { - if (bitstreamsRD.payload.page.length === 0) { - this.isLoading = false; - this.mediaList$.next([]); - } else { - this.subs.push(this.thumbnailsRD$.subscribe((thumbnailsRD: RemoteData>) => { - for ( - let index = 0; - index < bitstreamsRD.payload.page.length; - index++ - ) { - this.subs.push(bitstreamsRD.payload.page[index].format - .pipe(getFirstSucceededRemoteDataPayload()) - .subscribe((format: BitstreamFormat) => { - const mediaItem = this.createMediaViewerItem( - bitstreamsRD.payload.page[index], - format, - thumbnailsRD.payload && thumbnailsRD.payload.page[index], - ); - if (types.includes(mediaItem.format)) { - this.mediaList$.next([...this.mediaList$.getValue(), mediaItem]); - } else if (format.mimetype === 'text/vtt') { - this.captions$.next([...this.captions$.getValue(), bitstreamsRD.payload.page[index]]); - } - })); - } - this.isLoading = false; - this.changeDetectorRef.detectChanges(); - })); - } - })); - } - - /** - * This method will retrieve the next page of Bitstreams from the external BitstreamDataService call. - * @param bundleName Bundle name - */ - loadRemoteData( - bundleName: string, - ): Observable>> { - return this.bitstreamDataService - .findAllByItemAndBundleName( - this.item, - bundleName, - {}, - true, - true, - followLink('format'), - ) - .pipe( - filter( - (bitstreamsRD: RemoteData>) => - hasValue(bitstreamsRD) && - (hasValue(bitstreamsRD.errorMessage) || hasValue(bitstreamsRD.payload)), - ), - take(1), - ); - } - - /** - * This method creates a {@link MediaViewerItem} from incoming {@link Bitstream}s - * @param original original bitstream - * @param format original bitstream format - * @param thumbnail thumbnail bitstream - */ - createMediaViewerItem(original: Bitstream, format: BitstreamFormat, thumbnail: Bitstream): MediaViewerItem { - const mediaItem = new MediaViewerItem(); - mediaItem.bitstream = original; - mediaItem.format = format.mimetype.split('/')[0]; - mediaItem.mimetype = format.mimetype; - mediaItem.thumbnail = thumbnail ? thumbnail._links.content.href : null; - return mediaItem; - } -} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.html b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.html deleted file mode 100644 index bafc6f079c..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.html +++ /dev/null @@ -1,7 +0,0 @@ -
- -
diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.scss b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.scss deleted file mode 100644 index cba963b6fa..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.scss +++ /dev/null @@ -1,20 +0,0 @@ -:host ::ng-deep { - .ngx-gallery { - width: unset !important; - height: unset !important; - } - - ngx-gallery-image { - max-width: 340px !important; - - .ngx-gallery-image { - background-position: left; - } - } - - ngx-gallery-image:after { - padding-top: 75%; - display: block; - content: ''; - } -} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.spec.ts b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.spec.ts deleted file mode 100644 index 536c073e41..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { NgxGalleryOptions } from '@kolkov/ngx-gallery'; -import { of as observableOf } from 'rxjs'; - -import { AuthService } from '../../../../../core/auth/auth.service'; -import { Bitstream } from '../../../../../core/shared/bitstream.model'; -import { MediaViewerItem } from '../../../../../core/shared/media-viewer-item.model'; -import { MockBitstreamFormat1 } from '../../../../../shared/mocks/item.mock'; -import { SecureMediaViewerImageComponent } from './secure-media-viewer-image.component'; - -describe('ItemSecureMediaViewerImageComponent', () => { - let component: SecureMediaViewerImageComponent; - let fixture: ComponentFixture; - - const authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(false), - }); - - const mockBitstream: Bitstream = Object.assign(new Bitstream(), { - sizeBytes: 10201, - content: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', - format: observableOf(MockBitstreamFormat1), - bundleName: 'ORIGINAL', - _links: { - self: { - href: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', - }, - content: { - href: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', - }, - }, - id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - type: 'bitstream', - metadata: { - 'dc.title': [ - { - language: null, - value: 'test_word.docx', - }, - ], - }, - }); - - const mockMediaViewerItems: MediaViewerItem[] = Object.assign( - new Array(), - [ - { bitstream: mockBitstream, format: 'image', thumbnail: null }, - { bitstream: mockBitstream, format: 'image', thumbnail: null }, - ], - ); - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [SecureMediaViewerImageComponent], - schemas: [NO_ERRORS_SCHEMA], - providers: [ - { provide: AuthService, useValue: authService }, - ], - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(SecureMediaViewerImageComponent); - component = fixture.componentInstance; - component.galleryOptions = [new NgxGalleryOptions({})]; - component.galleryImages = component.convertToGalleryImage( - mockMediaViewerItems, - ); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should contain a gallery options', () => { - expect(component.galleryOptions.length).toBeGreaterThan(0); - }); - - it('should contain an image array', () => { - expect(component.galleryImages.length).toBeGreaterThan(0); - }); -}); diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.ts b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.ts deleted file mode 100644 index c6c9cec24a..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-image/secure-media-viewer-image.component.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { AsyncPipe } from '@angular/common'; -import { - Component, - Input, - OnChanges, - OnInit, -} from '@angular/core'; -import { - NgxGalleryAnimation, - NgxGalleryImage, - NgxGalleryModule, - NgxGalleryOptions, -} from '@kolkov/ngx-gallery'; -import { Observable } from 'rxjs'; - -import { AuthService } from '../../../../../core/auth/auth.service'; -import { MediaViewerItem } from '../../../../../core/shared/media-viewer-item.model'; - -/** - * This componenet render an image gallery for the image viewer - */ -@Component({ - selector: 'ds-secure-media-viewer-image', - templateUrl: './secure-media-viewer-image.component.html', - styleUrls: ['./secure-media-viewer-image.component.scss'], - imports: [ - NgxGalleryModule, - AsyncPipe, - ], - standalone: true, -}) -export class SecureMediaViewerImageComponent implements OnChanges, OnInit { - @Input() images: MediaViewerItem[]; - @Input() preview?: boolean; - @Input() image?: string; - @Input() accessToken: string; - - thumbnailPlaceholder = './assets/images/replacement_image.svg'; - - galleryOptions: NgxGalleryOptions[] = []; - - galleryImages: NgxGalleryImage[] = []; - - /** - * Whether or not the current user is authenticated - */ - isAuthenticated$: Observable; - - constructor( - protected authService: AuthService, - ) { - } - - ngOnChanges(): void { - this.galleryOptions = [ - { - preview: this.preview !== undefined ? this.preview : true, - image: true, - imageSize: 'contain', - thumbnails: false, - imageArrows: false, - startIndex: 0, - imageAnimation: NgxGalleryAnimation.Slide, - previewCloseOnEsc: true, - previewZoom: true, - previewRotate: true, - previewFullscreen: true, - }, - ]; - if (this.image) { - this.galleryImages = [ - { - small: this.image, - medium: this.image, - big: this.image, - }, - ]; - } else { - this.galleryImages = this.convertToGalleryImage(this.images); - } - } - - ngOnInit(): void { - this.isAuthenticated$ = this.authService.isAuthenticated(); - this.ngOnChanges(); - } - - /** - * This method convert an array of MediaViewerItem into NgxGalleryImage array - * @param medias input NgxGalleryImage array - */ - convertToGalleryImage(medias: MediaViewerItem[]): NgxGalleryImage[] { - const mappedImages = []; - for (const image of medias) { - if (image.format === 'image') { - mappedImages.push({ - small: image.thumbnail - ? image.thumbnail - : this.thumbnailPlaceholder, - medium: image.thumbnail - ? image.thumbnail - : this.thumbnailPlaceholder, - big: image.bitstream._links.content.href + '?accessToken=' + this.accessToken, - }); - } - } - return mappedImages; - } -} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.html b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.html deleted file mode 100644 index 8a6a447d9e..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.html +++ /dev/null @@ -1,54 +0,0 @@ - -
- - - -
- -
- -
-
-
diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.scss b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.scss deleted file mode 100644 index bb8b9d360e..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.scss +++ /dev/null @@ -1,10 +0,0 @@ -video { - width: 100%; - height: auto; - max-width: 340px; -} - -.buttons { - display: flex; - gap: .25rem; -} diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.spec.ts b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.spec.ts deleted file mode 100644 index 1e3282641c..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { - TranslateLoader, - TranslateModule, -} from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; - -import { Bitstream } from '../../../../../core/shared/bitstream.model'; -import { MediaViewerItem } from '../../../../../core/shared/media-viewer-item.model'; -import { MetadataFieldWrapperComponent } from '../../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; -import { MockBitstreamFormat1 } from '../../../../../shared/mocks/item.mock'; -import { TranslateLoaderMock } from '../../../../../shared/mocks/translate-loader.mock'; -import { FileSizePipe } from '../../../../../shared/utils/file-size-pipe'; -import { VarDirective } from '../../../../../shared/utils/var.directive'; -import { SecureMediaViewerVideoComponent } from './secure-media-viewer-video.component'; - -describe('SecureMediaViewerVideoComponent', () => { - let component: SecureMediaViewerVideoComponent; - let fixture: ComponentFixture; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock, - }, - }), - BrowserAnimationsModule, - SecureMediaViewerVideoComponent, - VarDirective, - FileSizePipe, - MetadataFieldWrapperComponent, - ], - schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); - })); - - const mockBitstream: Bitstream = Object.assign(new Bitstream(), { - sizeBytes: 10201, - content: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', - format: observableOf(MockBitstreamFormat1), - bundleName: 'ORIGINAL', - _links: { - self: { - href: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', - }, - content: { - href: - 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', - }, - }, - id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - type: 'bitstream', - metadata: { - 'dc.title': [ - { - language: null, - value: 'test_word.docx', - }, - ], - }, - }); - - const mockMediaViewerItems: MediaViewerItem[] = Object.assign( - new Array(), - [ - { bitstream: mockBitstream, format: 'video', thumbnail: null }, - { bitstream: mockBitstream, format: 'video', thumbnail: null }, - ], - ); - const mockMediaViewerItem: MediaViewerItem[] = Object.assign( - new Array(), - [{ bitstream: mockBitstream, format: 'video', thumbnail: null }], - ); - - beforeEach(() => { - fixture = TestBed.createComponent(SecureMediaViewerVideoComponent); - component = fixture.componentInstance; - component.medias = mockMediaViewerItem; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('should show controller buttons when the having mode then one video', () => { - beforeEach(() => { - component.medias = mockMediaViewerItems; - fixture.detectChanges(); - }); - - it('should show buttons', () => { - const controllerButtons = fixture.debugElement.query(By.css('.buttons')); - expect(controllerButtons).toBeTruthy(); - }); - - describe('when the "Next" button is clicked', () => { - beforeEach(() => { - component.currentIndex = 0; - fixture.detectChanges(); - }); - - it('should increase the index', () => { - const viewMore = fixture.debugElement.query(By.css('.next')); - viewMore.triggerEventHandler('click', null); - expect(component.currentIndex).toBe(1); - }); - }); - - describe('when the "Previous" button is clicked', () => { - beforeEach(() => { - component.currentIndex = 1; - fixture.detectChanges(); - }); - - it('should decrease the index', () => { - const viewMore = fixture.debugElement.query(By.css('.previous')); - viewMore.triggerEventHandler('click', null); - expect(component.currentIndex).toBe(0); - }); - }); - - describe('when the "Playlist element" button is clicked', () => { - beforeEach(() => { - component.isCollapsed = true; - fixture.detectChanges(); - }); - - it('should set the the index with the selected one', () => { - const viewMore = fixture.debugElement.query(By.css('.list-element')); - viewMore.triggerEventHandler('click', null); - expect(component.currentIndex).toBe(0); - }); - }); - }); -}); diff --git a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.ts b/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.ts deleted file mode 100644 index 75f597c8a2..0000000000 --- a/src/app/item-page/access-by-token/field-components/media-viewer/media-viewer-video/secure-media-viewer-video.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - NgForOf, - NgIf, -} from '@angular/common'; -import { - Component, - Input, -} from '@angular/core'; -import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateModule } from '@ngx-translate/core'; -import { Bitstream } from 'src/app/core/shared/bitstream.model'; - -import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; -import { MediaViewerItem } from '../../../../../core/shared/media-viewer-item.model'; -import { BtnDisabledDirective } from '../../../../../shared/btn-disabled.directive'; -import { CaptionInfo } from '../../../../media-viewer/media-viewer-video/caption-info'; -import { languageHelper } from '../../../../media-viewer/media-viewer-video/language-helper'; - -/** - * This component renders a video viewer and playlist for the media viewer - */ -@Component({ - selector: 'ds-secure-media-viewer-video', - templateUrl: './secure-media-viewer-video.component.html', - styleUrls: ['./secure-media-viewer-video.component.scss'], - imports: [ - NgForOf, - NgbDropdownModule, - TranslateModule, - NgIf, - BtnDisabledDirective, - ], - standalone: true, -}) -export class SecureMediaViewerVideoComponent { - @Input() medias: MediaViewerItem[]; - - @Input() captions: Bitstream[] = []; - - @Input() accessToken: string; - - isCollapsed = false; - - currentIndex = 0; - - replacements = { - video: './assets/images/replacement_video.svg', - audio: './assets/images/replacement_audio.svg', - }; - - constructor( - public dsoNameService: DSONameService, - ) { - } - - /** - * This method check if there is caption file for the media - * The caption file name is the media name plus "-" following two letter - * language code and .vtt suffix - * - * html5 video only support WEBVTT format - * - * Two letter language code reference - * https://www.w3schools.com/tags/ref_language_codes.asp - */ - getMediaCap(name: string, captions: Bitstream[]): CaptionInfo[] { - const capInfos: CaptionInfo[] = []; - const filteredCapMedias: Bitstream[] = captions - .filter((media: Bitstream) => media.name.substring(0, (media.name.length - 7)).toLowerCase() === name.toLowerCase()); - - for (const media of filteredCapMedias) { - const srclang: string = media.name.slice(-6, -4).toLowerCase(); - capInfos.push(new CaptionInfo( - media._links.content.href + '?accessToken=' + this.accessToken, - srclang, - languageHelper[srclang], - )); - } - return capInfos; - } - - /** - * This method sets the received index into currentIndex - * @param index Selected index - */ - selectedMedia(index: number) { - this.currentIndex = index; - } - - /** - * This method increases the number of the currentIndex - */ - nextMedia() { - this.currentIndex++; - } - - /** - * This method decreases the number of the currentIndex - */ - prevMedia() { - this.currentIndex--; - } -} diff --git a/src/app/item-page/access-by-token/item-access-by-token-page.component.html b/src/app/item-page/access-by-token/item-access-by-token-page.component.html deleted file mode 100644 index a99f01858b..0000000000 --- a/src/app/item-page/access-by-token/item-access-by-token-page.component.html +++ /dev/null @@ -1,8 +0,0 @@ -
-
- - -
-
diff --git a/src/app/item-page/access-by-token/item-access-by-token-page.component.scss b/src/app/item-page/access-by-token/item-access-by-token-page.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/item-page/access-by-token/item-access-by-token-page.component.spec.ts b/src/app/item-page/access-by-token/item-access-by-token-page.component.spec.ts deleted file mode 100644 index e270b34669..0000000000 --- a/src/app/item-page/access-by-token/item-access-by-token-page.component.spec.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { KeyValuePipe } from '@angular/common'; -import { PLATFORM_ID } from '@angular/core'; -import { - ComponentFixture, - fakeAsync, - TestBed, -} from '@angular/core/testing'; -import { - ActivatedRoute, - Router, -} from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Store } from '@ngrx/store'; -import { TranslateModule } from '@ngx-translate/core'; -import { - BehaviorSubject, - of as observableOf, -} from 'rxjs'; - -import { getForbiddenRoute } from '../../app-routing-paths'; -import { AuthService } from '../../core/auth/auth.service'; -import { NotifyInfoService } from '../../core/coar-notify/notify-info/notify-info.service'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { ItemDataService } from '../../core/data/item-data.service'; -import { ItemRequestDataService } from '../../core/data/item-request-data.service'; -import { SignpostingDataService } from '../../core/data/signposting-data.service'; -import { LinkHeadService } from '../../core/services/link-head.service'; -import { ServerResponseService } from '../../core/services/server-response.service'; -import { Item } from '../../core/shared/item.model'; -import { ItemRequest } from '../../core/shared/item-request.model'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; -import { ItemAccessByTokenPageComponent } from './item-access-by-token-page.component'; -import { ItemAccessByTokenViewComponent } from './item-access-by-token-view.component'; - -describe('ItemAccessByTokenPageComponent', () => { - let component: ItemAccessByTokenPageComponent; - let fixture: ComponentFixture; - let itemRequestService: jasmine.SpyObj; - let router: jasmine.SpyObj; - let authorizationService: AuthorizationDataService; - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(false), - }); - let signpostingDataService: SignpostingDataService; - - const mocklink = { - href: 'http://test.org', - rel: 'test', - type: 'test', - }; - - const mocklink2 = { - href: 'http://test2.org', - rel: 'test', - type: 'test', - }; - signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { - getLinks: observableOf([mocklink, mocklink2]), - }); - const linkHeadService = jasmine.createSpyObj('linkHeadService', { - addTag: '', - }); - - const mockItem = Object.assign(new Item(), { - uuid: 'test-item-uuid', - id: 'test-item-id', - metadata: { - 'dspace.entity.type': [{ - value: 'Publication', - language: 'en', - place: 0, - authority: null, - confidence: -1, - }], - }, - _links: { - self: { href: 'obj-selflink' }, - }, - }); - - const mockItemRequest = Object.assign(new ItemRequest(), { - token: 'valid-token', - accessToken: 'valid-token', - itemId: mockItem.uuid, - }); - - const queryParams = { accessToken: 'valid-token' }; - const mockActivatedRoute = { - queryParams: new BehaviorSubject(queryParams), - data: observableOf({ - dso: createSuccessfulRemoteDataObject(mockItem), - }), - params: observableOf({ itemId: mockItem.uuid, queryParams: [ { accessToken: 'valid-token' } ] }), - children: [], - }; - itemRequestService = jasmine.createSpyObj('ItemRequestDataService', { - getSanitizedRequestByAccessToken: observableOf(createSuccessfulRemoteDataObject(mockItemRequest)), - }); - router = jasmine.createSpyObj('Router', ['navigateByUrl'], { - events: observableOf([]), - }); - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), - KeyValuePipe, - ], - providers: [ - { provide: ItemRequestDataService, useValue: itemRequestService }, - { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: ItemDataService, useValue: {} }, - { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: ServerResponseService, useValue: {} }, - { provide: SignpostingDataService, useValue: signpostingDataService }, - { provide: LinkHeadService, useValue: linkHeadService }, - { provide: NotifyInfoService, useValue: { isCoarConfigEnabled: () => observableOf(false) } }, - { provide: PLATFORM_ID, useValue: 'browser' }, - KeyValuePipe, - { - provide: Store, - useValue: { - pipe: () => observableOf({}), - dispatch: () => { - }, - select: () => observableOf({}), - }, - }, - { - provide: AuthService, useValue: { - isAuthenticated: () => observableOf(true), - }, - }, - ], - }).overrideComponent(ItemAccessByTokenPageComponent, { - set: { - template: '
', - }, - }).overrideComponent(ItemAccessByTokenViewComponent, { - set: { - template: '
', - }, - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ItemAccessByTokenPageComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - }); - - /** - * Tests in this component are concerned only with successful access token processing (or error handling) - * and a resulting item request object. Testing of template elements is out of scope and left for child components. - */ - describe('ngOnInit - basic component testing', () => { - it('should find valid access token and sanitize it', fakeAsync(() => { - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), - KeyValuePipe, - RouterTestingModule.withRoutes([]), - ], - providers: [ - { provide: ItemRequestDataService, useValue: itemRequestService }, - { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: AuthService, useValue: {} }, - { provide: ItemDataService, useValue: {} }, - { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: ServerResponseService, useValue: {} }, - { provide: SignpostingDataService, useValue: signpostingDataService }, - { provide: LinkHeadService, useValue: linkHeadService }, - { provide: NotifyInfoService, useValue: { isCoarConfigEnabled: () => observableOf(false) } }, - { provide: PLATFORM_ID, useValue: 'browser' }, - KeyValuePipe, - { - provide: Store, - useValue: { - pipe: () => observableOf({}), - dispatch: () => {}, - select: () => observableOf({}), - }, - }, - { provide: AuthService, useValue: { - isAuthenticated: () => observableOf(false ) }, - }, - ], - }).overrideComponent(ItemAccessByTokenViewComponent, { - set: { template: '
' } } ).compileComponents(); - - fixture = TestBed.createComponent(ItemAccessByTokenPageComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - expect(itemRequestService.getSanitizedRequestByAccessToken).toHaveBeenCalledWith('valid-token'); - - })); - - it('should process valid access token and load item request', fakeAsync(() => { - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), - KeyValuePipe, - RouterTestingModule.withRoutes([]), - ], - providers: [ - { provide: ItemRequestDataService, useValue: itemRequestService }, - { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: AuthService, useValue: {} }, - { provide: ItemDataService, useValue: {} }, - { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: ServerResponseService, useValue: {} }, - { provide: SignpostingDataService, useValue: signpostingDataService }, - { provide: LinkHeadService, useValue: linkHeadService }, - { provide: NotifyInfoService, useValue: { isCoarConfigEnabled: () => observableOf(false) } }, - { provide: PLATFORM_ID, useValue: 'browser' }, - KeyValuePipe, - { - provide: Store, - useValue: { - pipe: () => observableOf({}), - dispatch: () => {}, - select: () => observableOf({}), - }, - }, - { provide: AuthService, useValue: { - isAuthenticated: () => observableOf(false ) }, - }, - ], - }).overrideComponent(ItemAccessByTokenViewComponent, { - set: { template: '
' } } ).compileComponents(); - - fixture = TestBed.createComponent(ItemAccessByTokenPageComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - component.itemRequest$.subscribe((request) => { - expect(request).toBeTruthy(); - }); - })); - - it('should redirect to forbidden route when access token is missing', fakeAsync(() => { - const routeWithoutToken = { - queryParams: observableOf({}), - data: observableOf({ - dso: createSuccessfulRemoteDataObject(mockItem), - }), - params: observableOf({ itemId: mockItem.uuid }), - children: [], - }; - - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), - KeyValuePipe, - ], - providers: [ - { provide: ItemRequestDataService, useValue: itemRequestService }, - { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: routeWithoutToken }, - { provide: AuthService, useValue: {} }, - { provide: ItemDataService, useValue: {} }, - { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: ServerResponseService, useValue: {} }, - { provide: SignpostingDataService, useValue: signpostingDataService }, - { provide: LinkHeadService, useValue: linkHeadService }, - { provide: NotifyInfoService, useValue: { isCoarConfigEnabled: () => observableOf(false) } }, - { provide: PLATFORM_ID, useValue: 'browser' }, - { - provide: Store, - useValue: { - pipe: () => observableOf({}), - dispatch: () => {}, - select: () => observableOf({}), - }, - }, - { provide: AuthService, useValue: { - isAuthenticated: () => observableOf(false ) }, - }, - ], - }).overrideComponent(ItemAccessByTokenViewComponent, { - set: { - template: '
', - } }) - .compileComponents(); - - fixture = TestBed.createComponent(ItemAccessByTokenPageComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: false }); - })); - }); -}); - diff --git a/src/app/item-page/access-by-token/item-access-by-token-page.component.ts b/src/app/item-page/access-by-token/item-access-by-token-page.component.ts deleted file mode 100644 index 5250ac37a2..0000000000 --- a/src/app/item-page/access-by-token/item-access-by-token-page.component.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { - AsyncPipe, - KeyValuePipe, - Location, - NgForOf, - NgIf, -} from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - Inject, - OnDestroy, - OnInit, - PLATFORM_ID, -} from '@angular/core'; -import { - ActivatedRoute, - Router, - RouterLink, -} from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; -import { - filter, - map, - switchMap, - take, - tap, -} from 'rxjs/operators'; - -import { getForbiddenRoute } from '../../app-routing-paths'; -import { AuthService } from '../../core/auth/auth.service'; -import { NotifyInfoService } from '../../core/coar-notify/notify-info/notify-info.service'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { ItemDataService } from '../../core/data/item-data.service'; -import { ItemRequestDataService } from '../../core/data/item-request-data.service'; -import { SignpostingDataService } from '../../core/data/signposting-data.service'; -import { LinkHeadService } from '../../core/services/link-head.service'; -import { ServerResponseService } from '../../core/services/server-response.service'; -import { redirectOn4xx } from '../../core/shared/authorized.operators'; -import { ItemRequest } from '../../core/shared/item-request.model'; -import { - getFirstCompletedRemoteData, - getFirstSucceededRemoteDataPayload, -} from '../../core/shared/operators'; -import { fadeInOut } from '../../shared/animations/fade'; -import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; -import { hasValue } from '../../shared/empty.util'; -import { ErrorComponent } from '../../shared/error/error.component'; -import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; -import { MetadataFieldWrapperComponent } from '../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; -import { ThemedResultsBackButtonComponent } from '../../shared/results-back-button/themed-results-back-button.component'; -import { VarDirective } from '../../shared/utils/var.directive'; -import { ViewTrackerComponent } from '../../statistics/angulartics/dspace/view-tracker.component'; -import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component'; -import { ThemedItemAlertsComponent } from '../alerts/themed-item-alerts.component'; -import { CollectionsComponent } from '../field-components/collections/collections.component'; -import { ThemedFullFileSectionComponent } from '../full/field-components/file-section/themed-full-file-section.component'; -import { ThemedMediaViewerComponent } from '../media-viewer/themed-media-viewer.component'; -import { MiradorViewerComponent } from '../mirador-viewer/mirador-viewer.component'; -import { ThemedFileSectionComponent } from '../simple/field-components/file-section/themed-file-section.component'; -import { ItemPageAbstractFieldComponent } from '../simple/field-components/specific-field/abstract/item-page-abstract-field.component'; -import { ItemPageDateFieldComponent } from '../simple/field-components/specific-field/date/item-page-date-field.component'; -import { GenericItemPageFieldComponent } from '../simple/field-components/specific-field/generic/generic-item-page-field.component'; -import { ThemedItemPageTitleFieldComponent } from '../simple/field-components/specific-field/title/themed-item-page-field.component'; -import { ItemPageUriFieldComponent } from '../simple/field-components/specific-field/uri/item-page-uri-field.component'; -import { ItemPageComponent } from '../simple/item-page.component'; -import { ThemedMetadataRepresentationListComponent } from '../simple/metadata-representation-list/themed-metadata-representation-list.component'; -import { ItemVersionsComponent } from '../versions/item-versions.component'; -import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; -import { ItemSecureFileSectionComponent } from './field-components/file-section/item-secure-file-section.component'; -import { ItemAccessByTokenViewComponent } from './item-access-by-token-view.component'; - -@Component({ - selector: 'ds-access-by-token-item-page', - styleUrls: ['./item-access-by-token-page.component.scss'], - templateUrl: './item-access-by-token-page.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut], - standalone: true, - imports: [ - ErrorComponent, - ThemedLoadingComponent, - TranslateModule, - ThemedFullFileSectionComponent, - CollectionsComponent, - ItemVersionsComponent, - NgIf, - NgForOf, - AsyncPipe, - KeyValuePipe, - RouterLink, - ThemedItemPageTitleFieldComponent, - DsoEditMenuComponent, - ItemVersionsNoticeComponent, - ViewTrackerComponent, - ThemedItemAlertsComponent, - VarDirective, - ItemSecureFileSectionComponent, - GenericItemPageFieldComponent, - ItemPageAbstractFieldComponent, - ItemPageDateFieldComponent, - ItemPageUriFieldComponent, - MetadataFieldWrapperComponent, - MiradorViewerComponent, - ThemedFileSectionComponent, - ThemedMediaViewerComponent, - ThemedMetadataRepresentationListComponent, - ThemedResultsBackButtonComponent, - ThemedThumbnailComponent, - ItemAccessByTokenViewComponent, - ], -}) -export class ItemAccessByTokenPageComponent extends ItemPageComponent implements OnInit, OnDestroy { - - itemRequest$: Observable; - - constructor( - protected route: ActivatedRoute, - protected router: Router, - protected items: ItemDataService, - protected authService: AuthService, - protected authorizationService: AuthorizationDataService, - protected _location: Location, - protected responseService: ServerResponseService, - protected signpostingDataService: SignpostingDataService, - protected linkHeadService: LinkHeadService, - protected notifyInfoService: NotifyInfoService, - private itemRequestDataService: ItemRequestDataService, - @Inject(PLATFORM_ID) protected platformId: string, - ) { - super(route, router, items, authorizationService, responseService, signpostingDataService, linkHeadService, notifyInfoService, platformId); - } - - protected readonly hasValue = hasValue; - - /** - * Initialise this component - * 1. take the access token from the query params and complete the stream - * 2. test for access token or redirect to forbidden page - * 3. get the sanitized token, make sure it is valid (if not, redirect to forbidden page) - * 4. return observable to itemRequest$ for the view to subscribe to - */ - ngOnInit(): void { - this.itemRequest$ = this.route.queryParams.pipe( - take(1), - map(params => { - if (!hasValue(params?.accessToken)) { - this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: false }); - return null; - } - return params.accessToken; - }), - filter(token => hasValue(token)), - switchMap(token => this.itemRequestDataService.getSanitizedRequestByAccessToken(token)), - getFirstCompletedRemoteData(), - redirectOn4xx(this.router, this.authService), - getFirstSucceededRemoteDataPayload(), - tap(request => { - if (!hasValue(request)) { - this.router.navigateByUrl(getForbiddenRoute()); - } - }), - ); - - // Call item page component initialization. - super.ngOnInit(); - } - - /** - * Navigate back in browser history. - */ - back() { - this._location.back(); - } - -} - diff --git a/src/app/item-page/access-by-token/item-access-by-token-view.component.html b/src/app/item-page/access-by-token/item-access-by-token-view.component.html deleted file mode 100644 index 047966e615..0000000000 --- a/src/app/item-page/access-by-token/item-access-by-token-view.component.html +++ /dev/null @@ -1,101 +0,0 @@ - - -
-
- - -
-
- -
- - - -
-
-

{{'bitstream-request-a-copy.access-by-token.warning' | translate}}

-

{{ 'bitstream-request-a-copy.access-by-token.expiry-label' | translate }} {{ getAccessPeriodEndDate() }}

-
-
-
- - - - - -
- - -
- - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
-
- - -
-
-
diff --git a/src/app/item-page/access-by-token/item-access-by-token-view.component.scss b/src/app/item-page/access-by-token/item-access-by-token-view.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/item-page/access-by-token/item-access-by-token-view.component.spec.ts b/src/app/item-page/access-by-token/item-access-by-token-view.component.spec.ts deleted file mode 100644 index 78e4544b6c..0000000000 --- a/src/app/item-page/access-by-token/item-access-by-token-view.component.spec.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { provideMockStore } from '@ngrx/store/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { - of as observableOf, - of, -} from 'rxjs'; - -import { - APP_CONFIG, - APP_DATA_SERVICES_MAP, -} from '../../../config/app-config.interface'; -import { environment } from '../../../environments/environment'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { ItemRequestDataService } from '../../core/data/item-request-data.service'; -import { Bitstream } from '../../core/shared/bitstream.model'; -import { Item } from '../../core/shared/item.model'; -import { ItemRequest } from '../../core/shared/item-request.model'; -import { ITEM_REQUEST } from '../../core/shared/item-request.resource-type'; -import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; -import { ErrorComponent } from '../../shared/error/error.component'; -import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; -import { ThemedResultsBackButtonComponent } from '../../shared/results-back-button/themed-results-back-button.component'; -import { RouterLinkDirectiveStub } from '../../shared/testing/router-link-directive.stub'; -import { ViewTrackerComponent } from '../../statistics/angulartics/dspace/view-tracker.component'; -import { ThemedItemAlertsComponent } from '../alerts/themed-item-alerts.component'; -import { CollectionsComponent } from '../field-components/collections/collections.component'; -import { ThemedFullFileSectionComponent } from '../full/field-components/file-section/themed-full-file-section.component'; -import { ThemedMediaViewerComponent } from '../media-viewer/themed-media-viewer.component'; -import { MiradorViewerComponent } from '../mirador-viewer/mirador-viewer.component'; -import { ThemedFileSectionComponent } from '../simple/field-components/file-section/themed-file-section.component'; -import { ThemedMetadataRepresentationListComponent } from '../simple/metadata-representation-list/themed-metadata-representation-list.component'; -import { ItemVersionsComponent } from '../versions/item-versions.component'; -import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; -import { ItemSecureFileDownloadLinkComponent } from './field-components/file-download-link/item-secure-file-download-link.component'; -import { ItemSecureFileSectionComponent } from './field-components/file-section/item-secure-file-section.component'; -import { ItemSecureMediaViewerComponent } from './field-components/media-viewer/item-secure-media-viewer.component'; -import { ItemAccessByTokenViewComponent } from './item-access-by-token-view.component'; - - -describe('ItemAccessByTokenViewComponent', () => { - let authorizationService: AuthorizationDataService; - let itemRequestDataService: ItemRequestDataService; - let bitstream: Bitstream; - let item: Item; - let itemRequest: ItemRequest; - let component: ItemAccessByTokenViewComponent; - let fixture: ComponentFixture; - let routeStub: any; - - function init() { - itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { - canDownload: observableOf(true), - }); - bitstream = Object.assign(new Bitstream(), { - uuid: 'bitstreamUuid', - }); - item = Object.assign(new Item(), { - uuid: 'itemUuid', - metadata: { - 'dspace.entity.type': [ - { - value: 'Publication', - }, - ], - }, - _links: { - self: { href: 'obj-selflink' }, - }, - }); - routeStub = { - data: observableOf({ - dso: createSuccessfulRemoteDataObject(item), - }), - children: [], - }; - - const mockItemRequest: ItemRequest = Object.assign(new ItemRequest(), { - - }); - itemRequest = Object.assign(new ItemRequest(), - { - itemId: item.uuid, - bitstreamId: bitstream.uuid, - allfiles: false, - requestEmail: 'user@name.org', - requestName: 'User Name', - requestMessage: 'I would like to request a copy', - accessPeriod: 3600, - decisionDate: new Date().toISOString(), - token: 'test-token', - type: ITEM_REQUEST, - requestDate: new Date().toISOString(), - accessToken: 'test-token', - expires: null, - acceptRequest: true, - }); - } - - function initTestbed() { - - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), ItemSecureFileDownloadLinkComponent, - RouterLinkDirectiveStub, - ], - providers: [ - { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: ActivatedRoute, useValue: routeStub }, - { provide: RouterLinkDirectiveStub }, - { provide: ItemRequestDataService, useValue: itemRequestDataService }, - provideMockStore(), - { provide: APP_DATA_SERVICES_MAP, useValue: {} }, - { provide: APP_CONFIG, useValue: environment }, - ], - schemas: [NO_ERRORS_SCHEMA], - }).overrideComponent(ItemAccessByTokenViewComponent, { - remove: { - imports: [ - ErrorComponent, - ThemedLoadingComponent, - ThemedFullFileSectionComponent, - CollectionsComponent, - ItemVersionsComponent, - DsoEditMenuComponent, - ItemVersionsNoticeComponent, - ViewTrackerComponent, - ThemedItemAlertsComponent, - ItemSecureFileSectionComponent, - MiradorViewerComponent, - ThemedFileSectionComponent, - ThemedMediaViewerComponent, - ThemedMetadataRepresentationListComponent, - ThemedResultsBackButtonComponent, - ItemSecureMediaViewerComponent, - ], - }, - }).compileComponents(); - } - - const mockItem = Object.assign(new Item(), { - uuid: 'test-item-uuid', - id: 'test-item-id', - }); - - - - - beforeEach(waitForAsync(() => { - init(); - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true), - }); - initTestbed(); - })); - beforeEach(() => { - fixture = TestBed.createComponent(ItemAccessByTokenViewComponent); - component = fixture.componentInstance; - component.object = item; - component.itemRequest$ = of(itemRequest); - component.itemRequestSubject.next(itemRequest); - fixture.detectChanges(); - }); - - describe('Component and inputs initialised properly', () => { - it('should initialize with valid ItemRequest input', () => { - //component.itemRequestSubject.next(itemRequest); - component.itemRequest$.subscribe(request => { - expect(request).toBeDefined(); - expect(request.accessPeriod).toBe(3600); - expect(request.token).toBe('test-token'); - expect(request.requestName).toBe('User Name'); - expect(request.requestEmail).toBe('user@name.org'); - expect(request.requestMessage).toBe('I would like to request a copy'); - expect(request.allfiles).toBe(false); - expect(request.bitstreamId).toBe(bitstream.uuid); - expect(request.acceptRequest).toBe(true); - }); - }); - }); - - describe('getAccessPeriodEndDate', () => { - it('should calculate correct end date based on decision date and access period', () => { - const testDecisionDate = '2024-01-01T00:00:00Z'; - const testAccessPeriod = 3600; - - const testRequest = { - ...itemRequest, - decisionDate: testDecisionDate, - accessPeriod: testAccessPeriod, - }; - component.itemRequest$ = of(testRequest); - component.itemRequestSubject.next(testRequest); - const expectedDate = new Date(testDecisionDate); - expectedDate.setUTCSeconds(expectedDate.getUTCSeconds() + testAccessPeriod); - - expect(component.getAccessPeriodEndDate()).toEqual(expectedDate); - }); - - it('should return undefined when access period is 0', () => { - component.itemRequestSubject.next({ ...itemRequest, accessPeriod: 0 }); - expect(component.getAccessPeriodEndDate()).toBeUndefined(); - }); - }); -}); - diff --git a/src/app/item-page/access-by-token/item-access-by-token-view.component.ts b/src/app/item-page/access-by-token/item-access-by-token-view.component.ts deleted file mode 100644 index 61f7679cfc..0000000000 --- a/src/app/item-page/access-by-token/item-access-by-token-view.component.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - AsyncPipe, - KeyValuePipe, - NgForOf, - NgIf, -} from '@angular/common'; -import { - Component, - Input, - OnInit, -} from '@angular/core'; -import { - Router, - RouterLink, -} from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; -import { - BehaviorSubject, - Observable, -} from 'rxjs'; -import { filter } from 'rxjs/operators'; - -import { RouteService } from '../../core/services/route.service'; -import { ItemRequest } from '../../core/shared/item-request.model'; -import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; -import { hasValue } from '../../shared/empty.util'; -import { ErrorComponent } from '../../shared/error/error.component'; -import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; -import { MetadataFieldWrapperComponent } from '../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; -import { ThemedResultsBackButtonComponent } from '../../shared/results-back-button/themed-results-back-button.component'; -import { VarDirective } from '../../shared/utils/var.directive'; -import { ViewTrackerComponent } from '../../statistics/angulartics/dspace/view-tracker.component'; -import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component'; -import { ThemedItemAlertsComponent } from '../alerts/themed-item-alerts.component'; -import { CollectionsComponent } from '../field-components/collections/collections.component'; -import { ThemedFullFileSectionComponent } from '../full/field-components/file-section/themed-full-file-section.component'; -import { ThemedMediaViewerComponent } from '../media-viewer/themed-media-viewer.component'; -import { MiradorViewerComponent } from '../mirador-viewer/mirador-viewer.component'; -import { ThemedFileSectionComponent } from '../simple/field-components/file-section/themed-file-section.component'; -import { ItemPageAbstractFieldComponent } from '../simple/field-components/specific-field/abstract/item-page-abstract-field.component'; -import { ItemPageDateFieldComponent } from '../simple/field-components/specific-field/date/item-page-date-field.component'; -import { GenericItemPageFieldComponent } from '../simple/field-components/specific-field/generic/generic-item-page-field.component'; -import { ThemedItemPageTitleFieldComponent } from '../simple/field-components/specific-field/title/themed-item-page-field.component'; -import { ItemPageUriFieldComponent } from '../simple/field-components/specific-field/uri/item-page-uri-field.component'; -import { ItemComponent } from '../simple/item-types/shared/item.component'; -import { ThemedMetadataRepresentationListComponent } from '../simple/metadata-representation-list/themed-metadata-representation-list.component'; -import { ItemVersionsComponent } from '../versions/item-versions.component'; -import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; -import { ItemSecureFileSectionComponent } from './field-components/file-section/item-secure-file-section.component'; -import { ItemSecureMediaViewerComponent } from './field-components/media-viewer/item-secure-media-viewer.component'; - -@Component({ - selector: 'ds-item-access-by-token-view', - styleUrls: ['./item-access-by-token-view.component.scss'], - templateUrl: './item-access-by-token-view.component.html', - standalone: true, - imports: [ - ErrorComponent, - ThemedLoadingComponent, - TranslateModule, - ThemedFullFileSectionComponent, - CollectionsComponent, - ItemVersionsComponent, - NgIf, - NgForOf, - AsyncPipe, - KeyValuePipe, - RouterLink, - ThemedItemPageTitleFieldComponent, - DsoEditMenuComponent, - ItemVersionsNoticeComponent, - ViewTrackerComponent, - ThemedItemAlertsComponent, - VarDirective, - ItemSecureFileSectionComponent, - GenericItemPageFieldComponent, - ItemPageAbstractFieldComponent, - ItemPageDateFieldComponent, - ItemPageUriFieldComponent, - MetadataFieldWrapperComponent, - MiradorViewerComponent, - ThemedFileSectionComponent, - ThemedMediaViewerComponent, - ThemedMetadataRepresentationListComponent, - ThemedResultsBackButtonComponent, - ThemedThumbnailComponent, - ItemSecureMediaViewerComponent, - //ItemPageTitleFieldComponent, - //ThumbnailComponent, - //MetadataRepresentationListComponent, - ], -}) -export class ItemAccessByTokenViewComponent extends ItemComponent implements OnInit { - - @Input() itemRequest$: Observable; - itemRequestSubject = new BehaviorSubject(null); - expiryDate: Date; - - constructor( - protected routeService: RouteService, - protected router: Router, - ) { - super(routeService, router); - } - - protected readonly hasValue = hasValue; - - ngOnInit(): void { - this.itemRequest$.pipe( - filter(request => hasValue(request)), - ).subscribe(request => { - this.itemRequestSubject.next(request); - super.ngOnInit(); - }); - - - } - - getAccessPeriodEndDate(): Date { - const request = this.itemRequestSubject.getValue(); - // Set expiry, if not 0 - if (hasValue(request) && request.accessPeriod > 0) { - const date = new Date(request.decisionDate); - date.setUTCSeconds(date.getUTCSeconds() + request.accessPeriod); - return date; - } - } -} diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index 12ddcba701..632d979457 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -1,6 +1,7 @@ import { Route } from '@angular/router'; import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; +import { accessTokenResolver } from '../core/auth/access-token.resolver'; import { authenticatedGuard } from '../core/auth/authenticated.guard'; import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver'; import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; @@ -21,13 +22,13 @@ import { orcidPageGuard } from './orcid-page/orcid-page.guard'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { versionResolver } from './version-page/version.resolver'; import { VersionPageComponent } from './version-page/version-page/version-page.component'; -import { accessTokenResolver } from '../core/auth/access-token.resolver'; export const ROUTES: Route[] = [ { path: ':id', resolve: { dso: itemPageResolver, + itemRequest: accessTokenResolver, breadcrumb: itemBreadcrumbResolver, }, runGuardsAndResolvers: 'always', diff --git a/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts b/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts index 3acb14d3ee..cf1fd64855 100644 --- a/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts +++ b/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts @@ -15,6 +15,7 @@ import { Observable } from 'rxjs'; import { AuthService } from '../../../core/auth/auth.service'; import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; +import { hasValue } from '../../../shared/empty.util'; /** * This component render an image gallery for the image viewer @@ -99,7 +100,7 @@ export class MediaViewerImageComponent implements OnChanges, OnInit { medium: image.thumbnail ? image.thumbnail : this.thumbnailPlaceholder, - big: image.bitstream._links.content.href, + big: image.bitstream._links.content.href + (hasValue(image.accessToken) ? ('?accessToken=' + image.accessToken) : ''), }); } } diff --git a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html index a3d87c780d..d9ddcbf6d9 100644 --- a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html +++ b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html @@ -1,6 +1,6 @@
diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts index 4e60aaebe2..97ca744998 100644 --- a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts +++ b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts @@ -1,7 +1,6 @@ import { AsyncPipe, NgClass, - NgIf, NgTemplateOutlet, } from '@angular/common'; import { @@ -41,7 +40,7 @@ import { styleUrls: ['./item-secure-file-download-link.component.scss'], standalone: true, imports: [ - RouterLink, NgClass, NgIf, NgTemplateOutlet, AsyncPipe, TranslateModule, + RouterLink, NgClass, NgTemplateOutlet, AsyncPipe, TranslateModule, ], }) /** diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.html b/src/app/item-page/simple/field-components/file-section/file-section.component.html index fda6d8ae88..4966ca17d8 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.html @@ -3,7 +3,8 @@
@for (file of bitstreams; track file; let last = $last) { - + @if (hasAccessToken() === true) { + @if (primaryBitsreamId === file.id) { {{ 'item.page.bitstreams.primary' | translate }} @@ -14,7 +15,21 @@ @if (!last) { } - + + } @else { + + + @if (primaryBitsreamId === file.id) { + {{ 'item.page.bitstreams.primary' | translate }} + } + {{ dsoNameService.getName(file) }} + + ({{(file?.sizeBytes) | dsFileSize }}) + @if (!last) { + + } + + } } @if (isLoading) { diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html index af659aa855..4a5e91273b 100644 --- a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html @@ -36,8 +36,9 @@ }
+ } - @if (itemRequestRD.payload.decisionDate && (itemRequestRD.payload.acceptRequest === false || !itemRequestRD.payload.accessToken)) { + @if (itemRequestRD.payload.decisionDate && (!itemRequestRD.payload.acceptRequest || !itemRequestRD.payload.accessToken)) {

{{ 'grant-deny-request-copy.processed' | translate }}

@@ -45,7 +46,6 @@

} - }
} @if (!itemRequestRD || itemRequestRD?.isLoading) { diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts index b00c8575bc..023e2df4a2 100644 --- a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts @@ -1,7 +1,9 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, + fakeAsync, TestBed, + tick, waitForAsync, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -138,14 +140,16 @@ describe('GrantDenyRequestCopyComponent', () => { expect(message).toBeNull(); }); - it('should be displayed when decisionDate is defined', () => { + it('should be displayed when decisionDate is defined', fakeAsync(() => { component.itemRequestRD$ = createSuccessfulRemoteDataObject$(Object.assign(new ItemRequest(), itemRequest, { decisionDate: 'defined-date', })); fixture.detectChanges(); + tick(); // Simulate passage of time + fixture.detectChanges(); const message = fixture.debugElement.query(By.css('.processed-message')); expect(message).not.toBeNull(); - }); + })); }); }); diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts index 1455875b4e..597e55e23f 100644 --- a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts @@ -47,7 +47,7 @@ import { ThemedEmailRequestCopyComponent } from '../email-request-copy/themed-em styleUrls: ['./grant-request-copy.component.scss'], templateUrl: './grant-request-copy.component.html', standalone: true, - imports: [CommonModule, VarDirective, NgIf, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule, RouterLink, NgClass], + imports: [CommonModule, VarDirective, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule, RouterLink, NgClass], }) /** * Component for granting an item request diff --git a/src/themes/custom/app/register-email-form/register-email-form.component.ts b/src/themes/custom/app/register-email-form/register-email-form.component.ts index 598c37680d..4819880ff3 100644 --- a/src/themes/custom/app/register-email-form/register-email-form.component.ts +++ b/src/themes/custom/app/register-email-form/register-email-form.component.ts @@ -8,7 +8,6 @@ import { TranslateModule } from '@ngx-translate/core'; import { AlertComponent } from 'src/app/shared/alert/alert.component'; import { GoogleRecaptchaComponent } from 'src/app/shared/google-recaptcha/google-recaptcha.component'; -import { AltchaCaptchaComponent } from '../../../../app/item-page/bitstreams/request-a-copy/altcha-captcha.component'; import { RegisterEmailFormComponent as BaseComponent } from '../../../../app/register-email-form/register-email-form.component'; import { BtnDisabledDirective } from '../../../../app/shared/btn-disabled.directive'; From 16451807f902dd90ff6c80449a39873f90529f84 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Mon, 17 Mar 2025 17:41:34 +0100 Subject: [PATCH 16/25] Request-a-copy: Using route only in place of wrapper item --- ...m-secure-file-download-link.component.html | 19 - ...m-secure-file-download-link.component.scss | 0 ...ecure-file-download-link.component.spec.ts | 343 ------------------ ...tem-secure-file-download-link.component.ts | 153 -------- .../file-section/file-section.component.html | 31 +- .../file-section.component.spec.ts | 8 +- .../file-section/file-section.component.ts | 11 +- .../item-page/simple/item-page.component.html | 10 + .../item-page/simple/item-page.component.scss | 4 + .../item-page/simple/item-page.component.ts | 59 +-- .../file-download-link.component.html | 9 +- .../file-download-link.component.scss | 3 + .../file-download-link.component.ts | 54 ++- .../file-section/file-section.component.ts | 2 - src/themes/custom/lazy-theme.module.ts | 2 - 15 files changed, 107 insertions(+), 601 deletions(-) delete mode 100644 src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html delete mode 100644 src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.scss delete mode 100644 src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.spec.ts delete mode 100644 src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html deleted file mode 100644 index 37e2c5f38d..0000000000 --- a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.html +++ /dev/null @@ -1,19 +0,0 @@ - - @if ((canDownload$ | async) === false && (canDownloadWithToken$ | async) === false) { - - - } @else if ((canDownloadWithToken$ | async) && (canDownload$ | async) === false) { - - - } - - - - - - - diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.scss b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.spec.ts b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.spec.ts deleted file mode 100644 index dffdd007c0..0000000000 --- a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.spec.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; - -import { getBitstreamModuleRoute } from '../../../../app-routing-paths'; -import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; -import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; -import { ItemRequestDataService } from '../../../../core/data/item-request-data.service'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { Item } from '../../../../core/shared/item.model'; -import { ItemRequest } from '../../../../core/shared/item-request.model'; -import { URLCombiner } from '../../../../core/url-combiner/url-combiner'; -import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; -import { RouterLinkDirectiveStub } from '../../../../shared/testing/router-link-directive.stub'; -import { getItemModuleRoute } from '../../../item-page-routing-paths'; -import { ItemSecureFileDownloadLinkComponent } from './item-secure-file-download-link.component'; - -describe('FileDownloadLinkComponent', () => { - let component: ItemSecureFileDownloadLinkComponent; - let fixture: ComponentFixture; - - let authorizationService: AuthorizationDataService; - let itemRequestDataService: ItemRequestDataService; - let bitstream: Bitstream; - let item: Item; - let itemRequest: ItemRequest; - let routeStub: any; - - function init() { - itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { - canDownload: observableOf(true), - }); - bitstream = Object.assign(new Bitstream(), { - uuid: 'bitstreamUuid', - _links: { - self: { href: 'obj-selflink' }, - }, - }); - item = Object.assign(new Item(), { - uuid: 'itemUuid', - _links: { - self: { href: 'obj-selflink' }, - }, - }); - routeStub = { - data: observableOf({ - dso: createSuccessfulRemoteDataObject(item), - }), - children: [], - }; - - itemRequest = Object.assign(new ItemRequest(), - { - accessToken: 'accessToken', - itemId: item.uuid, - bitstreamId: bitstream.uuid, - allfiles: false, - requestEmail: 'user@name.org', - requestName: 'User Name', - requestMessage: 'I would like to request a copy', - }); - } - - function initTestbed() { - - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), ItemSecureFileDownloadLinkComponent, - RouterLinkDirectiveStub, - ], - providers: [ - { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: ActivatedRoute, useValue: routeStub }, - { provide: RouterLinkDirectiveStub }, - { provide: ItemRequestDataService, useValue: itemRequestDataService }, - ], - }) .compileComponents(); - } - - describe('when the user has download rights AND a valid item access token', () => { - /** - * We expect the normal download link to be rendered, whether or not there is a valid item request or request a copy feature - * available, since the user already has the right to download this file - */ - beforeEach(waitForAsync(() => { - init(); - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true), - }); - initTestbed(); - })); - beforeEach(() => { - fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); - component = fixture.componentInstance; - component.bitstream = bitstream; - component.item = item; - component.itemRequest = itemRequest; - component.enableRequestACopy = true; - fixture.detectChanges(); - }); - it('should init the component', () => { - expect(component).toBeTruthy(); - }); - it('canDownload$ should return true', () => { - component.canDownload$.subscribe((canDownload) => { - expect(canDownload).toBe(true); - }); - }); - it('canDownloadWithToken$ should return true', () => { - component.canDownloadWithToken$.subscribe((canDownloadWithToken) => { - expect(canDownloadWithToken).toBe(true); - }); - }); - it('canRequestACopy$ should return true', () => { - component.canRequestACopy$.subscribe((canRequestACopy) => { - expect(canRequestACopy).toBe(true); - }); - }); - it('should return the bitstreamPath based on the input bitstream', () => { - component.bitstreamPath$.subscribe((bitstreamPath) => { - expect(bitstreamPath).toEqual({ - routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), - queryParams: {}, - }); - }); - }); - }); - - describe('when the user has download rights but no valid item access token', () => { - /** - * We expect the normal download link to be rendered, whether or not there is a valid item request or request a copy feature - * available, since the user already has the right to download this file - */ - beforeEach(waitForAsync(() => { - init(); - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true), - }); - initTestbed(); - })); - beforeEach(() => { - fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); - component = fixture.componentInstance; - component.bitstream = bitstream; - component.item = item; - component.itemRequest = null; - component.enableRequestACopy = true; - fixture.detectChanges(); - }); - it('should init the component', () => { - expect(component).toBeTruthy(); - }); - it('canDownload$ should return true', () => { - component.canDownload$.subscribe((canDownload) => { - expect(canDownload).toBe(true); - }); - }); - it('canDownloadWithToken$ should return false', () => { - component.canDownloadWithToken$.subscribe((canDownloadWithToken) => { - expect(canDownloadWithToken).toBe(false); - }); - }); - it('canRequestACopy$ should return true', () => { - component.canRequestACopy$.subscribe((canRequestACopy) => { - expect(canRequestACopy).toBe(true); - }); - }); - it('should return the bitstreamPath based on the input bitstream', () => { - component.bitstreamPath$.subscribe((bitstreamPath) => { - expect(bitstreamPath).toEqual({ - routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), - queryParams: {}, - }); - }); - }); - }); - - describe('when the user has no download rights but there is a valid access token', () => { - /** - * We expect the download-with-token link to be rendered, since we have a valid request but no normal download rights - */ - beforeEach(waitForAsync(() => { - init(); - authorizationService = { - isAuthorized: (featureId: FeatureID) => { - if (featureId === FeatureID.CanDownload) { - return observableOf(false); - } - return observableOf(true); - }, - } as AuthorizationDataService; - initTestbed(); - })); - beforeEach(() => { - fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); - component = fixture.componentInstance; - component.bitstream = bitstream; - component.item = item; - component.itemRequest = itemRequest; - component.enableRequestACopy = true; - fixture.detectChanges(); - }); - it('should init the component', () => { - expect(component).toBeTruthy(); - }); - it('canDownload$ should return false', () => { - component.canDownload$.subscribe((canDownload) => { - expect(canDownload).toBe(false); - }); - }); - it('canDownloadWithToken$ should return true', () => { - component.canDownloadWithToken$.subscribe((canDownloadWithToken) => { - expect(canDownloadWithToken).toBe(true); - }); - }); - it('canRequestACopy$ should return true', () => { - component.canRequestACopy$.subscribe((canRequestACopy) => { - expect(canRequestACopy).toBe(true); - }); - }); - it('should return the access token path based on the input bitstream', () => { - component.bitstreamPath$.subscribe((accessTokenPath) => { - expect(accessTokenPath).toEqual({ - routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), - queryParams: { - accessToken: itemRequest.accessToken, - }, - }); - }); - }); - }); - - describe('when the user has no download rights but has the right to request a copy and there is no valid access token', () => { - /** - * We expect the request-a-copy link to be rendered instead of the normal download link or download-by-token link - */ - beforeEach(waitForAsync(() => { - init(); - authorizationService = { - isAuthorized: (featureId: FeatureID) => { - if (featureId === FeatureID.CanDownload) { - return observableOf(false); - } - return observableOf(true); - }, - } as AuthorizationDataService; - initTestbed(); - })); - beforeEach(() => { - fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); - component = fixture.componentInstance; - component.item = item; - component.bitstream = bitstream; - component.itemRequest = null; - component.enableRequestACopy = true; - fixture.detectChanges(); - }); - it('should init the component', () => { - expect(component).toBeTruthy(); - }); - it('canDownload should be false', () => { - component.canDownload$.subscribe((canDownload) => { - expect(canDownload).toBeFalse(); - }); - }); - it('canDownloadWithToken should be false', () => { - component.canDownloadWithToken$.subscribe((canDownload) => { - expect(canDownload).toBeFalse(); - }); - }); - it('canRequestACopy should be true', () => { - component.canRequestACopy$.subscribe((canRequestACopy) => { - expect(canRequestACopy).toBeTrue(); - }); - }); - it('should return the bitstreamPath based a request-a-copy item + bitstream ID link', () => { - component.bitstreamPath$.subscribe((bitstreamPath) => { - expect(bitstreamPath).toEqual({ - routerLink: new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString(), - queryParams: { bitstream: bitstream.uuid }, - }); - }); - }); - - }); - - describe('when the user has no download rights and no request a copy rights and there is no valid itemRequest', () => { - /** - * We expect a normal download link (which would then be treated as a forbidden and redirect to the login page as per normal) - */ - beforeEach(waitForAsync(() => { - init(); - // This mock will return false for both canDownload and canRequestACopy checks - authorizationService = { - isAuthorized: (featureId: FeatureID) => { - return observableOf(false); - }, - } as AuthorizationDataService; - initTestbed(); - })); - beforeEach(() => { - fixture = TestBed.createComponent(ItemSecureFileDownloadLinkComponent); - component = fixture.componentInstance; - component.bitstream = bitstream; - component.item = item; - component.itemRequest = null; - component.enableRequestACopy = false; - fixture.detectChanges(); - }); - it('should init the component', () => { - expect(component).toBeTruthy(); - }); - it('canDownload$ should be false', () => { - component.canDownload$.subscribe((canDownload) => { - expect(canDownload).toBeFalse(); - }); - }); - it('canDownloadWithToken$ should be false', () => { - component.canDownloadWithToken$.subscribe((canDownloadWithToken) => { - expect(canDownloadWithToken).toBeFalse(); - }); - }); - it('canRequestACopy$ should be false', () => { - component.canRequestACopy$.subscribe((canRequestACopy) => { - expect(canRequestACopy).toBeFalse(); - }); - }); - it('should return the bitstreamPath based on the input bitstream', () => { - component.bitstreamPath$.subscribe((bitstreamPath) => { - expect(bitstreamPath).toEqual({ - routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), - queryParams: {}, - }); - }); - }); - }); -}); - diff --git a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts b/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts deleted file mode 100644 index 97ca744998..0000000000 --- a/src/app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - AsyncPipe, - NgClass, - NgTemplateOutlet, -} from '@angular/common'; -import { - Component, - Input, - OnInit, -} from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; -import { - combineLatest as observableCombineLatest, - Observable, - of as observableOf, -} from 'rxjs'; -import { map } from 'rxjs/operators'; - -import { - getBitstreamDownloadRoute, - getBitstreamDownloadWithAccessTokenRoute, - getBitstreamRequestACopyRoute, -} from '../../../../app-routing-paths'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; -import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; -import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { Item } from '../../../../core/shared/item.model'; -import { ItemRequest } from '../../../../core/shared/item-request.model'; -import { ItemWithSupplementaryData } from '../../../../core/shared/item-with-supplementary-data.model'; -import { - hasValue, - isNotEmpty, -} from '../../../../shared/empty.util'; - -@Component({ - selector: 'ds-item-secure-file-download-link', - templateUrl: './item-secure-file-download-link.component.html', - styleUrls: ['./item-secure-file-download-link.component.scss'], - standalone: true, - imports: [ - RouterLink, NgClass, NgTemplateOutlet, AsyncPipe, TranslateModule, - ], -}) -/** - * Component displaying a download link - * When the user is authenticated, a short-lived token retrieved from the REST API is added to the download link, - * ensuring the user is authorized to download the file. - */ -export class ItemSecureFileDownloadLinkComponent implements OnInit { - - /** - * Optional bitstream instead of href and file name - */ - @Input() bitstream: Bitstream; - - /** - * Item that owns the linked bitstream - */ - @Input() item: Item; - - /** - * Additional css classes to apply to link - */ - @Input() cssClasses = ''; - - /** - * A boolean representing if link is shown in same tab or in a new one. - */ - @Input() isBlank = false; - - @Input() enableRequestACopy = true; - - bitstreamPath$: Observable<{ - routerLink: string, - queryParams: any, - }>; - // ItemRequest object with access token, expiry, etc. - itemRequest: ItemRequest; - // authorized to download normally - canDownload$: Observable; - // authorized to download with token - canDownloadWithToken$: Observable; - // authorized to request a copy - canRequestACopy$: Observable; - - constructor( - private authorizationService: AuthorizationDataService, - protected dsoNameService: DSONameService, - ) { - } - - /** - * Initialise component observables to test access rights to a normal bitstream download, a valid token download - * (for a given bitstream), and ability to request a copy of a bitstream. - */ - ngOnInit() { - if (this.item instanceof ItemWithSupplementaryData) { - this.itemRequest = this.item.itemRequest; - } - this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); - this.canDownloadWithToken$ = observableOf(this.itemRequest ? (this.itemRequest.allfiles !== false || this.itemRequest.bitstreamId === this.bitstream.uuid) : false); - this.canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); - - this.bitstreamPath$ = observableCombineLatest([this.canDownload$, this.canDownloadWithToken$, this.canRequestACopy$]).pipe( - map(([canDownload, canDownloadWithToken, canRequestACopy]) => this.getBitstreamPath(canDownload, canDownloadWithToken, canRequestACopy)), - ); - } - - /** - * Return a path to the bitstream based on what kind of access and authorization the user has, and whether - * they may request a copy - * - * @param canDownload user can download normally - * @param canDownloadWithToken user can download using a token granted by a request approver - * @param canRequestACopy user can request approval to access a copy - */ - getBitstreamPath(canDownload: boolean, canDownloadWithToken, canRequestACopy: boolean) { - // No matter what, if the user can download with their own authZ, allow it - if (canDownload) { - return this.getBitstreamDownloadPath(); - } - // Otherwise, if they access token is valid, use this - if (canDownloadWithToken) { - return this.getAccessByTokenBitstreamPath(this.itemRequest); - } - // If the user can't download, but can request a copy, show the request a copy link - if (!canDownload && canRequestACopy && hasValue(this.item)) { - return getBitstreamRequestACopyRoute(this.item, this.bitstream); - } - // By default, return the plain path - return this.getBitstreamDownloadPath(); - } - - /** - * Resolve special bitstream path which includes access token parameter - * @param itemRequest the item request object - */ - getAccessByTokenBitstreamPath(itemRequest: ItemRequest) { - return getBitstreamDownloadWithAccessTokenRoute(this.bitstream, itemRequest.accessToken); - } - - /** - * Get normal bitstream download path, with no parameters - */ - getBitstreamDownloadPath() { - return { - routerLink: getBitstreamDownloadRoute(this.bitstream), - queryParams: {}, - }; - } -} diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.html b/src/app/item-page/simple/field-components/file-section/file-section.component.html index 4966ca17d8..79440ac655 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.html @@ -3,33 +3,18 @@
@for (file of bitstreams; track file; let last = $last) { - @if (hasAccessToken() === true) { - - - @if (primaryBitsreamId === file.id) { - {{ 'item.page.bitstreams.primary' | translate }} - } - {{ dsoNameService.getName(file) }} - + + + @if (primaryBitstreamId === file.id) { + {{ 'item.page.bitstreams.primary' | translate }} + } + {{ dsoNameService.getName(file) }} + ({{(file?.sizeBytes) | dsFileSize }}) @if (!last) { } - - } @else { - - - @if (primaryBitsreamId === file.id) { - {{ 'item.page.bitstreams.primary' | translate }} - } - {{ dsoNameService.getName(file) }} - - ({{(file?.sizeBytes) | dsFileSize }}) - @if (!last) { - - } - - } + } @if (isLoading) { diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts b/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts index 9ead81c337..7384949267 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts @@ -111,17 +111,17 @@ describe('FileSectionComponent', () => { })); it('should set the id of primary bitstream', () => { - comp.primaryBitsreamId = undefined; + comp.primaryBitstreamId = undefined; bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(mockBitstream)); comp.ngOnInit(); - expect(comp.primaryBitsreamId).toBe(mockBitstream.id); + expect(comp.primaryBitstreamId).toBe(mockBitstream.id); }); it('should not set the id of primary bitstream', () => { - comp.primaryBitsreamId = undefined; + comp.primaryBitstreamId = undefined; bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(null)); comp.ngOnInit(); - expect(comp.primaryBitsreamId).toBeUndefined(); + expect(comp.primaryBitstreamId).toBeUndefined(); }); describe('when the bitstreams are loading', () => { diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.ts b/src/app/item-page/simple/field-components/file-section/file-section.component.ts index 631d6c18aa..ffa302687e 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.ts @@ -21,7 +21,6 @@ import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; -import { ItemWithSupplementaryData } from '../../../../core/shared/item-with-supplementary-data.model'; import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { hasValue } from '../../../../shared/empty.util'; import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; @@ -31,7 +30,6 @@ import { NotificationsService } from '../../../../shared/notifications/notificat import { FileSizePipe } from '../../../../shared/utils/file-size-pipe'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { VarDirective } from '../../../../shared/utils/var.directive'; -import { ItemSecureFileDownloadLinkComponent } from '../../../access-by-token/field-components/file-download-link/item-secure-file-download-link.component'; /** * This component renders the file section of the item @@ -48,7 +46,6 @@ import { ItemSecureFileDownloadLinkComponent } from '../../../access-by-token/fi TranslateModule, FileSizePipe, VarDirective, - ItemSecureFileDownloadLinkComponent, ], standalone: true, }) @@ -70,7 +67,7 @@ export class FileSectionComponent implements OnInit { pageSize: number; - primaryBitsreamId: string; + primaryBitstreamId: string; constructor( protected bitstreamDataService: BitstreamDataService, @@ -92,7 +89,7 @@ export class FileSectionComponent implements OnInit { if (!primaryBitstream) { return; } - this.primaryBitsreamId = primaryBitstream?.id; + this.primaryBitstreamId = primaryBitstream?.id; }); } @@ -126,8 +123,4 @@ export class FileSectionComponent implements OnInit { } }); } - - hasAccessToken(): boolean { - return (this.item instanceof ItemWithSupplementaryData && hasValue(this.item.itemRequest)); - } } diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index 207fcdab5b..1892095cb5 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -3,6 +3,16 @@
@if (itemRD?.payload; as item) {
+ + @if (hasValue(itemRequest)) { +
+

{{'bitstream-request-a-copy.access-by-token.warning' | translate}}

+ @if (hasValue(itemRequest.accessPeriod) && itemRequest.accessPeriod > 0 && hasValue(itemRequest.decisionDate)) { +

{{ 'bitstream-request-a-copy.access-by-token.expiry-label' | translate }} {{ getAccessPeriodEndDate(itemRequest.accessPeriod, itemRequest.decisionDate) }}

+ } +
+ } +
diff --git a/src/app/item-page/simple/item-page.component.scss b/src/app/item-page/simple/item-page.component.scss index 2501fc92ca..862ddd4f45 100644 --- a/src/app/item-page/simple/item-page.component.scss +++ b/src/app/item-page/simple/item-page.component.scss @@ -4,3 +4,7 @@ max-width: none; } } +.request-a-copy-access-icon { + margin-right: 4px; + color: #26a269; +} diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index dc7b07e0c6..16e701623f 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -40,7 +40,6 @@ import { import { ServerResponseService } from '../../core/services/server-response.service'; import { Item } from '../../core/shared/item.model'; import { ItemRequest } from '../../core/shared/item-request.model'; -import { ItemWithSupplementaryData } from '../../core/shared/item-with-supplementary-data.model'; import { getAllSucceededRemoteDataPayload } from '../../core/shared/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; import { fadeInOut } from '../../shared/animations/fade'; @@ -99,7 +98,10 @@ export class ItemPageComponent implements OnInit, OnDestroy { */ itemRD$: Observable>; - item$: Observable; + /** + * The request item wrapped in a remote-data object, obtained from the route data + */ + itemRequest$: Observable; /** * The view-mode we're currently on @@ -150,20 +152,11 @@ export class ItemPageComponent implements OnInit, OnDestroy { * Initialize instance variables */ ngOnInit(): void { - // Get item request this.itemRD$ = this.route.data.pipe( - map((data) => { - const itemRD = data.dso; - // If the item has a valid itemRequest, add it to the item and set the itemRD payload to the - // modified ItemWithSupplementaryData object - if (hasValue(data.itemRequest)) { - const itemRequest = data.itemRequest; - itemRD.payload = Object.assign(new ItemWithSupplementaryData(itemRequest), itemRD.payload); - } - // Return itemRD - return itemRD; - }, - ), + map((data) => data.dso as RemoteData), + ); + this.itemRequest$ = this.route.data.pipe( + map((data) => data.itemRequest as ItemRequest), ); this.itemPageRoute$ = this.itemRD$.pipe( getAllSucceededRemoteDataPayload(), @@ -257,30 +250,6 @@ export class ItemPageComponent implements OnInit, OnDestroy { return links; } - /** - * Helper function to return the item request from an item for use in templates - * @param item - */ - getAccessByToken(item): ItemRequest { - if (item instanceof ItemWithSupplementaryData) { - return item.itemRequest; - } - return null; - } - - /** - * Helper function to return the expiry date from an item request for use in templates, alerts, etc. - * @param itemRequest - */ - getAccessPeriodEndDate(itemRequest): Date { - // Set expiry, if not 0 - if (hasValue(itemRequest) && itemRequest.accessPeriod > 0) { - const date = new Date(itemRequest.decisionDate); - date.setUTCSeconds(date.getUTCSeconds() + itemRequest.accessPeriod); - return date; - } - } - ngOnDestroy(): void { this.signpostingLinks.forEach((link: SignpostingLink) => { this.linkHeadService.removeTag(`href='${link.href}'`); @@ -290,4 +259,16 @@ export class ItemPageComponent implements OnInit, OnDestroy { }); } + /** + * Calculate and return end period access date for a request-a-copy link for alert display + */ + getAccessPeriodEndDate(accessPeriod: number, decisionDate: string | number | Date): Date { + // Set expiry, if not 0 + if (hasValue(accessPeriod) && accessPeriod > 0 && hasValue(decisionDate)) { + const date = new Date(decisionDate); + date.setUTCSeconds(date.getUTCSeconds() + accessPeriod); + return date; + } + } + } diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index ca15dd93a8..a03836c9f1 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -6,9 +6,14 @@ [target]="isBlank ? '_blank': '_self'" [ngClass]="cssClasses" [attr.aria-label]="('file-download-link.download' | translate) + dsoNameService.getName(bitstream)"> - @if ((canDownload$ |async) !== true) { - + @if ((canDownload$ | async) === false && (canDownloadWithToken$ | async) === false) { + + + } @else if ((canDownloadWithToken$ | async) && (canDownload$ | async) === false) { + + } + diff --git a/src/app/shared/file-download-link/file-download-link.component.scss b/src/app/shared/file-download-link/file-download-link.component.scss index e69de29bb2..78014331a4 100644 --- a/src/app/shared/file-download-link/file-download-link.component.scss +++ b/src/app/shared/file-download-link/file-download-link.component.scss @@ -0,0 +1,3 @@ +.request-a-copy-access-icon { + color: #26a269; +} diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index a323c49ce9..93fa07b0ad 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -8,7 +8,10 @@ import { Input, OnInit, } from '@angular/core'; -import { RouterLink } from '@angular/router'; +import { + ActivatedRoute, + RouterLink, +} from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { combineLatest as observableCombineLatest, @@ -19,6 +22,7 @@ import { map } from 'rxjs/operators'; import { getBitstreamDownloadRoute, + getBitstreamDownloadWithAccessTokenRoute, getBitstreamRequestACopyRoute, } from '../../app-routing-paths'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @@ -26,6 +30,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/ import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { Bitstream } from '../../core/shared/bitstream.model'; import { Item } from '../../core/shared/item.model'; +import { ItemRequest } from '../../core/shared/item-request.model'; import { hasValue, isNotEmpty, @@ -70,25 +75,35 @@ export class FileDownloadLinkComponent implements OnInit { */ @Input() showAccessStatusBadge = true; + itemRequest: ItemRequest; + bitstreamPath$: Observable<{ routerLink: string, queryParams: any, }>; canDownload$: Observable; + canDownloadWithToken$: Observable; + canRequestACopy$: Observable; constructor( private authorizationService: AuthorizationDataService, public dsoNameService: DSONameService, + private route: ActivatedRoute, ) { } ngOnInit() { if (this.enableRequestACopy) { + // Obtain item request data from the route snapshot + this.itemRequest = this.route.snapshot.data.itemRequest; + // Set up observables to test access rights to a normal bitstream download, a valid token download, and the request-a-copy feature this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); - const canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); - this.bitstreamPath$ = observableCombineLatest([this.canDownload$, canRequestACopy$]).pipe( - map(([canDownload, canRequestACopy]) => this.getBitstreamPath(canDownload, canRequestACopy)), + this.canDownloadWithToken$ = observableOf(this.itemRequest ? (this.itemRequest.allfiles !== false || this.itemRequest.bitstreamId === this.bitstream.uuid) : false); + this.canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + // Set up observable to determine the path to the bitstream based on the user's access rights and features as above + this.bitstreamPath$ = observableCombineLatest([this.canDownload$, this.canDownloadWithToken$, this.canRequestACopy$]).pipe( + map(([canDownload, canDownloadWithToken, canRequestACopy]) => this.getBitstreamPath(canDownload, canDownloadWithToken, canRequestACopy)), ); } else { this.bitstreamPath$ = observableOf(this.getBitstreamDownloadPath()); @@ -96,13 +111,42 @@ export class FileDownloadLinkComponent implements OnInit { } } - getBitstreamPath(canDownload: boolean, canRequestACopy: boolean) { + /** + * Return a path to the bitstream based on what kind of access and authorization the user has, and whether + * they may request a copy + * + * @param canDownload user can download normally + * @param canDownloadWithToken user can download using a token granted by a request approver + * @param canRequestACopy user can request approval to access a copy + */ + getBitstreamPath(canDownload: boolean, canDownloadWithToken, canRequestACopy: boolean) { + // No matter what, if the user can download with their own authZ, allow it + if (canDownload) { + return this.getBitstreamDownloadPath(); + } + // Otherwise, if they access token is valid, use this + if (canDownloadWithToken) { + return this.getAccessByTokenBitstreamPath(this.itemRequest); + } + // If the user can't download, but can request a copy, show the request a copy link if (!canDownload && canRequestACopy && hasValue(this.item)) { return getBitstreamRequestACopyRoute(this.item, this.bitstream); } + // By default, return the plain path return this.getBitstreamDownloadPath(); } + /** + * Resolve special bitstream path which includes access token parameter + * @param itemRequest the item request object + */ + getAccessByTokenBitstreamPath(itemRequest: ItemRequest) { + return getBitstreamDownloadWithAccessTokenRoute(this.bitstream, itemRequest.accessToken); + } + + /** + * Get normal bitstream download path, with no parameters + */ getBitstreamDownloadPath() { return { routerLink: getBitstreamDownloadRoute(this.bitstream), diff --git a/src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts b/src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts index f7fa9df25b..e39f7536eb 100644 --- a/src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts @@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { ItemSecureFileDownloadLinkComponent } from '../../../../../../../app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component'; import { FileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component'; import { slideSidebarPadding } from '../../../../../../../app/shared/animations/slide'; import { ThemedFileDownloadLinkComponent } from '../../../../../../../app/shared/file-download-link/themed-file-download-link.component'; @@ -25,7 +24,6 @@ import { VarDirective } from '../../../../../../../app/shared/utils/var.directiv TranslateModule, FileSizePipe, VarDirective, - ItemSecureFileDownloadLinkComponent, ], }) export class FileSectionComponent extends BaseComponent { diff --git a/src/themes/custom/lazy-theme.module.ts b/src/themes/custom/lazy-theme.module.ts index 24b7b7a0c0..cd677847ce 100644 --- a/src/themes/custom/lazy-theme.module.ts +++ b/src/themes/custom/lazy-theme.module.ts @@ -14,7 +14,6 @@ import { StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; -import { ItemSecureFileDownloadLinkComponent } from '../../app/item-page/access-by-token/field-components/file-download-link/item-secure-file-download-link.component'; import { RootModule } from '../../app/root.module'; import { SearchResultsSkeletonComponent } from '../../app/shared/search/search-results/search-results-skeleton/search-results-skeleton.component'; import { MetadataImportPageComponent } from './app/admin/admin-import-metadata-page/metadata-import-page.component'; @@ -134,7 +133,6 @@ const DECLARATIONS = [ CollectionPageComponent, ItemPageComponent, FullItemPageComponent, - ItemSecureFileDownloadLinkComponent, LoginPageComponent, LogoutPageComponent, CreateProfileComponent, From 57b618ce34ec2e8715ecb09da6249a8b1866b596 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Tue, 18 Mar 2025 18:07:27 +0100 Subject: [PATCH 17/25] Request-a-copy: Changes to support access expiry as delta/date storage --- src/app/core/data/item-request-data.service.ts | 3 +-- src/app/core/shared/item-request.model.ts | 4 ++-- .../email-request-copy.component.spec.ts | 2 +- .../email-request-copy.component.ts | 11 ++++++----- .../grant-request-copy.component.ts | 2 +- src/assets/i18n/en.json5 | 10 +++++----- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index db6221572f..351c96a70d 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -211,11 +211,10 @@ export class ItemRequestDataService extends IdentifiableDataService * if the 'send secure link' feature is configured. * Expects integer values, conversion to number is done in this processing */ - getConfiguredAccessPeriods(): Observable { + getConfiguredAccessPeriods(): Observable { return this.configService.findByPropertyName('request.item.grant.link.period').pipe( getFirstCompletedRemoteData(), map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []), - map((values) => values.map(value => parseInt(value, 10))), ); } diff --git a/src/app/core/shared/item-request.model.ts b/src/app/core/shared/item-request.model.ts index 8fa3398d24..06d4423ea1 100644 --- a/src/app/core/shared/item-request.model.ts +++ b/src/app/core/shared/item-request.model.ts @@ -86,10 +86,10 @@ export class ItemRequest implements CacheableObject { @autoserialize accessToken: string; /** - * Access period of the request + * Access expiry date of the request */ @autoserialize - accessPeriod: number; + accessExpiry: string; /** * The {@link HALLink}s for this ItemRequest diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts index 676f987c0f..9fa32be58b 100644 --- a/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts @@ -45,7 +45,7 @@ describe('EmailRequestCopyComponent', () => { spyOn(component.send, 'emit').and.stub(); component.subject = 'test-subject'; component.message = 'test-message'; - component.validAccessPeriods = [0]; + component.validAccessPeriods = ['FOREVER']; component.submit(); expect(component.send.emit).toHaveBeenCalledWith(new RequestCopyEmail('test-subject', 'test-message')); }); diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.ts index bb455f7fca..e427996080 100644 --- a/src/app/request-copy/email-request-copy/email-request-copy.component.ts +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.ts @@ -34,7 +34,7 @@ export class EmailRequestCopyComponent implements OnInit { * Event emitter for sending the email */ @Output() send: EventEmitter = new EventEmitter(); - @Output() selectedAccessPeriod: EventEmitter = new EventEmitter(); + @Output() selectedAccessPeriod: EventEmitter = new EventEmitter(); /** * The subject of the email @@ -49,12 +49,13 @@ export class EmailRequestCopyComponent implements OnInit { /** * A list of valid access periods to render in a drop-down menu */ - @Input() validAccessPeriods: number[] = []; + @Input() validAccessPeriods: string [] = []; /** - * The selected access period + * The selected access period, e.g. +7DAYS, +12MONTHS, FOREVER. These will be + * calculated as a timestamp to store as the access expiry date for the requested item */ - accessPeriod = 0; + accessPeriod = 'FOREVER'; protected readonly hasValue = hasValue; @@ -86,7 +87,7 @@ export class EmailRequestCopyComponent implements OnInit { * Update the access period when a dropdown menu button is clicked for a value * @param accessPeriod */ - selectAccessPeriod(accessPeriod: number) { + selectAccessPeriod(accessPeriod: string) { this.accessPeriod = accessPeriod; this.selectedAccessPeriod.emit(accessPeriod); } diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts index 597e55e23f..79519341aa 100644 --- a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts @@ -76,7 +76,7 @@ export class GrantRequestCopyComponent implements OnInit { /** * A list of integers determining valid access periods in seconds */ - validAccessPeriods$: Observable; + validAccessPeriods$: Observable; /** * The currently selected access period diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 82dc0092e8..5540d16e65 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2088,15 +2088,15 @@ "grant-request-copy.access-period.header": "Access period", - "grant-request-copy.access-period.120": "2 minutes", + "grant-request-copy.access-period.+1DAY": "1 day", - "grant-request-copy.access-period.240": "4 minutes", + "grant-request-copy.access-period.+1WEEK": "1 week", - "grant-request-copy.access-period.86400": "1 day", + "grant-request-copy.access-period.+1MONTH": "1 month", - "grant-request-copy.access-period.604800": "7 days", + "grant-request-copy.access-period.+3MONTHS": "3 months", - "grant-request-copy.access-period.0": "Forever", + "grant-request-copy.access-period.FOREVER": "Forever", "health.breadcrumbs": "Health", From 1fff3b5b86ac495804fe4ffb3442043e149ee80b Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Mon, 24 Mar 2025 12:56:32 +0100 Subject: [PATCH 18/25] Request-a-copy: Changes to support access expiry as delta/date storage - psql max --- .../core/data/item-request-data.service.ts | 24 +--------- .../email-request-copy.component.html | 4 +- .../email-request-copy.component.ts | 46 ++++++++++++++++--- .../themed-email-request-copy.component.ts | 7 +-- .../grant-request-copy.component.html | 2 +- .../grant-request-copy.component.ts | 7 ++- .../email-request-copy.component.ts | 7 ++- 7 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index 351c96a70d..7c0bb9f21e 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -125,7 +125,7 @@ export class ItemRequestDataService extends IdentifiableDataService * @param suggestOpenAccess Whether or not to suggest the item to become open access * @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments) */ - grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod = 0): Observable> { + grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod: string = null): Observable> { return this.process(token, email, true, suggestOpenAccess, accessPeriod); } @@ -137,7 +137,7 @@ export class ItemRequestDataService extends IdentifiableDataService * @param suggestOpenAccess Whether or not to suggest the item to become open access * @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments) */ - process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod = 0): Observable> { + process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod: string = null): Observable> { const requestId = this.requestService.generateRequestId(); this.getItemRequestEndpointByToken(token).pipe( @@ -161,26 +161,6 @@ export class ItemRequestDataService extends IdentifiableDataService return this.rdbService.buildFromRequestUUID(requestId); } - // TODO: Remove this, after discussion about implications and compare to bitstream data service byItemHandle - // Reviewers may ask that we instead just wrap the REST response in pagination even though we only expect one obj - /** - * Get a sanitized item request using the searchBy method and the access token sent to the original requester. - * - * @param accessToken access token contained in the secure link sent to a requester - */ - getSanitizedRequestByAccessTokenPaged(accessToken: string): Observable>> { - // We only expect / want one result as access tokens are unique - const findListOptions = Object.assign({}, new FindListOptions(), { - elementsPerPage: 1, - currentPage: 1, - searchParams: [ - new RequestParam('accessToken', accessToken), - ], - }); - // Pipe the paginated searchBy results and return a single item request - return this.searchBy('byAccessToken', findListOptions); - } - /** * Get a sanitized item request using the searchBy method and the access token sent to the original requester. * diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.html b/src/app/request-copy/email-request-copy/email-request-copy.component.html index 473d976d0d..c3fe7d0435 100644 --- a/src/app/request-copy/email-request-copy/email-request-copy.component.html +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.html @@ -15,7 +15,7 @@
- @if (hasValue(validAccessPeriods) && validAccessPeriods.length > 0) { + @if (hasValue(validAccessPeriods$ | async) && (validAccessPeriods$ | async).length > 0) {
@@ -28,7 +28,7 @@
- @for (accessPeriod of validAccessPeriods; track accessPeriod) { + @for (accessPeriod of (validAccessPeriods$ | async); track accessPeriod) { -
+