diff --git a/src/app/core/auth/access-token.resolver.ts b/src/app/core/auth/access-token.resolver.ts index 61deb89c4b..a0646d72e8 100644 --- a/src/app/core/auth/access-token.resolver.ts +++ b/src/app/core/auth/access-token.resolver.ts @@ -4,11 +4,15 @@ import { Router, } from '@angular/router'; import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { + map, + 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 { RemoteData } from '../data/remote-data'; import { redirectOn4xx } from '../shared/authorized.operators'; import { ItemRequest } from '../shared/item-request.model'; import { @@ -43,6 +47,7 @@ export const accessTokenResolver: ResolveFn = ( getFirstCompletedRemoteData(), // Handle authorization errors, not found errors and forbidden errors as normal redirectOn4xx(router, authService), + map((rd: RemoteData) => rd), // Get payload of the item request getFirstSucceededRemoteDataPayload(), tap(request => { diff --git a/src/app/core/shared/item-request.model.ts b/src/app/core/shared/item-request.model.ts index 06d4423ea1..5180be1fe7 100644 --- a/src/app/core/shared/item-request.model.ts +++ b/src/app/core/shared/item-request.model.ts @@ -91,6 +91,8 @@ export class ItemRequest implements CacheableObject { @autoserialize accessExpiry: string; + @autoserialize + accessExpired: boolean; /** * The {@link HALLink}s for this ItemRequest */ diff --git a/src/app/core/shared/item-with-supplementary-data.model.ts b/src/app/core/shared/item-with-supplementary-data.model.ts deleted file mode 100644 index 76480a2650..0000000000 --- a/src/app/core/shared/item-with-supplementary-data.model.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/item-page/media-viewer/media-viewer.component.spec.ts b/src/app/item-page/media-viewer/media-viewer.component.spec.ts index 7649e17b71..33e3ec8ea1 100644 --- a/src/app/item-page/media-viewer/media-viewer.component.spec.ts +++ b/src/app/item-page/media-viewer/media-viewer.component.spec.ts @@ -6,6 +6,7 @@ import { } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; import { TranslateLoader, TranslateModule, @@ -22,6 +23,7 @@ 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 { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { ThemeService } from '../../shared/theme-support/theme.service'; import { FileSizePipe } from '../../shared/utils/file-size-pipe'; @@ -91,6 +93,7 @@ describe('MediaViewerComponent', () => { { provide: BitstreamDataService, useValue: bitstreamDataService }, { provide: ThemeService, useValue: getMockThemeService() }, { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/src/app/item-page/media-viewer/media-viewer.component.ts b/src/app/item-page/media-viewer/media-viewer.component.ts index 230c059937..daa85963a5 100644 --- a/src/app/item-page/media-viewer/media-viewer.component.ts +++ b/src/app/item-page/media-viewer/media-viewer.component.ts @@ -6,6 +6,7 @@ import { OnDestroy, OnInit, } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { BehaviorSubject, @@ -25,7 +26,7 @@ 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 { ItemWithSupplementaryData } from '../../core/shared/item-with-supplementary-data.model'; +import { ItemRequest } from '../../core/shared/item-request.model'; import { MediaViewerItem } from '../../core/shared/media-viewer-item.model'; import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { hasValue } from '../../shared/empty.util'; @@ -71,9 +72,12 @@ export class MediaViewerComponent implements OnDestroy, OnInit { subs: Subscription[] = []; + itemRequest: ItemRequest; + constructor( protected bitstreamDataService: BitstreamDataService, protected changeDetectorRef: ChangeDetectorRef, + protected route: ActivatedRoute, ) { } @@ -85,6 +89,7 @@ export class MediaViewerComponent implements OnDestroy, OnInit { * This method loads all the Bitstreams and Thumbnails and converts it to {@link MediaViewerItem}s */ ngOnInit(): void { + this.itemRequest = this.route.snapshot.data.itemRequest; const types: string[] = [ ...(this.mediaOptions.image ? ['image'] : []), ...(this.mediaOptions.video ? ['audio', 'video'] : []), @@ -170,9 +175,10 @@ export class MediaViewerComponent implements OnDestroy, OnInit { * Get access token, if this is accessed via a Request-a-Copy link */ get accessToken() { - if (this.item instanceof ItemWithSupplementaryData && hasValue(this.item.itemRequest)) { - return this.item.itemRequest.accessToken; + if (hasValue(this.itemRequest) && this.itemRequest.accessToken && !this.itemRequest.accessExpired) { + return this.itemRequest.accessToken; } return null; } + } diff --git a/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.html b/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.html new file mode 100644 index 0000000000..ee174201df --- /dev/null +++ b/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.html @@ -0,0 +1,25 @@ + + @if (hasValue(itemRequest)) { + @if (!itemRequest.acceptRequest) { + +
+

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

+

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

+
+ } @else if (itemRequest.accessExpired) { + +
+

{{'bitstream-request-a-copy.access-by-token.expired' | translate}} {{ formatDate(itemRequest.accessExpiry) }}

+

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

+
+ } @else { +
+

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

+ + @if (hasValue(itemRequest.accessExpiry) && !itemRequest.accessExpiry.startsWith('+294276')) { +

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

+ } +
+ } + } +
diff --git a/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.scss b/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.scss new file mode 100644 index 0000000000..00ecbaa50f --- /dev/null +++ b/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.scss @@ -0,0 +1,7 @@ +.request-a-copy-access-icon { + margin-right: 4px; + color: var(--bs-success); +} +.request-a-copy-access-error-icon { + margin-right: 4px; +} diff --git a/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.spec.ts b/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.spec.ts new file mode 100644 index 0000000000..62b643ae79 --- /dev/null +++ b/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.spec.ts @@ -0,0 +1,114 @@ +import { CommonModule } from '@angular/common'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { SplitPipe } from 'src/app/shared/utils/split.pipe'; + +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { ItemRequest } from '../../../core/shared/item-request.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { AccessByTokenNotificationComponent } from './access-by-token-notification.component'; + +describe('AccessByTokenNotificationComponent', () => { + let component: AccessByTokenNotificationComponent; + let fixture: ComponentFixture; + let activatedRouteStub: ActivatedRouteStub; + let itemRequestSubject: BehaviorSubject; + + const createItemRequest = (acceptRequest: boolean, accessExpired: boolean, accessExpiry?: string): ItemRequest => { + const itemRequest = new ItemRequest(); + itemRequest.acceptRequest = acceptRequest; + itemRequest.accessExpired = accessExpired; + itemRequest.accessExpiry = accessExpiry; + return itemRequest; + }; + + beforeEach(async () => { + itemRequestSubject = new BehaviorSubject(null); + activatedRouteStub = new ActivatedRouteStub({}, { itemRequest: null }); + (activatedRouteStub as any).data = itemRequestSubject.asObservable().pipe( + map(itemRequest => ({ itemRequest })), + ); + + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + AccessByTokenNotificationComponent, + SplitPipe, + VarDirective, + ], + providers: [ + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: RequestService, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: HALEndpointService, useValue: new HALEndpointServiceStub('test') }, + ObjectCacheService, + RemoteDataBuildService, + provideMockStore({}), + ], + }) + .compileComponents(); + + fixture = TestBed.createComponent(AccessByTokenNotificationComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should not display any alert when no itemRequest is present', () => { + itemRequestSubject.next(null); + fixture.detectChanges(); + + const alertElements = fixture.debugElement.queryAll(By.css('.alert')); + expect(alertElements.length).toBe(0); + }); + + it('should display an error alert when request has not been accepted', () => { + // Set up a request that has not been accepted + const itemRequest = createItemRequest(false, false); + itemRequestSubject.next(itemRequest); + fixture.detectChanges(); + + // Check for the error alert with the correct class + const alertElement = fixture.debugElement.query(By.css('.alert.alert-danger.request-a-copy-access-success')); + expect(alertElement).toBeTruthy(); + + // Verify the content includes the lock icon + const lockIcon = alertElement.query(By.css('.fa-lock')); + expect(lockIcon).toBeTruthy(); + + // Verify the text content mentions re-requesting + const paragraphs = alertElement.queryAll(By.css('p')); + expect(paragraphs.length).toBe(2); + }); + + it('should display an expired access alert when access period has expired', () => { + // Set up a request that has been accepted but expired + const itemRequest = createItemRequest(true, true, '2023-01-01'); + itemRequestSubject.next(itemRequest); + fixture.detectChanges(); + + // Check for the expired alert with the correct class + const alertElement = fixture.debugElement.query(By.css('.alert.alert-danger.request-a-copy-access-expired')); + expect(alertElement).toBeTruthy(); + }); +}); diff --git a/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.ts b/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.ts new file mode 100644 index 0000000000..867cf51e18 --- /dev/null +++ b/src/app/item-page/simple/access-by-token-notification/access-by-token-notification.component.ts @@ -0,0 +1,56 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ItemRequest } from '../../../core/shared/item-request.model'; +import { + dateToString, + stringToNgbDateStruct, +} from '../../../shared/date.util'; +import { + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { VarDirective } from '../../../shared/utils/var.directive'; + +@Component({ + selector: 'ds-access-by-token-notification', + templateUrl: './access-by-token-notification.component.html', + styleUrls: ['./access-by-token-notification.component.scss'], + imports: [ + AsyncPipe, + TranslateModule, + VarDirective, + ], + standalone: true, +}) +export class AccessByTokenNotificationComponent implements OnInit { + + itemRequest$: Observable; + protected readonly hasValue = hasValue; + + constructor(protected route: ActivatedRoute) { + } + + ngOnInit() { + this.itemRequest$ = this.route.data.pipe( + map((data) => data.itemRequest as ItemRequest), + ); + } + + /** + * Returns a date in simplified format (YYYY-MM-DD). + * + * @param date + * @return a string with formatted date + */ + formatDate(date: string): string { + return isNotEmpty(date) ? dateToString(stringToNgbDateStruct(date)) : ''; + } +} diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index 2e82824451..2052a36b81 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -3,18 +3,9 @@
@if (itemRD?.payload; as item) {
- - @if (hasValue(itemRequest)) { -
-

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

- - @if (hasValue(itemRequest.accessExpiry) && !itemRequest.accessExpiry.startsWith('+294276')) { -

{{ 'bitstream-request-a-copy.access-by-token.expiry-label' | translate }} {{ itemRequest.accessExpiry }}

- } -
- } -
+ + diff --git a/src/app/item-page/simple/item-page.component.scss b/src/app/item-page/simple/item-page.component.scss index 862ddd4f45..6c5c4746c1 100644 --- a/src/app/item-page/simple/item-page.component.scss +++ b/src/app/item-page/simple/item-page.component.scss @@ -4,7 +4,4 @@ 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 16e701623f..9efcd3d9a7 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -56,6 +56,7 @@ import { ThemedItemAlertsComponent } from '../alerts/themed-item-alerts.componen import { getItemPageRoute } from '../item-page-routing-paths'; import { ItemVersionsComponent } from '../versions/item-versions.component'; import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; +import { AccessByTokenNotificationComponent } from './access-by-token-notification/access-by-token-notification.component'; import { NotifyRequestsStatusComponent } from './notify-requests-status/notify-requests-status-component/notify-requests-status.component'; import { QaEventNotificationComponent } from './qa-event-notification/qa-event-notification.component'; @@ -84,6 +85,7 @@ import { QaEventNotificationComponent } from './qa-event-notification/qa-event-n AsyncPipe, NotifyRequestsStatusComponent, QaEventNotificationComponent, + AccessByTokenNotificationComponent, ], }) export class ItemPageComponent implements OnInit, OnDestroy { @@ -155,9 +157,7 @@ export class ItemPageComponent implements OnInit, OnDestroy { this.itemRD$ = this.route.data.pipe( map((data) => data.dso as RemoteData), ); - this.itemRequest$ = this.route.data.pipe( - map((data) => data.itemRequest as ItemRequest), - ); + this.itemPageRoute$ = this.itemRD$.pipe( getAllSucceededRemoteDataPayload(), map((item) => getItemPageRoute(item)), 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 78014331a4..4b18747a95 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 @@ -1,3 +1,3 @@ .request-a-copy-access-icon { - color: #26a269; + color: var(--bs-success); } diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index 4dd3abfc85..189ce60445 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -44,6 +44,8 @@ describe('FileDownloadLinkComponent', () => { token: 'item-request-token', requestName: 'requester name', accessToken: 'abc123', + acceptRequest: true, + accessExpired: false, allfiles: true, }); 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 93fa07b0ad..7eb2392b07 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 @@ -99,7 +99,7 @@ export class FileDownloadLinkComponent implements OnInit { 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); - this.canDownloadWithToken$ = observableOf(this.itemRequest ? (this.itemRequest.allfiles !== false || this.itemRequest.bitstreamId === this.bitstream.uuid) : false); + this.canDownloadWithToken$ = observableOf((this.itemRequest && this.itemRequest.acceptRequest && !this.itemRequest.accessExpired) ? (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( diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 5617d4eb04..33040de68d 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1002,6 +1002,12 @@ "bitstream-request-a-copy.access-by-token.expiry-label": "Access provided by this link will expire on", + "bitstream-request-a-copy.access-by-token.expired": "Access provided by this link is no longer possible. Access expired on", + + "bitstream-request-a-copy.access-by-token.not-granted": "Access provided by this link is not possible. Access has either not been granted, or has been revoked.", + + "bitstream-request-a-copy.access-by-token.re-request": "Follow restricted download links to submit a new request for access.", + "bitstream-request-a-copy.access-by-token.alt-text": "Access to this item is provided by a secure token", "browse.back.all-results": "All browse results", diff --git a/src/themes/custom/app/item-page/simple/item-page.component.ts b/src/themes/custom/app/item-page/simple/item-page.component.ts index 9a9551012a..a45269f292 100644 --- a/src/themes/custom/app/item-page/simple/item-page.component.ts +++ b/src/themes/custom/app/item-page/simple/item-page.component.ts @@ -6,6 +6,7 @@ import { import { TranslateModule } from '@ngx-translate/core'; import { ThemedItemAlertsComponent } from '../../../../../app/item-page/alerts/themed-item-alerts.component'; +import { AccessByTokenNotificationComponent } from '../../../../../app/item-page/simple/access-by-token-notification/access-by-token-notification.component'; import { ItemPageComponent as BaseComponent } from '../../../../../app/item-page/simple/item-page.component'; import { NotifyRequestsStatusComponent } from '../../../../../app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component'; import { QaEventNotificationComponent } from '../../../../../app/item-page/simple/qa-event-notification/qa-event-notification.component'; @@ -45,6 +46,7 @@ import { ViewTrackerComponent } from '../../../../../app/statistics/angulartics/ AsyncPipe, NotifyRequestsStatusComponent, QaEventNotificationComponent, + AccessByTokenNotificationComponent, ], }) export class ItemPageComponent extends BaseComponent {