mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
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:
32
package-lock.json
generated
32
package-lock.json
generated
@@ -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",
|
||||||
|
@@ -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",
|
||||||
|
@@ -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';
|
||||||
|
@@ -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']);
|
||||||
|
@@ -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,20 +113,28 @@ 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) {
|
||||||
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
|
// Either we have an access token, or we are logged in, or we are not logged in.
|
||||||
} else if (!isAuthorized && !isLoggedIn) {
|
// For now, the access token does not care if we are logged in or not.
|
||||||
this.auth.setRedirectUrl(this.router.url);
|
if (hasValue(accessToken)) {
|
||||||
this.router.navigateByUrl('login');
|
this.hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken);
|
||||||
|
} else if (isLoggedIn) {
|
||||||
|
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
|
||||||
|
} else if (!isLoggedIn) {
|
||||||
|
this.auth.setRedirectUrl(this.router.url);
|
||||||
|
this.router.navigateByUrl('login');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
62
src/app/core/auth/access-token.resolver.ts
Normal file
62
src/app/core/auth/access-token.resolver.ts
Normal 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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
@@ -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));
|
||||||
|
@@ -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;
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
37
src/app/core/data/proof-of-work-captcha-data.service.ts
Normal file
37
src/app/core/data/proof-of-work-captcha-data.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
*/
|
*/
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
<altcha-widget
|
||||||
|
id="altcha-widget"
|
||||||
|
auto="{{ autoload }}"
|
||||||
|
expire="100000"
|
||||||
|
workers=16
|
||||||
|
challengeurl="{{ challengeUrl }}"></altcha-widget>
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -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">
|
||||||
|
@@ -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();
|
||||||
});
|
});
|
||||||
|
@@ -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) => {
|
||||||
|
@@ -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: {
|
||||||
|
@@ -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';
|
||||||
|
@@ -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) : ''),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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 ||
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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();
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
@@ -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;
|
||||||
|
}
|
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
@@ -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)) : '';
|
||||||
|
}
|
||||||
|
}
|
@@ -4,12 +4,12 @@
|
|||||||
<div class="file-section">
|
<div class="file-section">
|
||||||
@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) }}
|
||||||
</span>
|
</span>
|
||||||
<span> ({{(file?.sizeBytes) | dsFileSize }})</span>
|
<span> ({{(file?.sizeBytes) | dsFileSize }})</span>
|
||||||
@if (!last) {
|
@if (!last) {
|
||||||
<span innerHTML="{{separator}}"></span>
|
<span innerHTML="{{separator}}"></span>
|
||||||
|
@@ -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', () => {
|
||||||
|
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
@@ -4,3 +4,4 @@
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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,18 +13,46 @@
|
|||||||
<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()"
|
||||||
[dsBtnDisabled]="!subject || subject.length === 0"
|
[dsBtnDisabled]="!subject || subject.length === 0"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
title="{{'grant-deny-request-copy.email.send' | translate }}">
|
title="{{'grant-deny-request-copy.email.send' | translate }}">
|
||||||
<i class="fas fa-envelope"></i> {{'grant-deny-request-copy.email.send' | translate }}
|
<i class="fas fa-envelope"></i> {{ 'grant-deny-request-copy.email.send' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button (click)="return()"
|
<button (click)="return()"
|
||||||
class="btn btn-outline-secondary me-1"
|
class="btn btn-outline-secondary me-1"
|
||||||
title="{{'grant-deny-request-copy.email.back' | translate }}">
|
title="{{'grant-deny-request-copy.email.back' | translate }}">
|
||||||
<i class="fas fa-arrow-left"></i> {{'grant-deny-request-copy.email.back' | translate }}
|
<i class="fas fa-arrow-left"></i> {{ 'grant-deny-request-copy.email.back' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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';
|
||||||
|
@@ -1,33 +1,51 @@
|
|||||||
<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
|
||||||
<p>{{'grant-deny-request-copy.intro2' | translate}}</p>
|
[innerHTML]="'grant-deny-request-copy.intro1' | translate:{ url: (itemUrl$ | async), name: (itemName$ | async) }"></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 ">
|
||||||
<a [routerLink]="grantRoute$ | async"
|
<!-- Don't show accept button for previous requests, we only want to allow revoking old requests -->
|
||||||
class="btn btn-outline-primary"
|
@if (!itemRequestRD.payload.decisionDate) {
|
||||||
title="{{'grant-deny-request-copy.grant' | translate }}">
|
<a [routerLink]="grantRoute$ | async"
|
||||||
{{'grant-deny-request-copy.grant' | translate }}
|
class="btn btn-outline-primary"
|
||||||
</a>
|
title="{{'grant-deny-request-copy.grant' | translate }}">
|
||||||
<a [routerLink]="denyRoute$ | async"
|
{{ 'grant-deny-request-copy.grant' | translate }}
|
||||||
class="btn btn-outline-danger"
|
</a>
|
||||||
title="{{'grant-deny-request-copy.deny' | translate }}">
|
|
||||||
{{'grant-deny-request-copy.deny' | translate }}
|
<a [routerLink]="denyRoute$ | async"
|
||||||
</a>
|
class="btn btn-outline-danger"
|
||||||
|
title="{{'grant-deny-request-copy.deny' | translate }}">
|
||||||
|
{{ 'grant-deny-request-copy.deny' | translate }}
|
||||||
|
</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 -->
|
||||||
<div class="processed-message">
|
@if (itemRequestRD.payload.decisionDate && (!itemRequestRD.payload.acceptRequest || !itemRequestRD.payload.accessToken)) {
|
||||||
<p>{{'grant-deny-request-copy.processed' | translate}}</p>
|
<div class="processed-message">
|
||||||
<p class="text-center">
|
<p>{{ 'grant-deny-request-copy.processed' | translate }}</p>
|
||||||
<a routerLink="/home" class="btn btn-primary">{{'grant-deny-request-copy.home-page' | translate}}</a>
|
<p class="text-center">
|
||||||
</p>
|
<a routerLink="/home" class="btn btn-primary">{{ 'grant-deny-request-copy.home-page' | translate }}</a>
|
||||||
</div>
|
</p>
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!itemRequestRD || itemRequestRD?.isLoading) {
|
@if (!itemRequestRD || itemRequestRD?.isLoading) {
|
||||||
|
@@ -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();
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,14 +1,37 @@
|
|||||||
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
|
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
|
||||||
<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>
|
||||||
|
@@ -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],
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
|
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
.request-a-copy-access-icon {
|
||||||
|
color: var(--bs-success);
|
||||||
|
}
|
||||||
|
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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),
|
||||||
|
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -11,4 +11,5 @@ import {
|
|||||||
})
|
})
|
||||||
export class RouterLinkDirectiveStub {
|
export class RouterLinkDirectiveStub {
|
||||||
@Input() routerLink: any;
|
@Input() routerLink: any;
|
||||||
|
@Input() queryParams: any;
|
||||||
}
|
}
|
||||||
|
@@ -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",
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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 {
|
||||||
|
Reference in New Issue
Block a user