Merge pull request #3984 from the-library-code/request-a-copy-secure-links_main

Request-a-copy improvements: Support access by secure link
This commit is contained in:
Tim Donohue
2025-03-28 09:59:58 -05:00
committed by GitHub
54 changed files with 1376 additions and 111 deletions

32
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@ngrx/store": "^18.1.1", "@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3", "@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"altcha": "^0.9.0",
"angulartics2": "^12.2.0", "angulartics2": "^12.2.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3", "bootstrap": "^5.3",
@@ -164,6 +165,12 @@
"version": "0.0.0", "version": "0.0.0",
"dev": true "dev": true
}, },
"node_modules/@altcha/crypto": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@altcha/crypto/-/crypto-0.0.1.tgz",
"integrity": "sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==",
"license": "MIT"
},
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -8895,6 +8902,31 @@
"ajv": "^8.8.2" "ajv": "^8.8.2"
} }
}, },
"node_modules/altcha": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/altcha/-/altcha-0.9.0.tgz",
"integrity": "sha512-W83eEYpBw5lg37O9c/rtBpp0AaW3+6uiMHifSW8VKFRs2afps16UMO6B93Kaqbr/xA9KNSPEW3q0PwwA01+Ugg==",
"license": "MIT",
"dependencies": {
"@altcha/crypto": "^0.0.1"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.18.0"
}
},
"node_modules/altcha/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz",
"integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/angulartics2": { "node_modules/angulartics2": {
"version": "12.2.1", "version": "12.2.1",
"resolved": "https://registry.npmjs.org/angulartics2/-/angulartics2-12.2.1.tgz", "resolved": "https://registry.npmjs.org/angulartics2/-/angulartics2-12.2.1.tgz",

View File

@@ -114,6 +114,7 @@
"@ngrx/store": "^18.1.1", "@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3", "@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"altcha": "^0.9.0",
"angulartics2": "^12.2.0", "angulartics2": "^12.2.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3", "bootstrap": "^5.3",

View File

@@ -35,6 +35,41 @@ 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 };
}
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;
}
export const COAR_NOTIFY_SUPPORT = 'coar-notify-support';
export const HOME_PAGE_PATH = 'home'; export const HOME_PAGE_PATH = 'home';
export function getHomePageRoute() { export function getHomePageRoute() {
@@ -128,6 +163,11 @@ export function getRequestCopyModulePath() {
return `/${REQUEST_COPY_MODULE_PATH}`; 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 HEALTH_PAGE_PATH = 'health';
export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions'; export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions';

View File

@@ -73,16 +73,16 @@ describe('BitstreamDownloadPageComponent', () => {
self: { href: 'bitstream-self-link' }, self: { href: 'bitstream-self-link' },
}, },
}); });
activatedRoute = { activatedRoute = {
data: observableOf({ data: observableOf({
bitstream: createSuccessfulRemoteDataObject( bitstream: createSuccessfulRemoteDataObject(bitstream),
bitstream,
),
}), }),
params: observableOf({ params: observableOf({
id: 'testid', id: 'testid',
}), }),
queryParams: observableOf({
accessToken: undefined,
}),
}; };
router = jasmine.createSpyObj('router', ['navigateByUrl']); router = jasmine.createSpyObj('router', ['navigateByUrl']);

View File

@@ -11,6 +11,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { import {
ActivatedRoute, ActivatedRoute,
Params,
Router, Router,
} from '@angular/router'; } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -83,6 +84,10 @@ export class BitstreamDownloadPageComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
const accessToken$: Observable<string> = this.route.queryParams.pipe(
map((queryParams: Params) => queryParams?.accessToken || null),
take(1),
);
this.bitstreamRD$ = this.route.data.pipe( this.bitstreamRD$ = this.route.data.pipe(
map((data) => data.bitstream)); map((data) => data.bitstream));
@@ -96,11 +101,11 @@ export class BitstreamDownloadPageComponent implements OnInit {
switchMap((bitstream: Bitstream) => { switchMap((bitstream: Bitstream) => {
const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined); const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined);
const isLoggedIn$ = this.auth.isAuthenticated(); 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), take(1),
switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => { switchMap(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => {
if (isAuthorized && isLoggedIn) { if (isAuthorized && isLoggedIn) {
return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe( return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
filter((fileLink) => hasValue(fileLink)), filter((fileLink) => hasValue(fileLink)),
@@ -108,21 +113,29 @@ export class BitstreamDownloadPageComponent implements OnInit {
map((fileLink) => { map((fileLink) => {
return [isAuthorized, isLoggedIn, bitstream, fileLink]; return [isAuthorized, isLoggedIn, bitstream, fileLink];
})); }));
} else if (hasValue(accessToken)) {
return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]];
} else { } else {
return [[isAuthorized, isLoggedIn, bitstream, '']]; 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)) { if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
this.hardRedirectService.redirect(fileLink); this.hardRedirectService.redirect(fileLink);
} else if (isAuthorized && !isLoggedIn) { } else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) {
this.hardRedirectService.redirect(bitstream._links.content.href); this.hardRedirectService.redirect(bitstream._links.content.href);
} else if (!isAuthorized && isLoggedIn) { } 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);
} else if (isLoggedIn) {
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true }); this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
} else if (!isAuthorized && !isLoggedIn) { } else if (!isLoggedIn) {
this.auth.setRedirectUrl(this.router.url); this.auth.setRedirectUrl(this.router.url);
this.router.navigateByUrl('login'); this.router.navigateByUrl('login');
} }
}
}); });
} }

View File

@@ -0,0 +1,62 @@
import { inject } from '@angular/core';
import {
ResolveFn,
Router,
} from '@angular/router';
import { Observable } from 'rxjs';
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 {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
} 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<ItemRequest> = (
route,
state,
router: Router = inject(Router),
authService: AuthService = inject(AuthService),
itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService),
): Observable<ItemRequest> => {
const accessToken = route.queryParams.accessToken;
// Set null object if accesstoken is empty
if ( !hasValue(accessToken) ) {
return null;
}
// 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),
map((rd: RemoteData<ItemRequest>) => rd),
// Get payload of the item request
getFirstSucceededRemoteDataPayload(),
tap(request => {
if (!hasValue(request)) {
// If the request is not found, redirect to 403 Forbidden
router.navigateByUrl(getForbiddenRoute());
}
// Return the resolved item request object
return request;
}),
);
};

View File

@@ -95,7 +95,7 @@ describe('EpersonRegistrationService', () => {
const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken'); const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken');
let headers = new HttpHeaders(); let headers = new HttpHeaders();
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken'); headers = headers.append('x-captcha-payload', 'afreshcaptchatoken');
options.headers = headers; options.headers = headers;
expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options)); expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options));

View File

@@ -69,7 +69,7 @@ export class EpersonRegistrationService {
/** /**
* Register a new email address * Register a new email address
* @param email * @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<RemoteData<Registration>> { registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
const registration = new Registration(); const registration = new Registration();
@@ -82,7 +82,7 @@ export class EpersonRegistrationService {
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); let headers = new HttpHeaders();
if (captchaToken) { if (captchaToken) {
headers = headers.append('x-recaptcha-token', captchaToken); headers = headers.append('x-captcha-payload', captchaToken);
} }
options.headers = headers; options.headers = headers;

View File

@@ -1,10 +1,17 @@
import { HttpHeaders } from '@angular/common/http';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; 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 { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ConfigurationProperty } from '../shared/configuration-property.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ItemRequest } from '../shared/item-request.model'; 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 { ItemRequestDataService } from './item-request-data.service';
import { PostRequest } from './request.models'; import { PostRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
@@ -16,12 +23,36 @@ describe('ItemRequestDataService', () => {
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
let halService: HALEndpointService; let halService: HALEndpointService;
let configService: ConfigurationDataService;
let authorizationDataService: AuthorizationDataService;
const restApiEndpoint = 'rest/api/endpoint/'; const restApiEndpoint = 'rest/api/endpoint/';
const requestId = 'request-id'; const requestId = 'request-id';
let itemRequest: ItemRequest; let itemRequest: ItemRequest;
beforeEach(() => { 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: ['FOREVER', '+1DAY', '+1MONTH'],
}));
default:
return createSuccessfulRemoteDataObject$(new ConfigurationProperty());
}
});
authorizationDataService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(false),
});
itemRequest = Object.assign(new ItemRequest(), { itemRequest = Object.assign(new ItemRequest(), {
token: 'item-request-token', token: 'item-request-token',
}); });
@@ -36,13 +67,42 @@ describe('ItemRequestDataService', () => {
getEndpoint: observableOf(restApiEndpoint), 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', () => { describe('requestACopy', () => {
it('should send a POST request containing the provided item request', (done) => { it('should send a POST request containing the provided item request', (done) => {
service.requestACopy(itemRequest).subscribe(() => { const captchaPayload = 'payload';
expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest)); service.requestACopy(itemRequest, captchaPayload).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(
new PostRequest(
requestId,
restApiEndpoint,
itemRequest,
{
headers: new HttpHeaders().set('x-captcha-payload', captchaPayload),
},
),
false,
);
done(); done();
}); });
}); });
@@ -56,14 +116,19 @@ describe('ItemRequestDataService', () => {
}); });
it('should send a PUT request containing the correct properties', (done) => { it('should send a PUT request containing the correct properties', (done) => {
service.grant(itemRequest.token, email, true).subscribe(() => { service.grant(itemRequest.token, email, true, '+1DAY').subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT, method: RestRequestMethod.PUT,
href: `${restApiEndpoint}/${itemRequest.token}`,
body: JSON.stringify({ body: JSON.stringify({
acceptRequest: true, acceptRequest: true,
responseMessage: email.message, responseMessage: email.message,
subject: email.subject, subject: email.subject,
suggestOpenAccess: true, suggestOpenAccess: true,
accessPeriod: '+1DAY',
}),
options: jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
}), }),
})); }));
done(); done();
@@ -82,15 +147,70 @@ describe('ItemRequestDataService', () => {
service.deny(itemRequest.token, email).subscribe(() => { service.deny(itemRequest.token, email).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT, method: RestRequestMethod.PUT,
href: `${restApiEndpoint}/${itemRequest.token}`,
body: JSON.stringify({ body: JSON.stringify({
acceptRequest: false, acceptRequest: false,
responseMessage: email.message, responseMessage: email.message,
subject: email.subject, subject: email.subject,
suggestOpenAccess: false, suggestOpenAccess: false,
accessPeriod: null,
}),
options: jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
}), }),
})); }));
done(); 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(['FOREVER', '+1DAY', '+1MONTH']);
});
});
});
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);
});
});
});
}); });

View File

@@ -13,14 +13,27 @@ import {
hasValue, hasValue,
isNotEmpty, isNotEmpty,
} from '../../shared/empty.util'; } from '../../shared/empty.util';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; 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 { ObjectCacheService } from '../cache/object-cache.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.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 { HALEndpointService } from '../shared/hal-endpoint.service';
import { ItemRequest } from '../shared/item-request.model'; import { ItemRequest } from '../shared/item-request.model';
import { getFirstCompletedRemoteData } from '../shared/operators'; import { getFirstCompletedRemoteData } from '../shared/operators';
import { sendRequest } from '../shared/request.operators'; import { sendRequest } from '../shared/request.operators';
import { IdentifiableDataService } from './base/identifiable-data.service'; 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 { RemoteData } from './remote-data';
import { import {
PostRequest, PostRequest,
@@ -34,14 +47,20 @@ import { RequestService } from './request.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> { export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> implements SearchData<ItemRequest> {
private searchData: SearchDataImpl<ItemRequest>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected configService: ConfigurationDataService,
protected authorizationService: AuthorizationDataService,
) { ) {
super('itemrequests', requestService, rdbService, objectCache, halService); super('itemrequests', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
} }
getItemRequestEndpoint(): Observable<string> { getItemRequestEndpoint(): Observable<string> {
@@ -61,17 +80,26 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
/** /**
* Request a copy of an item * Request a copy of an item
* @param itemRequest * @param itemRequest
* @param captchaPayload payload of captcha verification
*/ */
requestACopy(itemRequest: ItemRequest): Observable<RemoteData<ItemRequest>> { requestACopy(itemRequest: ItemRequest, captchaPayload: string): Observable<RemoteData<ItemRequest>> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
const href$ = this.getItemRequestEndpoint(); 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( href$.pipe(
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
map((href: string) => { map((href: string) => {
const request = new PostRequest(requestId, href, itemRequest); const request = new PostRequest(requestId, href, itemRequest, options);
this.requestService.send(request); this.requestService.send(request, false);
}), }),
).subscribe(); ).subscribe();
@@ -94,9 +122,10 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
* @param token Token of the {@link ItemRequest} * @param token Token of the {@link ItemRequest}
* @param email Email to send back to the user requesting the item * @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 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<RemoteData<ItemRequest>> { grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod: string = null): Observable<RemoteData<ItemRequest>> {
return this.process(token, email, true, suggestOpenAccess); return this.process(token, email, true, suggestOpenAccess, accessPeriod);
} }
/** /**
@@ -105,8 +134,9 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
* @param email Email to send back to the user requesting the item * @param email Email to send back to the user requesting the item
* @param grant Grant or deny the request (true = grant, false = deny) * @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 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<RemoteData<ItemRequest>> { process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod: string = null): Observable<RemoteData<ItemRequest>> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
this.getItemRequestEndpointByToken(token).pipe( this.getItemRequestEndpointByToken(token).pipe(
@@ -121,6 +151,7 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
responseMessage: email.message, responseMessage: email.message,
subject: email.subject, subject: email.subject,
suggestOpenAccess, suggestOpenAccess,
accessPeriod: accessPeriod,
}), options); }), options);
}), }),
sendRequest(this.requestService), sendRequest(this.requestService),
@@ -128,4 +159,81 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
return this.rdbService.buildFromRequestUUID(requestId); return this.rdbService.buildFromRequestUUID(requestId);
} }
/**
* 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<RemoteData<ItemRequest>> {
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<ItemRequest>[]): Observable<RemoteData<PaginatedList<ItemRequest>>> {
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<string[]> {
return this.configService.findByPropertyName('request.item.grant.link.period').pipe(
getFirstCompletedRemoteData(),
map((propertyRD: RemoteData<ConfigurationProperty>) => propertyRD.hasSucceeded ? propertyRD.payload.values : []),
);
}
/**
* 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<boolean> {
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<string>}
* 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<ItemRequest>[]): Observable<string> {
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<boolean>} true if user may download, false if not
*/
canDownload(bitstream: Bitstream): Observable<boolean> {
return this.authorizationService.isAuthorized(FeatureID.CanDownload, bitstream?.self);
}
} }

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { HALEndpointService } from '../shared/hal-endpoint.service';
/**
* Service for retrieving captcha challenge data, so proof-of-work calculations can be performed
* and returned with protected form data.
*/
@Injectable({ providedIn: 'root' })
export class ProofOfWorkCaptchaDataService {
private linkPath = 'captcha';
constructor(
private halService: HALEndpointService) {
}
/**
* Get the endpoint for retrieving a new captcha challenge, to be passed
* to the Altcha captcha component as an input property
*/
public getChallengeHref(): Observable<string> {
return this.getEndpoint().pipe(
map((endpoint) => endpoint + '/challenge'),
);
}
/**
* Get the base CAPTCHA endpoint URL
* @protected
*/
protected getEndpoint(): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
}

View File

@@ -80,7 +80,19 @@ export class ItemRequest implements CacheableObject {
*/ */
@autoserialize @autoserialize
bitstreamId: string; bitstreamId: string;
/**
* Access token of the request (read-only)
*/
@autoserialize
accessToken: string;
/**
* Access expiry date of the request
*/
@autoserialize
accessExpiry: string;
@autoserialize
accessExpired: boolean;
/** /**
* The {@link HALLink}s for this ItemRequest * The {@link HALLink}s for this ItemRequest
*/ */

View File

@@ -23,4 +23,9 @@ export class MediaViewerItem {
* Incoming Bitsream thumbnail * Incoming Bitsream thumbnail
*/ */
thumbnail: string; thumbnail: string;
/**
* Access token, if accessed via a Request-a-Copy link
*/
accessToken: string;
} }

View File

@@ -0,0 +1,6 @@
<altcha-widget
id="altcha-widget"
auto="{{ autoload }}"
expire="100000"
workers=16
challengeurl="{{ challengeUrl }}"></altcha-widget>

View File

@@ -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<AltchaCaptchaComponent>;
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);
});
});

View File

@@ -0,0 +1,61 @@
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,
})
/**
* 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;
// 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<string>;
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);
}
});
}
}

View File

@@ -84,6 +84,14 @@
</div> </div>
</form> </form>
<!-- Captcha - to be rendered only if enabled in backend requestitem.cfg -->
@if (!!(captchaEnabled$ | async)) {
<div *ngVar="challengeHref$ | async as href">
<ds-altcha-captcha autoload="onload" challengeUrl="{{ href }}" (payload)="handlePayload($event)">
</ds-altcha-captcha>
</div>
}
<hr> <hr>
<div class="row"> <div class="row">
<div class="col-12 text-end"> <div class="col-12 text-end">

View File

@@ -16,19 +16,27 @@ import {
ActivatedRoute, ActivatedRoute,
Router, Router,
} from '@angular/router'; } from '@angular/router';
import { Store } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface';
import { AuthService } from '../../../core/auth/auth.service'; import { AuthService } from '../../../core/auth/auth.service';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.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 { BitstreamDataService } from '../../../core/data/bitstream-data.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { ItemRequestDataService } from '../../../core/data/item-request-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 { EPerson } from '../../../core/eperson/models/eperson.model';
import { Bitstream } from '../../../core/shared/bitstream.model'; import { Bitstream } from '../../../core/shared/bitstream.model';
import { Item } from '../../../core/shared/item.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 { ItemRequest } from '../../../core/shared/item-request.model';
import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; 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 { NotificationsService } from '../../../shared/notifications/notifications.service';
import { import {
createFailedRemoteDataObject$, createFailedRemoteDataObject$,
@@ -39,6 +47,9 @@ import { NotificationsServiceStub } from '../../../shared/testing/notifications-
import { RouterStub } from '../../../shared/testing/router.stub'; import { RouterStub } from '../../../shared/testing/router.stub';
import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page.component'; 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', () => { describe('BitstreamRequestACopyPageComponent', () => {
let component: BitstreamRequestACopyPageComponent; let component: BitstreamRequestACopyPageComponent;
@@ -48,10 +59,11 @@ describe('BitstreamRequestACopyPageComponent', () => {
let authorizationService: AuthorizationDataService; let authorizationService: AuthorizationDataService;
let activatedRoute; let activatedRoute;
let router; let router;
let itemRequestDataService; let itemRequestDataService: ItemRequestDataService;
let notificationsService; let notificationsService;
let location; let location;
let bitstreamDataService; let bitstreamDataService;
let requestService;
let item: Item; let item: Item;
let bitstream: Bitstream; let bitstream: Bitstream;
@@ -75,8 +87,20 @@ describe('BitstreamRequestACopyPageComponent', () => {
itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', {
requestACopy: createSuccessfulRemoteDataObject$({}), 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', { location = jasmine.createSpyObj('location', {
back: {}, back: {},
}); });
@@ -124,6 +148,9 @@ describe('BitstreamRequestACopyPageComponent', () => {
{ provide: NotificationsService, useValue: notificationsService }, { provide: NotificationsService, useValue: notificationsService },
{ provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: DSONameService, useValue: new DSONameServiceMock() },
{ provide: BitstreamDataService, useValue: bitstreamDataService }, { provide: BitstreamDataService, useValue: bitstreamDataService },
{ provide: Store, useValue: provideMockStore() },
{ provide: RequestService, useValue: requestService },
{ provide: APP_DATA_SERVICES_MAP, useValue: mockDataServiceMap },
], ],
}) })
.compileComponents(); .compileComponents();
@@ -246,6 +273,7 @@ describe('BitstreamRequestACopyPageComponent', () => {
component.email.patchValue('user@name.org'); component.email.patchValue('user@name.org');
component.allfiles.patchValue('false'); component.allfiles.patchValue('false');
component.message.patchValue('I would like to request a copy'); component.message.patchValue('I would like to request a copy');
component.captchaPayload.patchValue('payload');
component.onSubmit(); component.onSubmit();
const itemRequest = Object.assign(new ItemRequest(), const itemRequest = Object.assign(new ItemRequest(),
@@ -258,7 +286,7 @@ describe('BitstreamRequestACopyPageComponent', () => {
requestMessage: 'I would like to request a copy', requestMessage: 'I would like to request a copy',
}); });
expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest, 'payload');
expect(notificationsService.success).toHaveBeenCalled(); expect(notificationsService.success).toHaveBeenCalled();
expect(location.back).toHaveBeenCalled(); expect(location.back).toHaveBeenCalled();
}); });
@@ -280,6 +308,7 @@ describe('BitstreamRequestACopyPageComponent', () => {
component.email.patchValue('user@name.org'); component.email.patchValue('user@name.org');
component.allfiles.patchValue('false'); component.allfiles.patchValue('false');
component.message.patchValue('I would like to request a copy'); component.message.patchValue('I would like to request a copy');
component.captchaPayload.patchValue('payload');
component.onSubmit(); component.onSubmit();
const itemRequest = Object.assign(new ItemRequest(), const itemRequest = Object.assign(new ItemRequest(),
@@ -292,7 +321,7 @@ describe('BitstreamRequestACopyPageComponent', () => {
requestMessage: 'I would like to request a copy', requestMessage: 'I would like to request a copy',
}); });
expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest, 'payload');
expect(notificationsService.error).toHaveBeenCalled(); expect(notificationsService.error).toHaveBeenCalled();
expect(location.back).not.toHaveBeenCalled(); expect(location.back).not.toHaveBeenCalled();
}); });

View File

@@ -1,9 +1,13 @@
import 'altcha';
import { import {
AsyncPipe, AsyncPipe,
Location, Location,
} from '@angular/common'; } from '@angular/common';
import { import {
ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, OnDestroy,
OnInit, OnInit,
} from '@angular/core'; } 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 { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { ItemRequestDataService } from '../../../core/data/item-request-data.service'; 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 { EPerson } from '../../../core/eperson/models/eperson.model';
import { Bitstream } from '../../../core/shared/bitstream.model'; import { Bitstream } from '../../../core/shared/bitstream.model';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
@@ -60,7 +65,9 @@ import {
isNotEmpty, isNotEmpty,
} from '../../../shared/empty.util'; } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { VarDirective } from '../../../shared/utils/var.directive';
import { getItemPageRoute } from '../../item-page-routing-paths'; import { getItemPageRoute } from '../../item-page-routing-paths';
import { AltchaCaptchaComponent } from './altcha-captcha.component';
@Component({ @Component({
selector: 'ds-bitstream-request-a-copy-page', selector: 'ds-bitstream-request-a-copy-page',
@@ -71,7 +78,10 @@ import { getItemPageRoute } from '../../item-page-routing-paths';
AsyncPipe, AsyncPipe,
ReactiveFormsModule, ReactiveFormsModule,
BtnDisabledDirective, BtnDisabledDirective,
VarDirective,
AltchaCaptchaComponent,
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
standalone: true, standalone: true,
}) })
/** /**
@@ -92,6 +102,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
bitstream: Bitstream; bitstream: Bitstream;
bitstreamName: string; bitstreamName: string;
// Captcha settings
captchaEnabled$: Observable<boolean>;
challengeHref$: Observable<string>;
constructor(private location: Location, constructor(private location: Location,
private translateService: TranslateService, private translateService: TranslateService,
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -103,6 +117,8 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private dsoNameService: DSONameService, private dsoNameService: DSONameService,
private bitstreamService: BitstreamDataService, private bitstreamService: BitstreamDataService,
private captchaService: ProofOfWorkCaptchaDataService,
private changeDetectorRef: ChangeDetectorRef,
) { ) {
} }
@@ -117,8 +133,15 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
}), }),
allfiles: new UntypedFormControl(''), allfiles: new UntypedFormControl(''),
message: 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( this.item$ = this.route.data.pipe(
map((data) => data.dso), map((data) => data.dso),
@@ -172,6 +195,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
return this.requestCopyForm.get('allfiles'); return this.requestCopyForm.get('allfiles');
} }
get captchaPayload() {
return this.requestCopyForm.get('captchaPayload');
}
/** /**
* Initialise the form values based on the current user. * 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.bitstream$.pipe(take(1)).subscribe((bitstream) => {
this.requestCopyForm.patchValue({ allfiles: 'false' }); 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.requestEmail = this.email.value;
itemRequest.requestName = this.name.value; itemRequest.requestName = this.name.value;
itemRequest.requestMessage = this.message.value; itemRequest.requestMessage = this.message.value;
const captchaPayloadString: string = this.captchaPayload.value;
this.itemRequestDataService.requestACopy(itemRequest).pipe( this.itemRequestDataService.requestACopy(itemRequest, captchaPayloadString).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
).subscribe((rd) => { ).subscribe((rd) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
@@ -231,6 +270,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
}); });
} }
handlePayload(event): void {
this.requestCopyForm.patchValue({ captchaPayload: event });
}
ngOnDestroy(): void { ngOnDestroy(): void {
if (hasValue(this.subs)) { if (hasValue(this.subs)) {
this.subs.forEach((sub) => { this.subs.forEach((sub) => {

View File

@@ -1,6 +1,7 @@
import { Route } from '@angular/router'; import { Route } from '@angular/router';
import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; 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 { authenticatedGuard } from '../core/auth/authenticated.guard';
import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver'; import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
@@ -11,6 +12,7 @@ import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.c
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
import { itemPageResolver } from './item-page.resolver'; import { itemPageResolver } from './item-page.resolver';
import { import {
ITEM_ACCESS_BY_TOKEN_PATH,
ITEM_EDIT_PATH, ITEM_EDIT_PATH,
ORCID_PATH, ORCID_PATH,
UPLOAD_BITSTREAM_PATH, UPLOAD_BITSTREAM_PATH,
@@ -26,6 +28,7 @@ export const ROUTES: Route[] = [
path: ':id', path: ':id',
resolve: { resolve: {
dso: itemPageResolver, dso: itemPageResolver,
itemRequest: accessTokenResolver,
breadcrumb: itemBreadcrumbResolver, breadcrumb: itemBreadcrumbResolver,
}, },
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
@@ -64,6 +67,13 @@ export const ROUTES: Route[] = [
component: OrcidPageComponent, component: OrcidPageComponent,
canActivate: [authenticatedGuard, orcidPageGuard], canActivate: [authenticatedGuard, orcidPageGuard],
}, },
{
path: ITEM_ACCESS_BY_TOKEN_PATH,
component: ThemedFullItemPageComponent,
resolve: {
menu: accessTokenResolver,
},
},
], ],
data: { data: {
menu: { menu: {

View File

@@ -51,3 +51,5 @@ export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory';
export const ITEM_VERSION_PATH = 'version'; export const ITEM_VERSION_PATH = 'version';
export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new';
export const ORCID_PATH = 'orcid'; export const ORCID_PATH = 'orcid';
export const ITEM_ACCESS_BY_TOKEN_PATH = 'access-by-token';

View File

@@ -15,6 +15,7 @@ import { Observable } from 'rxjs';
import { AuthService } from '../../../core/auth/auth.service'; import { AuthService } from '../../../core/auth/auth.service';
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; 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 * This component render an image gallery for the image viewer
@@ -99,7 +100,7 @@ export class MediaViewerImageComponent implements OnChanges, OnInit {
medium: image.thumbnail medium: image.thumbnail
? image.thumbnail ? image.thumbnail
: this.thumbnailPlaceholder, : this.thumbnailPlaceholder,
big: image.bitstream._links.content.href, big: image.bitstream._links.content.href + (hasValue(image.accessToken) ? ('?accessToken=' + image.accessToken) : ''),
}); });
} }
} }

View File

@@ -1,6 +1,6 @@
<video <video
crossorigin="anonymous" crossorigin="anonymous"
[src]="medias[currentIndex].bitstream._links.content.href" [src]="constructHref(medias[currentIndex].bitstream._links.content.href)"
id="singleVideo" id="singleVideo"
[poster]=" [poster]="
medias[currentIndex].thumbnail || medias[currentIndex].thumbnail ||

View File

@@ -10,6 +10,7 @@ import { Bitstream } from 'src/app/core/shared/bitstream.model';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
import { hasValue } from '../../../shared/empty.util';
import { CaptionInfo } from './caption-info'; import { CaptionInfo } from './caption-info';
import { languageHelper } from './language-helper'; import { languageHelper } from './language-helper';
@@ -64,7 +65,7 @@ export class MediaViewerVideoComponent {
for (const media of filteredCapMedias) { for (const media of filteredCapMedias) {
const srclang: string = media.name.slice(-6, -4).toLowerCase(); const srclang: string = media.name.slice(-6, -4).toLowerCase();
capInfos.push(new CaptionInfo( capInfos.push(new CaptionInfo(
media._links.content.href, this.constructHref(media._links.content.href),
srclang, srclang,
languageHelper[srclang], languageHelper[srclang],
)); ));
@@ -93,4 +94,15 @@ export class MediaViewerVideoComponent {
prevMedia() { prevMedia() {
this.currentIndex--; this.currentIndex--;
} }
/**
* Construct a URL with Request-a-Copy access token appended, if present
* @param baseHref
*/
constructHref(baseHref) {
if (hasValue(this.medias) && this.medias.length >= 1 && hasValue(this.medias[0].accessToken)) {
return baseHref + '?accessToken=' + this.medias[0].accessToken;
}
return baseHref;
}
} }

View File

@@ -6,6 +6,7 @@ import {
} from '@angular/core/testing'; } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import { import {
TranslateLoader, TranslateLoader,
TranslateModule, TranslateModule,
@@ -22,6 +23,7 @@ import { MockBitstreamFormat1 } from '../../shared/mocks/item.mock';
import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { ThemeService } from '../../shared/theme-support/theme.service'; import { ThemeService } from '../../shared/theme-support/theme.service';
import { FileSizePipe } from '../../shared/utils/file-size-pipe'; import { FileSizePipe } from '../../shared/utils/file-size-pipe';
@@ -91,6 +93,7 @@ describe('MediaViewerComponent', () => {
{ provide: BitstreamDataService, useValue: bitstreamDataService }, { provide: BitstreamDataService, useValue: bitstreamDataService },
{ provide: ThemeService, useValue: getMockThemeService() }, { provide: ThemeService, useValue: getMockThemeService() },
{ provide: AuthService, useValue: new AuthServiceMock() }, { provide: AuthService, useValue: new AuthServiceMock() },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();

View File

@@ -6,6 +6,7 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
} from '@angular/core'; } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { import {
BehaviorSubject, BehaviorSubject,
@@ -25,6 +26,7 @@ import { RemoteData } from '../../core/data/remote-data';
import { Bitstream } from '../../core/shared/bitstream.model'; import { Bitstream } from '../../core/shared/bitstream.model';
import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { ItemRequest } from '../../core/shared/item-request.model';
import { MediaViewerItem } from '../../core/shared/media-viewer-item.model'; import { MediaViewerItem } from '../../core/shared/media-viewer-item.model';
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
@@ -70,9 +72,12 @@ export class MediaViewerComponent implements OnDestroy, OnInit {
subs: Subscription[] = []; subs: Subscription[] = [];
itemRequest: ItemRequest;
constructor( constructor(
protected bitstreamDataService: BitstreamDataService, protected bitstreamDataService: BitstreamDataService,
protected changeDetectorRef: ChangeDetectorRef, protected changeDetectorRef: ChangeDetectorRef,
protected route: ActivatedRoute,
) { ) {
} }
@@ -84,6 +89,7 @@ export class MediaViewerComponent implements OnDestroy, OnInit {
* This method loads all the Bitstreams and Thumbnails and converts it to {@link MediaViewerItem}s * This method loads all the Bitstreams and Thumbnails and converts it to {@link MediaViewerItem}s
*/ */
ngOnInit(): void { ngOnInit(): void {
this.itemRequest = this.route.snapshot.data.itemRequest;
const types: string[] = [ const types: string[] = [
...(this.mediaOptions.image ? ['image'] : []), ...(this.mediaOptions.image ? ['image'] : []),
...(this.mediaOptions.video ? ['audio', 'video'] : []), ...(this.mediaOptions.video ? ['audio', 'video'] : []),
@@ -120,6 +126,7 @@ export class MediaViewerComponent implements OnDestroy, OnInit {
})); }));
} }
})); }));
} }
/** /**
@@ -160,6 +167,18 @@ export class MediaViewerComponent implements OnDestroy, OnInit {
mediaItem.format = format.mimetype.split('/')[0]; mediaItem.format = format.mimetype.split('/')[0];
mediaItem.mimetype = format.mimetype; mediaItem.mimetype = format.mimetype;
mediaItem.thumbnail = thumbnail ? thumbnail._links.content.href : null; mediaItem.thumbnail = thumbnail ? thumbnail._links.content.href : null;
mediaItem.accessToken = this.accessToken;
return mediaItem; return mediaItem;
} }
/**
* Get access token, if this is accessed via a Request-a-Copy link
*/
get accessToken() {
if (hasValue(this.itemRequest) && this.itemRequest.accessToken && !this.itemRequest.accessExpired) {
return this.itemRequest.accessToken;
}
return null;
}
} }

View File

@@ -0,0 +1,25 @@
<ng-container *ngVar="(itemRequest$ | async) as itemRequest">
@if (hasValue(itemRequest)) {
@if (!itemRequest.acceptRequest) {
<!-- The request has NOT been accepted, display an error -->
<div class="alert alert-danger wb-100 mb-2 request-a-copy-access-success">
<p><span role="img" class="request-a-copy-access-error-icon" [attr.aria-label]="'bitstream-request-a-copy.access-by-token.alt-text' | translate"><i class="fa-solid fa-lock"></i></span>{{'bitstream-request-a-copy.access-by-token.not-granted' | translate}}</p>
<p>{{'bitstream-request-a-copy.access-by-token.re-request' | translate}}</p>
</div>
} @else if (itemRequest.accessExpired) {
<!-- The request is accepted, but the access period has expired, display an error -->
<div class="alert alert-danger wb-100 mb-2 request-a-copy-access-expired">
<p><span role="img" class="request-a-copy-access-error-icon" [attr.aria-label]="'bitstream-request-a-copy.access-by-token.alt-text' | translate"><i class="fa-solid fa-lock"></i></span>{{'bitstream-request-a-copy.access-by-token.expired' | translate}} {{ formatDate(itemRequest.accessExpiry) }}</p>
<p>{{'bitstream-request-a-copy.access-by-token.re-request' | translate}}</p>
</div>
} @else {
<div class="alert alert-warning wb-100 mb-2 request-a-copy-access-denied">
<p><span role="img" class="request-a-copy-access-icon" [attr.aria-label]="'bitstream-request-a-copy.access-by-token.alt-text' | translate"><i class="fa-solid fa-lock-open"></i></span>{{'bitstream-request-a-copy.access-by-token.warning' | translate}}</p>
<!-- Only show the expiry date if it's not null, and doesn't start with the "FOREVER" year -->
@if (hasValue(itemRequest.accessExpiry) && !itemRequest.accessExpiry.startsWith('+294276')) {
<p>{{ 'bitstream-request-a-copy.access-by-token.expiry-label' | translate }} {{ formatDate(itemRequest.accessExpiry) }}</p>
}
</div>
}
}
</ng-container>

View File

@@ -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;
}

View File

@@ -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<AccessByTokenNotificationComponent>;
let activatedRouteStub: ActivatedRouteStub;
let itemRequestSubject: BehaviorSubject<ItemRequest>;
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<ItemRequest>(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();
});
});

View File

@@ -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<ItemRequest>;
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)) : '';
}
}

View File

@@ -5,7 +5,7 @@
@for (file of bitstreams; track file; let last = $last) { @for (file of bitstreams; track file; let last = $last) {
<ds-file-download-link [bitstream]="file" [item]="item"> <ds-file-download-link [bitstream]="file" [item]="item">
<span> <span>
@if (primaryBitsreamId === file.id) { @if (primaryBitstreamId === file.id) {
<span class="badge bg-primary">{{ 'item.page.bitstreams.primary' | translate }}</span> <span class="badge bg-primary">{{ 'item.page.bitstreams.primary' | translate }}</span>
} }
{{ dsoNameService.getName(file) }} {{ dsoNameService.getName(file) }}

View File

@@ -111,17 +111,17 @@ describe('FileSectionComponent', () => {
})); }));
it('should set the id of primary bitstream', () => { it('should set the id of primary bitstream', () => {
comp.primaryBitsreamId = undefined; comp.primaryBitstreamId = undefined;
bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(mockBitstream)); bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(mockBitstream));
comp.ngOnInit(); comp.ngOnInit();
expect(comp.primaryBitsreamId).toBe(mockBitstream.id); expect(comp.primaryBitstreamId).toBe(mockBitstream.id);
}); });
it('should not set the id of primary bitstream', () => { it('should not set the id of primary bitstream', () => {
comp.primaryBitsreamId = undefined; comp.primaryBitstreamId = undefined;
bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(null)); bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(null));
comp.ngOnInit(); comp.ngOnInit();
expect(comp.primaryBitsreamId).toBeUndefined(); expect(comp.primaryBitstreamId).toBeUndefined();
}); });
describe('when the bitstreams are loading', () => { describe('when the bitstreams are loading', () => {

View File

@@ -67,7 +67,7 @@ export class FileSectionComponent implements OnInit {
pageSize: number; pageSize: number;
primaryBitsreamId: string; primaryBitstreamId: string;
constructor( constructor(
protected bitstreamDataService: BitstreamDataService, protected bitstreamDataService: BitstreamDataService,
@@ -89,7 +89,7 @@ export class FileSectionComponent implements OnInit {
if (!primaryBitstream) { if (!primaryBitstream) {
return; return;
} }
this.primaryBitsreamId = primaryBitstream?.id; this.primaryBitstreamId = primaryBitstream?.id;
}); });
} }

View File

@@ -3,7 +3,9 @@
<div class="item-page" @fadeInOut> <div class="item-page" @fadeInOut>
@if (itemRD?.payload; as item) { @if (itemRD?.payload; as item) {
<div> <div>
<ds-item-alerts [item]="item"></ds-item-alerts> <ds-item-alerts [item]="item"></ds-item-alerts>
<ds-access-by-token-notification></ds-access-by-token-notification>
<ds-qa-event-notification [item]="item"></ds-qa-event-notification> <ds-qa-event-notification [item]="item"></ds-qa-event-notification>
<ds-notify-requests-status [itemUuid]="item.uuid"></ds-notify-requests-status> <ds-notify-requests-status [itemUuid]="item.uuid"></ds-notify-requests-status>
<ds-item-versions-notice [item]="item"></ds-item-versions-notice> <ds-item-versions-notice [item]="item"></ds-item-versions-notice>

View File

@@ -4,3 +4,4 @@
max-width: none; max-width: none;
} }
} }

View File

@@ -39,10 +39,14 @@ import {
} from '../../core/services/link-head.service'; } from '../../core/services/link-head.service';
import { ServerResponseService } from '../../core/services/server-response.service'; import { ServerResponseService } from '../../core/services/server-response.service';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { ItemRequest } from '../../core/shared/item-request.model';
import { getAllSucceededRemoteDataPayload } from '../../core/shared/operators'; import { getAllSucceededRemoteDataPayload } from '../../core/shared/operators';
import { ViewMode } from '../../core/shared/view-mode.model'; import { ViewMode } from '../../core/shared/view-mode.model';
import { fadeInOut } from '../../shared/animations/fade'; import { fadeInOut } from '../../shared/animations/fade';
import { isNotEmpty } from '../../shared/empty.util'; import {
hasValue,
isNotEmpty,
} from '../../shared/empty.util';
import { ErrorComponent } from '../../shared/error/error.component'; import { ErrorComponent } from '../../shared/error/error.component';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { ListableObjectComponentLoaderComponent } from '../../shared/object-collection/shared/listable-object/listable-object-component-loader.component'; import { ListableObjectComponentLoaderComponent } from '../../shared/object-collection/shared/listable-object/listable-object-component-loader.component';
@@ -52,6 +56,7 @@ import { ThemedItemAlertsComponent } from '../alerts/themed-item-alerts.componen
import { getItemPageRoute } from '../item-page-routing-paths'; import { getItemPageRoute } from '../item-page-routing-paths';
import { ItemVersionsComponent } from '../versions/item-versions.component'; import { ItemVersionsComponent } from '../versions/item-versions.component';
import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.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 { NotifyRequestsStatusComponent } from './notify-requests-status/notify-requests-status-component/notify-requests-status.component';
import { QaEventNotificationComponent } from './qa-event-notification/qa-event-notification.component'; import { QaEventNotificationComponent } from './qa-event-notification/qa-event-notification.component';
@@ -80,6 +85,7 @@ import { QaEventNotificationComponent } from './qa-event-notification/qa-event-n
AsyncPipe, AsyncPipe,
NotifyRequestsStatusComponent, NotifyRequestsStatusComponent,
QaEventNotificationComponent, QaEventNotificationComponent,
AccessByTokenNotificationComponent,
], ],
}) })
export class ItemPageComponent implements OnInit, OnDestroy { export class ItemPageComponent implements OnInit, OnDestroy {
@@ -94,6 +100,11 @@ export class ItemPageComponent implements OnInit, OnDestroy {
*/ */
itemRD$: Observable<RemoteData<Item>>; itemRD$: Observable<RemoteData<Item>>;
/**
* The request item wrapped in a remote-data object, obtained from the route data
*/
itemRequest$: Observable<ItemRequest>;
/** /**
* The view-mode we're currently on * The view-mode we're currently on
*/ */
@@ -123,6 +134,8 @@ export class ItemPageComponent implements OnInit, OnDestroy {
coarRestApiUrls: string[] = []; coarRestApiUrls: string[] = [];
protected readonly hasValue = hasValue;
constructor( constructor(
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected router: Router, protected router: Router,
@@ -144,6 +157,7 @@ export class ItemPageComponent implements OnInit, OnDestroy {
this.itemRD$ = this.route.data.pipe( this.itemRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData<Item>), map((data) => data.dso as RemoteData<Item>),
); );
this.itemPageRoute$ = this.itemRD$.pipe( this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(), getAllSucceededRemoteDataPayload(),
map((item) => getItemPageRoute(item)), map((item) => getItemPageRoute(item)),
@@ -244,4 +258,17 @@ export class ItemPageComponent implements OnInit, OnDestroy {
this.linkHeadService.removeTag(`href='${link.href}'`); this.linkHeadService.removeTag(`href='${link.href}'`);
}); });
} }
/**
* 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;
}
}
} }

View File

@@ -1,7 +1,8 @@
<form> <form>
<div class="mb-3"> <div class="mb-3">
<label for="subject" class="form-label">{{ 'grant-deny-request-copy.email.subject' | translate }}</label> <label for="subject" class="form-label">{{ 'grant-deny-request-copy.email.subject' | translate }}</label>
<input type="text" class="form-control" id="subject" [ngClass]="{'is-invalid': !subject || subject.length === 0}" [(ngModel)]="subject" name="subject"> <input type="text" class="form-control" id="subject" [ngClass]="{'is-invalid': !subject || subject.length === 0}"
[(ngModel)]="subject" name="subject">
@if (!subject || subject.length === 0) { @if (!subject || subject.length === 0) {
<div class="invalid-feedback"> <div class="invalid-feedback">
{{ 'grant-deny-request-copy.email.subject.empty' | translate }} {{ 'grant-deny-request-copy.email.subject.empty' | translate }}
@@ -12,6 +13,34 @@
<label for="message" class="form-label">{{ 'grant-deny-request-copy.email.message' | translate }}</label> <label for="message" class="form-label">{{ 'grant-deny-request-copy.email.message' | translate }}</label>
<textarea class="form-control" id="message" rows="8" [(ngModel)]="message" name="message"></textarea> <textarea class="form-control" id="message" rows="8" [(ngModel)]="message" name="message"></textarea>
</div> </div>
<!-- Display access periods if more than one was bound to input. The parent component (grant-request-copy)
sends an empty list if the feature is not enabled or applicable to this request. -->
@if (hasValue(validAccessPeriods$ | async) && (validAccessPeriods$ | async).length > 0) {
<div class="form-group">
<label for="accessPeriod">{{ 'grant-request-copy.access-period.header' | translate }}</label>
<div ngbDropdown class="d-block">
<!-- Show current selected access period (defaults to first in array) -->
<button
class="btn btn-outline-primary playlist"
id="accessPeriod"
ngbDropdownToggle
> {{ 'grant-request-copy.access-period.' + this.accessPeriod | translate }}
</button>
<!-- Access period dropdown -->
<div ngbDropdownMenu aria-labelledby="accessPeriod" class="dropdown-menu">
@for (accessPeriod of (validAccessPeriods$ | async); track accessPeriod) {
<button
ngbDropdownItem
class="list-element"
(click)="selectAccessPeriod(accessPeriod)"
>
{{ ('grant-request-copy.access-period.' + accessPeriod | translate) }}
</button>
}
</div>
</div>
</div>
}
<ng-content></ng-content> <ng-content></ng-content>
<div class="d-flex flex-row-reverse"> <div class="d-flex flex-row-reverse">
<button (click)="submit()" <button (click)="submit()"

View File

@@ -7,6 +7,7 @@ import {
} from '@angular/core/testing'; } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { VarDirective } from '../../shared/utils/var.directive'; import { VarDirective } from '../../shared/utils/var.directive';
import { EmailRequestCopyComponent } from './email-request-copy.component'; import { EmailRequestCopyComponent } from './email-request-copy.component';
@@ -33,6 +34,8 @@ describe('EmailRequestCopyComponent', () => {
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(EmailRequestCopyComponent); fixture = TestBed.createComponent(EmailRequestCopyComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
// Set validAccessPeriods$ before detectChanges calls ngOnInit
component.validAccessPeriods$ = of(['FOREVER']);
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@@ -1,4 +1,7 @@
import 'altcha';
import { import {
AsyncPipe,
Location, Location,
NgClass, NgClass,
} from '@angular/common'; } from '@angular/common';
@@ -6,31 +9,44 @@ import {
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnDestroy,
OnInit,
Output, Output,
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import {
Observable,
Subject,
} from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { hasValue } from '../../shared/empty.util';
import { RequestCopyEmail } from './request-copy-email.model'; import { RequestCopyEmail } from './request-copy-email.model';
@Component({ @Component({
selector: 'ds-base-email-request-copy', selector: 'ds-base-email-request-copy',
styleUrls: ['./email-request-copy.component.scss'], styleUrls: ['./email-request-copy.component.scss'],
templateUrl: './email-request-copy.component.html', templateUrl: './email-request-copy.component.html',
standalone: true, standalone: true,
imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective], imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective, NgbDropdownModule, AsyncPipe],
}) })
/** /**
* A form component for an email to send back to the user requesting an item * A form component for an email to send back to the user requesting an item
*/ */
export class EmailRequestCopyComponent { export class EmailRequestCopyComponent implements OnInit, OnDestroy {
/** /**
* Event emitter for sending the email * Event emitter for sending the email
*/ */
@Output() send: EventEmitter<RequestCopyEmail> = new EventEmitter<RequestCopyEmail>(); @Output() send: EventEmitter<RequestCopyEmail> = new EventEmitter<RequestCopyEmail>();
/**
* Selected access period emmitter, sending the new period up to the parent component
*/
@Output() selectedAccessPeriod: EventEmitter<string> = new EventEmitter();
/** /**
* The subject of the email * The subject of the email
*/ */
@@ -41,9 +57,50 @@ export class EmailRequestCopyComponent {
*/ */
@Input() message: string; @Input() message: string;
/**
* A list of valid access periods to render in a drop-down menu
*/
@Input() validAccessPeriods$: Observable<string[]>;
/**
* 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 = 'FOREVER';
/**
* Destroy subject for unsubscribing from observables
* @private
*/
private destroy$ = new Subject<void>();
protected readonly hasValue = hasValue;
constructor(protected location: Location) { constructor(protected location: Location) {
} }
/**
* Initialise subscription to async valid access periods (from configuration service)
*/
ngOnInit(): void {
this.validAccessPeriods$.pipe(
takeUntil(this.destroy$),
).subscribe((validAccessPeriods) => {
if (hasValue(validAccessPeriods) && validAccessPeriods.length > 0) {
this.selectAccessPeriod(validAccessPeriods[0]);
}
});
}
/**
* Clean up subscriptions and selectors
*/
ngOnDestroy(): void {
this.selectedAccessPeriod.complete();
this.destroy$.next();
this.destroy$.complete();
}
/** /**
* Submit the email * Submit the email
*/ */
@@ -57,4 +114,14 @@ export class EmailRequestCopyComponent {
return() { return() {
this.location.back(); this.location.back();
} }
/**
* Update the access period when a dropdown menu button is clicked for a value
* @param accessPeriod
*/
selectAccessPeriod(accessPeriod: string) {
this.accessPeriod = accessPeriod;
this.selectedAccessPeriod.emit(accessPeriod);
}
} }

View File

@@ -4,6 +4,7 @@ import {
Input, Input,
Output, Output,
} from '@angular/core'; } from '@angular/core';
import { Observable } from 'rxjs';
import { ThemedComponent } from 'src/app/shared/theme-support/themed.component'; import { ThemedComponent } from 'src/app/shared/theme-support/themed.component';
import { EmailRequestCopyComponent } from './email-request-copy.component'; import { EmailRequestCopyComponent } from './email-request-copy.component';
@@ -25,6 +26,11 @@ export class ThemedEmailRequestCopyComponent extends ThemedComponent<EmailReques
*/ */
@Output() send: EventEmitter<RequestCopyEmail> = new EventEmitter<RequestCopyEmail>(); @Output() send: EventEmitter<RequestCopyEmail> = new EventEmitter<RequestCopyEmail>();
/**
* Event emitter for a selected / changed access period
*/
@Output() selectedAccessPeriod: EventEmitter<string> = new EventEmitter();
/** /**
* The subject of the email * The subject of the email
*/ */
@@ -35,7 +41,13 @@ export class ThemedEmailRequestCopyComponent extends ThemedComponent<EmailReques
*/ */
@Input() message: string; @Input() message: string;
protected inAndOutputNames: (keyof EmailRequestCopyComponent & keyof this)[] = ['send', 'subject', 'message']; /**
* A list of valid access periods, if configured
*/
@Input() validAccessPeriods$: Observable<string[]>;
protected inAndOutputNames: (keyof EmailRequestCopyComponent & keyof this)[] = ['send', 'subject', 'message', 'selectedAccessPeriod', 'validAccessPeriods$'];
protected getComponentName(): string { protected getComponentName(): string {
return 'EmailRequestCopyComponent'; return 'EmailRequestCopyComponent';

View File

@@ -1,26 +1,44 @@
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD"> <div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
<h3 class="mb-4">{{ 'grant-deny-request-copy.header' | translate }}</h3> <h3 class="mb-4">{{ 'grant-deny-request-copy.header' | translate }}</h3>
@if (itemRequestRD && itemRequestRD.hasSucceeded) { @if (itemRequestRD && itemRequestRD.hasSucceeded) {
<div> <div>
@if (!itemRequestRD.payload.decisionDate) { <!-- Allow previous decisions *if* they were "accept" and have an access token - this allows us to use the form to revoke access -->
@if (!itemRequestRD.payload.decisionDate || (itemRequestRD.payload.acceptRequest === true && itemRequestRD.payload.accessToken)) {
<div> <div>
<p [innerHTML]="'grant-deny-request-copy.intro1' | translate:{ url: (itemUrl$ | async), name: (itemName$ | async) }"></p> <p
[innerHTML]="'grant-deny-request-copy.intro1' | translate:{ url: (itemUrl$ | async), name: (itemName$ | async) }"></p>
<p>{{ 'grant-deny-request-copy.intro2' | translate }}</p> <p>{{ 'grant-deny-request-copy.intro2' | translate }}</p>
@if (itemRequestRD.payload.decisionDate) {
<p>{{ 'grant-deny-request-copy.previous-decision' | translate }}</p>
}
<div class="btn-group "> <div class="btn-group ">
<!-- Don't show accept button for previous requests, we only want to allow revoking old requests -->
@if (!itemRequestRD.payload.decisionDate) {
<a [routerLink]="grantRoute$ | async" <a [routerLink]="grantRoute$ | async"
class="btn btn-outline-primary" class="btn btn-outline-primary"
title="{{'grant-deny-request-copy.grant' | translate }}"> title="{{'grant-deny-request-copy.grant' | translate }}">
{{ 'grant-deny-request-copy.grant' | translate }} {{ 'grant-deny-request-copy.grant' | translate }}
</a> </a>
<a [routerLink]="denyRoute$ | async" <a [routerLink]="denyRoute$ | async"
class="btn btn-outline-danger" class="btn btn-outline-danger"
title="{{'grant-deny-request-copy.deny' | translate }}"> title="{{'grant-deny-request-copy.deny' | translate }}">
{{ 'grant-deny-request-copy.deny' | translate }} {{ 'grant-deny-request-copy.deny' | translate }}
</a> </a>
}
@if (itemRequestRD.payload.decisionDate && itemRequestRD.payload.acceptRequest === true && itemRequestRD.payload.accessToken) {
<a [routerLink]="denyRoute$ | async"
class="btn btn-outline-danger"
title="{{'grant-deny-request-copy.revoke' | translate }}">
{{ 'grant-deny-request-copy.revoke' | translate }}
</a>
}
</div> </div>
</div> </div>
} }
@if (itemRequestRD.payload.decisionDate) { <!-- Display the "already handled" message if there is a decision date, and either no access token (attachment was sent in email) or the request was denied -->
@if (itemRequestRD.payload.decisionDate && (!itemRequestRD.payload.acceptRequest || !itemRequestRD.payload.accessToken)) {
<div class="processed-message"> <div class="processed-message">
<p>{{ 'grant-deny-request-copy.processed' | translate }}</p> <p>{{ 'grant-deny-request-copy.processed' | translate }}</p>
<p class="text-center"> <p class="text-center">

View File

@@ -1,7 +1,9 @@
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { import {
ComponentFixture, ComponentFixture,
fakeAsync,
TestBed, TestBed,
tick,
waitForAsync, waitForAsync,
} from '@angular/core/testing'; } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
@@ -138,14 +140,16 @@ describe('GrantDenyRequestCopyComponent', () => {
expect(message).toBeNull(); 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, { component.itemRequestRD$ = createSuccessfulRemoteDataObject$(Object.assign(new ItemRequest(), itemRequest, {
decisionDate: 'defined-date', decisionDate: 'defined-date',
})); }));
fixture.detectChanges(); fixture.detectChanges();
tick(); // Simulate passage of time
fixture.detectChanges();
const message = fixture.debugElement.query(By.css('.processed-message')); const message = fixture.debugElement.query(By.css('.processed-message'));
expect(message).not.toBeNull(); expect(message).not.toBeNull();
}); }));
}); });
}); });

View File

@@ -2,13 +2,36 @@
<h3 class="mb-4">{{ 'grant-request-copy.header' | translate }}</h3> <h3 class="mb-4">{{ 'grant-request-copy.header' | translate }}</h3>
@if (itemRequestRD && itemRequestRD.hasSucceeded) { @if (itemRequestRD && itemRequestRD.hasSucceeded) {
<div> <div>
<p>{{'grant-request-copy.intro' | translate}}</p> <!-- Show the appropriate intro text depending on whether the email will have an attachment or a web link -->
<ds-email-request-copy [subject]="subject$ | async" [message]="message$ | async" (send)="grant($event)"> <p>{{ 'grant-request-copy.intro.' + (sendAsAttachment ? 'attachment' : 'link') | translate }}</p>
@if (!sendAsAttachment && hasValue(previewLink)) {
<div>
<p>{{ 'grant-request-copy.intro.link.preview' | translate }}
<a [attr.routerLink]="previewLinkOptions.routerLink" class="dont-break-out d-block" [target]="'_blank'"
[attr.queryParams]="previewLinkOptions.queryParams"
[attr.rel]=""
>
{{ previewLink }}
</a>
</p>
</div>
}
<!-- Only send access periods for display if an access token was present -->
<ds-email-request-copy [subject]="subject$ | async"
[message]="message$ | async"
(send)="grant($event)"
(selectedAccessPeriod)="selectAccessPeriod($event)"
[validAccessPeriods$]="validAccessPeriods$"
>
<p>{{ 'grant-deny-request-copy.email.permissions.info' | translate }}</p> <p>{{ 'grant-deny-request-copy.email.permissions.info' | translate }}</p>
<form class="mb-3"> <form class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="permissions" [(ngModel)]="suggestOpenAccess" name="permissions"> <input class="form-check-input" type="checkbox" value="" id="permissions" [(ngModel)]="suggestOpenAccess"
<label class="form-check-label" for="permissions">{{ 'grant-deny-request-copy.email.permissions.label' | translate }}</label> name="permissions">
<label class="form-check-label"
for="permissions">{{ 'grant-deny-request-copy.email.permissions.label' | translate }}</label>
</div> </div>
</form> </form>
</ds-email-request-copy> </ds-email-request-copy>

View File

@@ -20,6 +20,7 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { ItemDataService } from '../../core/data/item-data.service'; import { ItemDataService } from '../../core/data/item-data.service';
import { ItemRequestDataService } from '../../core/data/item-request-data.service'; import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { HardRedirectService } from '../../core/services/hard-redirect.service';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { ItemRequest } from '../../core/shared/item-request.model'; import { ItemRequest } from '../../core/shared/item-request.model';
import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock';
@@ -46,6 +47,7 @@ describe('GrantRequestCopyComponent', () => {
let itemDataService: ItemDataService; let itemDataService: ItemDataService;
let itemRequestService: ItemRequestDataService; let itemRequestService: ItemRequestDataService;
let notificationsService: NotificationsService; let notificationsService: NotificationsService;
let hardRedirectService: HardRedirectService;
let itemRequest: ItemRequest; let itemRequest: ItemRequest;
let user: EPerson; let user: EPerson;
@@ -90,7 +92,6 @@ describe('GrantRequestCopyComponent', () => {
], ],
}, },
}); });
router = jasmine.createSpyObj('router', { router = jasmine.createSpyObj('router', {
navigateByUrl: jasmine.createSpy('navigateByUrl'), navigateByUrl: jasmine.createSpy('navigateByUrl'),
}); });
@@ -106,11 +107,17 @@ describe('GrantRequestCopyComponent', () => {
itemDataService = jasmine.createSpyObj('itemDataService', { itemDataService = jasmine.createSpyObj('itemDataService', {
findById: createSuccessfulRemoteDataObject$(item), findById: createSuccessfulRemoteDataObject$(item),
}); });
itemRequestService = jasmine.createSpyObj('itemRequestService', { itemRequestService = jasmine.createSpyObj('ItemRequestDataService', {
getSanitizedRequestByAccessToken: observableOf(createSuccessfulRemoteDataObject(itemRequest)),
grant: 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']); notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']);
return TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), GrantRequestCopyComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), GrantRequestCopyComponent, VarDirective],
providers: [ providers: [
@@ -121,6 +128,7 @@ describe('GrantRequestCopyComponent', () => {
{ provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: DSONameService, useValue: new DSONameServiceMock() },
{ provide: ItemRequestDataService, useValue: itemRequestService }, { provide: ItemRequestDataService, useValue: itemRequestService },
{ provide: NotificationsService, useValue: notificationsService }, { provide: NotificationsService, useValue: notificationsService },
{ provide: HardRedirectService, useValue: hardRedirectService },
{ provide: ThemeService, useValue: getMockThemeService() }, { provide: ThemeService, useValue: getMockThemeService() },
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],

View File

@@ -1,4 +1,8 @@
import { AsyncPipe } from '@angular/common'; import {
AsyncPipe,
CommonModule,
NgClass,
} from '@angular/common';
import { import {
Component, Component,
OnInit, OnInit,
@@ -7,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { import {
ActivatedRoute, ActivatedRoute,
Router, Router,
RouterLink,
} from '@angular/router'; } from '@angular/router';
import { import {
TranslateModule, TranslateModule,
@@ -16,17 +21,21 @@ import { Observable } from 'rxjs';
import { import {
map, map,
switchMap, switchMap,
tap,
} from 'rxjs/operators'; } from 'rxjs/operators';
import { getAccessTokenRequestRoute } from '../../app-routing-paths';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { ItemRequestDataService } from '../../core/data/item-request-data.service'; import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { HardRedirectService } from '../../core/services/hard-redirect.service';
import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { redirectOn4xx } from '../../core/shared/authorized.operators';
import { ItemRequest } from '../../core/shared/item-request.model'; import { ItemRequest } from '../../core/shared/item-request.model';
import { import {
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload,
} from '../../core/shared/operators'; } from '../../core/shared/operators';
import { hasValue } from '../../shared/empty.util';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { VarDirective } from '../../shared/utils/var.directive'; 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'], styleUrls: ['./grant-request-copy.component.scss'],
templateUrl: './grant-request-copy.component.html', templateUrl: './grant-request-copy.component.html',
standalone: true, standalone: true,
imports: [VarDirective, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule], imports: [CommonModule, VarDirective, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule, RouterLink, NgClass],
}) })
/** /**
* Component for granting an item request * Component for granting an item request
@@ -59,11 +68,39 @@ export class GrantRequestCopyComponent implements OnInit {
message$: Observable<string>; message$: Observable<string>;
/** /**
* 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 * Defaults to false
*/ */
suggestOpenAccess = false; suggestOpenAccess = false;
/**
* A list of integers determining valid access periods in seconds
*/
validAccessPeriods$: Observable<string[]>;
/**
* The currently selected access period
*/
accessPeriod: string = null;
/**
* 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( constructor(
private router: Router, private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -71,17 +108,36 @@ export class GrantRequestCopyComponent implements OnInit {
private translateService: TranslateService, private translateService: TranslateService,
private itemRequestService: ItemRequestDataService, private itemRequestService: ItemRequestDataService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private hardRedirectService: HardRedirectService,
) { ) {
} }
/**
* Initialize the component - get the item request from route data an duse it to populate the form
*/
ngOnInit(): void { ngOnInit(): void {
// Get item request data via the router (async)
this.itemRequestRD$ = this.route.data.pipe( this.itemRequestRD$ = this.route.data.pipe(
map((data) => data.request as RemoteData<ItemRequest>), map((data) => data.request as RemoteData<ItemRequest>),
getFirstCompletedRemoteData(), 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), 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'); this.subject$ = this.translateService.get('grant-request-copy.email.subject');
} }
@@ -92,7 +148,7 @@ export class GrantRequestCopyComponent implements OnInit {
grant(email: RequestCopyEmail) { grant(email: RequestCopyEmail) {
this.itemRequestRD$.pipe( this.itemRequestRD$.pipe(
getFirstSucceededRemoteDataPayload(), 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(), getFirstCompletedRemoteData(),
).subscribe((rd) => { ).subscribe((rd) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
@@ -104,4 +160,8 @@ export class GrantRequestCopyComponent implements OnInit {
}); });
} }
selectAccessPeriod(accessPeriod: string) {
this.accessPeriod = accessPeriod;
}
} }

View File

@@ -6,9 +6,14 @@
[target]="isBlank ? '_blank': '_self'" [target]="isBlank ? '_blank': '_self'"
[ngClass]="cssClasses" [ngClass]="cssClasses"
[attr.aria-label]="('file-download-link.download' | translate) + dsoNameService.getName(bitstream)"> [attr.aria-label]="('file-download-link.download' | translate) + dsoNameService.getName(bitstream)">
@if ((canDownload$ |async) !== true) { @if ((canDownload$ | async) === false && (canDownloadWithToken$ | async) === false) {
<span role="img" [attr.aria-label]="'file-download-link.restricted' | translate" class="pe-1"><i class="fas fa-lock"></i></span> <!-- If the user cannot download the file by auth or token, show a lock icon -->
<span role="img" [attr.aria-label]="'file-download-link.restricted' | translate" class="pr-1"><i class="fas fa-lock"></i></span>
} @else if ((canDownloadWithToken$ | async) && (canDownload$ | async) === false) {
<!-- If the user can download the file by token, and NOT normally show a lock open icon -->
<span role="img" [attr.aria-label]="'file-download-link.secure-access' | translate" class="pr-1 request-a-copy-access-icon"><i class="fa-solid fa-lock-open" style=""></i></span>
} }
<!-- Otherwise, show no icon (normal download by authorized user), public access etc. -->
<ng-container *ngTemplateOutlet="content"></ng-container> <ng-container *ngTemplateOutlet="content"></ng-container>
</a> </a>

View File

@@ -0,0 +1,3 @@
.request-a-copy-access-icon {
color: var(--bs-success);
}

View File

@@ -22,6 +22,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { Bitstream } from '../../core/shared/bitstream.model'; import { Bitstream } from '../../core/shared/bitstream.model';
import { Item } from '../../core/shared/item.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 { URLCombiner } from '../../core/url-combiner/url-combiner';
import { getItemModuleRoute } from '../../item-page/item-page-routing-paths'; import { getItemModuleRoute } from '../../item-page/item-page-routing-paths';
import { ActivatedRouteStub } from '../testing/active-router.stub'; import { ActivatedRouteStub } from '../testing/active-router.stub';
@@ -39,6 +40,15 @@ describe('FileDownloadLinkComponent', () => {
let item: Item; let item: Item;
let storeMock: any; let storeMock: any;
const itemRequestStub = Object.assign(new ItemRequest(), {
token: 'item-request-token',
requestName: 'requester name',
accessToken: 'abc123',
acceptRequest: true,
accessExpired: false,
allfiles: true,
});
function init() { function init() {
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: cold('-a', { a: true }), isAuthorized: cold('-a', { a: true }),
@@ -62,7 +72,8 @@ describe('FileDownloadLinkComponent', () => {
}); });
} }
function initTestbed() { function initTestbed(itemRequest = null) {
const activatedRoute = new ActivatedRouteStub({}, { itemRequest: itemRequest });
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
TranslateModule.forRoot(), TranslateModule.forRoot(),
@@ -71,7 +82,7 @@ describe('FileDownloadLinkComponent', () => {
providers: [ providers: [
RouterLinkDirectiveStub, RouterLinkDirectiveStub,
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, { provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Store, useValue: storeMock }, { provide: Store, useValue: storeMock },
{ provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: APP_DATA_SERVICES_MAP, useValue: {} },
], ],
@@ -85,6 +96,9 @@ describe('FileDownloadLinkComponent', () => {
describe('init', () => { describe('init', () => {
describe('getBitstreamPath', () => { describe('getBitstreamPath', () => {
describe('when the user has download rights', () => { describe('when the user has download rights', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
scheduler = getTestScheduler(); scheduler = getTestScheduler();
@@ -113,6 +127,7 @@ describe('FileDownloadLinkComponent', () => {
expect(lock).toBeNull(); expect(lock).toBeNull();
}); });
}); });
describe('when the user has no download rights but has the right to request a copy', () => { describe('when the user has no download rights but has the right to request a copy', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
scheduler = getTestScheduler(); scheduler = getTestScheduler();
@@ -146,6 +161,7 @@ describe('FileDownloadLinkComponent', () => {
expect(lock).toBeTruthy(); expect(lock).toBeTruthy();
}); });
}); });
describe('when the user has no download rights and no request a copy rights', () => { describe('when the user has no download rights and no request a copy rights', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
scheduler = getTestScheduler(); scheduler = getTestScheduler();
@@ -165,7 +181,7 @@ describe('FileDownloadLinkComponent', () => {
expect(component.canDownload$).toBeObservable(cold('--a', { a: false })); expect(component.canDownload$).toBeObservable(cold('--a', { a: false }));
}); });
it('should init the component', () => { it('should init the component and show the locked icon', () => {
scheduler.flush(); scheduler.flush();
fixture.detectChanges(); fixture.detectChanges();
const link = fixture.debugElement.query(By.css('a')); const link = fixture.debugElement.query(By.css('a'));
@@ -174,6 +190,35 @@ describe('FileDownloadLinkComponent', () => {
expect(lock).toBeTruthy(); expect(lock).toBeTruthy();
}); });
}); });
describe('when the user has no (normal) download rights and request a copy rights via access token', () => {
beforeEach(waitForAsync(() => {
scheduler = getTestScheduler();
init();
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(cold('-a', { a: false }));
initTestbed(itemRequestStub);
}));
beforeEach(() => {
fixture = TestBed.createComponent(FileDownloadLinkComponent);
component = fixture.componentInstance;
component.bitstream = bitstream;
component.item = item;
fixture.detectChanges();
});
it('should return the bitstreamPath based on the access token and request-a-copy path', () => {
expect(component.bitstreamPath$).toBeObservable(cold('-a', { a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: { accessToken: 'abc123' } } }));
expect(component.canDownload$).toBeObservable(cold('--a', { a: false }));
});
it('should init the component and show an open lock', () => {
scheduler.flush();
fixture.detectChanges();
const link = fixture.debugElement.query(By.css('a'));
expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString());
const lock = fixture.debugElement.query(By.css('.fa-lock-open')).nativeElement;
expect(lock).toBeTruthy();
});
});
}); });
}); });
}); });

View File

@@ -8,7 +8,10 @@ import {
Input, Input,
OnInit, OnInit,
} from '@angular/core'; } from '@angular/core';
import { RouterLink } from '@angular/router'; import {
ActivatedRoute,
RouterLink,
} from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { import {
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
@@ -19,6 +22,7 @@ import { map } from 'rxjs/operators';
import { import {
getBitstreamDownloadRoute, getBitstreamDownloadRoute,
getBitstreamDownloadWithAccessTokenRoute,
getBitstreamRequestACopyRoute, getBitstreamRequestACopyRoute,
} from '../../app-routing-paths'; } from '../../app-routing-paths';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; 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 { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { Bitstream } from '../../core/shared/bitstream.model'; import { Bitstream } from '../../core/shared/bitstream.model';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { ItemRequest } from '../../core/shared/item-request.model';
import { import {
hasValue, hasValue,
isNotEmpty, isNotEmpty,
@@ -70,25 +75,35 @@ export class FileDownloadLinkComponent implements OnInit {
*/ */
@Input() showAccessStatusBadge = true; @Input() showAccessStatusBadge = true;
itemRequest: ItemRequest;
bitstreamPath$: Observable<{ bitstreamPath$: Observable<{
routerLink: string, routerLink: string,
queryParams: any, queryParams: any,
}>; }>;
canDownload$: Observable<boolean>; canDownload$: Observable<boolean>;
canDownloadWithToken$: Observable<boolean>;
canRequestACopy$: Observable<boolean>;
constructor( constructor(
private authorizationService: AuthorizationDataService, private authorizationService: AuthorizationDataService,
public dsoNameService: DSONameService, public dsoNameService: DSONameService,
private route: ActivatedRoute,
) { ) {
} }
ngOnInit() { ngOnInit() {
if (this.enableRequestACopy) { 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); 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.canDownloadWithToken$ = observableOf((this.itemRequest && this.itemRequest.acceptRequest && !this.itemRequest.accessExpired) ? (this.itemRequest.allfiles !== false || this.itemRequest.bitstreamId === this.bitstream.uuid) : false);
this.bitstreamPath$ = observableCombineLatest([this.canDownload$, canRequestACopy$]).pipe( this.canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined);
map(([canDownload, canRequestACopy]) => this.getBitstreamPath(canDownload, canRequestACopy)), // 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 { } else {
this.bitstreamPath$ = observableOf(this.getBitstreamDownloadPath()); 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)) { if (!canDownload && canRequestACopy && hasValue(this.item)) {
return getBitstreamRequestACopyRoute(this.item, this.bitstream); return getBitstreamRequestACopyRoute(this.item, this.bitstream);
} }
// By default, return the plain path
return this.getBitstreamDownloadPath(); 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() { getBitstreamDownloadPath() {
return { return {
routerLink: getBitstreamDownloadRoute(this.bitstream), routerLink: getBitstreamDownloadRoute(this.bitstream),

View File

@@ -62,6 +62,7 @@ export class ActivatedRouteStub {
paramMap: convertToParamMap(this.params), paramMap: convertToParamMap(this.params),
queryParamMap: convertToParamMap(this.testParams), queryParamMap: convertToParamMap(this.testParams),
queryParams: {} as Params, queryParams: {} as Params,
data: this.testData,
}; };
} }
} }

View File

@@ -11,4 +11,5 @@ import {
}) })
export class RouterLinkDirectiveStub { export class RouterLinkDirectiveStub {
@Input() routerLink: any; @Input() routerLink: any;
@Input() queryParams: any;
} }

View File

@@ -998,6 +998,18 @@
"bitstream-request-a-copy.submit.error": "Something went wrong with submitting the item request.", "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",
"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", "browse.back.all-results": "All browse results",
"browse.comcol.by.author": "By Author", "browse.comcol.by.author": "By Author",
@@ -1904,6 +1916,8 @@
"file-download-link.restricted": "Restricted bitstream", "file-download-link.restricted": "Restricted bitstream",
"file-download-link.secure-access": "Restricted bitstream available via secure access token",
"file-section.error.header": "Error obtaining files for this item", "file-section.error.header": "Error obtaining files for this item",
"footer.copyright": "copyright © 2002-{{ year }}", "footer.copyright": "copyright © 2002-{{ year }}",
@@ -2044,7 +2058,9 @@
"form.repeatable.sort.tip": "Drop the item in the new position", "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", "grant-deny-request-copy.email.back": "Back",
@@ -2062,7 +2078,7 @@
"grant-deny-request-copy.email.subject.empty": "Please enter a subject", "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", "grant-deny-request-copy.header": "Document copy request",
@@ -2072,6 +2088,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.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-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", "grant-request-copy.email.subject": "Request copy of document",
@@ -2080,10 +2098,26 @@
"grant-request-copy.header": "Grant document copy request", "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.success": "Successfully granted item request",
"grant-request-copy.access-period.header": "Access period",
"grant-request-copy.access-period.+1DAY": "1 day",
"grant-request-copy.access-period.+7DAYS": "1 week",
"grant-request-copy.access-period.+1MONTH": "1 month",
"grant-request-copy.access-period.+3MONTHS": "3 months",
"grant-request-copy.access-period.FOREVER": "Forever",
"health.breadcrumbs": "Health", "health.breadcrumbs": "Health",
"health-page.heading": "Health", "health-page.heading": "Health",

View File

@@ -6,6 +6,7 @@ import {
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ThemedItemAlertsComponent } from '../../../../../app/item-page/alerts/themed-item-alerts.component'; 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 { 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 { 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'; 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, AsyncPipe,
NotifyRequestsStatusComponent, NotifyRequestsStatusComponent,
QaEventNotificationComponent, QaEventNotificationComponent,
AccessByTokenNotificationComponent,
], ],
}) })
export class ItemPageComponent extends BaseComponent { export class ItemPageComponent extends BaseComponent {

View File

@@ -1,6 +1,10 @@
import { NgClass } from '@angular/common'; import {
AsyncPipe,
NgClass,
} from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { EmailRequestCopyComponent as BaseComponent } from 'src/app/request-copy/email-request-copy/email-request-copy.component'; import { EmailRequestCopyComponent as BaseComponent } from 'src/app/request-copy/email-request-copy/email-request-copy.component';
@@ -13,7 +17,7 @@ import { BtnDisabledDirective } from '../../../../../app/shared/btn-disabled.dir
// templateUrl: './email-request-copy.component.html', // templateUrl: './email-request-copy.component.html',
templateUrl: './../../../../../app/request-copy/email-request-copy/email-request-copy.component.html', templateUrl: './../../../../../app/request-copy/email-request-copy/email-request-copy.component.html',
standalone: true, standalone: true,
imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective], imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective, AsyncPipe, NgbDropdownModule],
}) })
export class EmailRequestCopyComponent export class EmailRequestCopyComponent
extends BaseComponent { extends BaseComponent {