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",
|
||||
"@ngx-translate/core": "^16.0.3",
|
||||
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
|
||||
"altcha": "^0.9.0",
|
||||
"angulartics2": "^12.2.0",
|
||||
"axios": "^1.7.9",
|
||||
"bootstrap": "^5.3",
|
||||
@@ -164,6 +165,12 @@
|
||||
"version": "0.0.0",
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
@@ -8895,6 +8902,31 @@
|
||||
"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": {
|
||||
"version": "12.2.1",
|
||||
"resolved": "https://registry.npmjs.org/angulartics2/-/angulartics2-12.2.1.tgz",
|
||||
|
@@ -114,6 +114,7 @@
|
||||
"@ngrx/store": "^18.1.1",
|
||||
"@ngx-translate/core": "^16.0.3",
|
||||
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
|
||||
"altcha": "^0.9.0",
|
||||
"angulartics2": "^12.2.0",
|
||||
"axios": "^1.7.9",
|
||||
"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 function getHomePageRoute() {
|
||||
@@ -128,6 +163,11 @@ export function getRequestCopyModulePath() {
|
||||
return `/${REQUEST_COPY_MODULE_PATH}`;
|
||||
}
|
||||
|
||||
export const ACCESS_BY_TOKEN_MODULE_PATH = 'access-by-token';
|
||||
export function getAccessByTokenModulePath() {
|
||||
return `/${ACCESS_BY_TOKEN_MODULE_PATH}`;
|
||||
}
|
||||
|
||||
export const HEALTH_PAGE_PATH = 'health';
|
||||
|
||||
export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions';
|
||||
|
@@ -73,16 +73,16 @@ describe('BitstreamDownloadPageComponent', () => {
|
||||
self: { href: 'bitstream-self-link' },
|
||||
},
|
||||
});
|
||||
|
||||
activatedRoute = {
|
||||
data: observableOf({
|
||||
bitstream: createSuccessfulRemoteDataObject(
|
||||
bitstream,
|
||||
),
|
||||
bitstream: createSuccessfulRemoteDataObject(bitstream),
|
||||
}),
|
||||
params: observableOf({
|
||||
id: 'testid',
|
||||
}),
|
||||
queryParams: observableOf({
|
||||
accessToken: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
router = jasmine.createSpyObj('router', ['navigateByUrl']);
|
||||
|
@@ -11,6 +11,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
Params,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -83,6 +84,10 @@ export class BitstreamDownloadPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const accessToken$: Observable<string> = this.route.queryParams.pipe(
|
||||
map((queryParams: Params) => queryParams?.accessToken || null),
|
||||
take(1),
|
||||
);
|
||||
|
||||
this.bitstreamRD$ = this.route.data.pipe(
|
||||
map((data) => data.bitstream));
|
||||
@@ -96,11 +101,11 @@ export class BitstreamDownloadPageComponent implements OnInit {
|
||||
switchMap((bitstream: Bitstream) => {
|
||||
const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined);
|
||||
const isLoggedIn$ = this.auth.isAuthenticated();
|
||||
return observableCombineLatest([isAuthorized$, isLoggedIn$, observableOf(bitstream)]);
|
||||
return observableCombineLatest([isAuthorized$, isLoggedIn$, accessToken$, observableOf(bitstream)]);
|
||||
}),
|
||||
filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)),
|
||||
filter(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => (hasValue(isAuthorized) && hasValue(isLoggedIn)) || hasValue(accessToken)),
|
||||
take(1),
|
||||
switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => {
|
||||
switchMap(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => {
|
||||
if (isAuthorized && isLoggedIn) {
|
||||
return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
|
||||
filter((fileLink) => hasValue(fileLink)),
|
||||
@@ -108,21 +113,29 @@ export class BitstreamDownloadPageComponent implements OnInit {
|
||||
map((fileLink) => {
|
||||
return [isAuthorized, isLoggedIn, bitstream, fileLink];
|
||||
}));
|
||||
} else if (hasValue(accessToken)) {
|
||||
return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]];
|
||||
} else {
|
||||
return [[isAuthorized, isLoggedIn, bitstream, '']];
|
||||
}
|
||||
}),
|
||||
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => {
|
||||
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]: [boolean, boolean, Bitstream, string, string]) => {
|
||||
if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
|
||||
this.hardRedirectService.redirect(fileLink);
|
||||
} else if (isAuthorized && !isLoggedIn) {
|
||||
} else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) {
|
||||
this.hardRedirectService.redirect(bitstream._links.content.href);
|
||||
} else if (!isAuthorized && isLoggedIn) {
|
||||
} else if (!isAuthorized) {
|
||||
// Either we have an access token, or we are logged in, or we are not logged in.
|
||||
// For now, the access token does not care if we are logged in or not.
|
||||
if (hasValue(accessToken)) {
|
||||
this.hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken);
|
||||
} else if (isLoggedIn) {
|
||||
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
|
||||
} else if (!isAuthorized && !isLoggedIn) {
|
||||
} 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');
|
||||
let headers = new HttpHeaders();
|
||||
const options: HttpOptions = Object.create({});
|
||||
headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken');
|
||||
headers = headers.append('x-captcha-payload', 'afreshcaptchatoken');
|
||||
options.headers = headers;
|
||||
|
||||
expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options));
|
||||
|
@@ -69,7 +69,7 @@ export class EpersonRegistrationService {
|
||||
/**
|
||||
* Register a new email address
|
||||
* @param email
|
||||
* @param captchaToken the value of x-recaptcha-token header
|
||||
* @param captchaToken the value of x-captcha-payload header
|
||||
*/
|
||||
registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
|
||||
const registration = new Registration();
|
||||
@@ -82,7 +82,7 @@ export class EpersonRegistrationService {
|
||||
const options: HttpOptions = Object.create({});
|
||||
let headers = new HttpHeaders();
|
||||
if (captchaToken) {
|
||||
headers = headers.append('x-recaptcha-token', captchaToken);
|
||||
headers = headers.append('x-captcha-payload', captchaToken);
|
||||
}
|
||||
options.headers = headers;
|
||||
|
||||
|
@@ -1,10 +1,17 @@
|
||||
import { HttpHeaders } from '@angular/common/http';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
|
||||
import { MockBitstream1 } from '../../shared/mocks/item.mock';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { ConfigurationProperty } from '../shared/configuration-property.model';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { ItemRequest } from '../shared/item-request.model';
|
||||
import { ConfigurationDataService } from './configuration-data.service';
|
||||
import { AuthorizationDataService } from './feature-authorization/authorization-data.service';
|
||||
import { FeatureID } from './feature-authorization/feature-id';
|
||||
import { FindListOptions } from './find-list-options.model';
|
||||
import { ItemRequestDataService } from './item-request-data.service';
|
||||
import { PostRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
@@ -16,12 +23,36 @@ describe('ItemRequestDataService', () => {
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let halService: HALEndpointService;
|
||||
let configService: ConfigurationDataService;
|
||||
let authorizationDataService: AuthorizationDataService;
|
||||
|
||||
const restApiEndpoint = 'rest/api/endpoint/';
|
||||
const requestId = 'request-id';
|
||||
let itemRequest: ItemRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']);
|
||||
(configService.findByPropertyName as jasmine.Spy).and.callFake((propertyName: string) => {
|
||||
switch (propertyName) {
|
||||
case 'request.item.create.captcha':
|
||||
return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
|
||||
name: 'request.item.create.captcha',
|
||||
values: ['true'],
|
||||
}));
|
||||
case 'request.item.grant.link.period':
|
||||
return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
|
||||
name: 'request.item.grant.link.period',
|
||||
values: ['FOREVER', '+1DAY', '+1MONTH'],
|
||||
}));
|
||||
default:
|
||||
return createSuccessfulRemoteDataObject$(new ConfigurationProperty());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
authorizationDataService = jasmine.createSpyObj('authorizationService', {
|
||||
isAuthorized: observableOf(false),
|
||||
});
|
||||
itemRequest = Object.assign(new ItemRequest(), {
|
||||
token: 'item-request-token',
|
||||
});
|
||||
@@ -36,13 +67,42 @@ describe('ItemRequestDataService', () => {
|
||||
getEndpoint: observableOf(restApiEndpoint),
|
||||
});
|
||||
|
||||
service = new ItemRequestDataService(requestService, rdbService, null, halService);
|
||||
service = new ItemRequestDataService(requestService, rdbService, null, halService, configService, authorizationDataService);
|
||||
});
|
||||
|
||||
describe('searchBy', () => {
|
||||
it('should use searchData to perform search operations', () => {
|
||||
const searchMethod = 'testMethod';
|
||||
const options = new FindListOptions();
|
||||
|
||||
const searchDataSpy = spyOn((service as any).searchData, 'searchBy').and.returnValue(observableOf(null));
|
||||
|
||||
service.searchBy(searchMethod, options);
|
||||
|
||||
expect(searchDataSpy).toHaveBeenCalledWith(
|
||||
searchMethod,
|
||||
options,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestACopy', () => {
|
||||
it('should send a POST request containing the provided item request', (done) => {
|
||||
service.requestACopy(itemRequest).subscribe(() => {
|
||||
expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest));
|
||||
const captchaPayload = 'payload';
|
||||
service.requestACopy(itemRequest, captchaPayload).subscribe(() => {
|
||||
expect(requestService.send).toHaveBeenCalledWith(
|
||||
new PostRequest(
|
||||
requestId,
|
||||
restApiEndpoint,
|
||||
itemRequest,
|
||||
{
|
||||
headers: new HttpHeaders().set('x-captcha-payload', captchaPayload),
|
||||
},
|
||||
),
|
||||
false,
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -56,14 +116,19 @@ describe('ItemRequestDataService', () => {
|
||||
});
|
||||
|
||||
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({
|
||||
method: RestRequestMethod.PUT,
|
||||
href: `${restApiEndpoint}/${itemRequest.token}`,
|
||||
body: JSON.stringify({
|
||||
acceptRequest: true,
|
||||
responseMessage: email.message,
|
||||
subject: email.subject,
|
||||
suggestOpenAccess: true,
|
||||
accessPeriod: '+1DAY',
|
||||
}),
|
||||
options: jasmine.objectContaining({
|
||||
headers: jasmine.any(HttpHeaders),
|
||||
}),
|
||||
}));
|
||||
done();
|
||||
@@ -82,15 +147,70 @@ describe('ItemRequestDataService', () => {
|
||||
service.deny(itemRequest.token, email).subscribe(() => {
|
||||
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
method: RestRequestMethod.PUT,
|
||||
href: `${restApiEndpoint}/${itemRequest.token}`,
|
||||
body: JSON.stringify({
|
||||
acceptRequest: false,
|
||||
responseMessage: email.message,
|
||||
subject: email.subject,
|
||||
suggestOpenAccess: false,
|
||||
accessPeriod: null,
|
||||
}),
|
||||
options: jasmine.objectContaining({
|
||||
headers: jasmine.any(HttpHeaders),
|
||||
}),
|
||||
}));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestACopy', () => {
|
||||
it('should send a POST request containing the provided item request', (done) => {
|
||||
const captchaPayload = 'payload';
|
||||
service.requestACopy(itemRequest, captchaPayload).subscribe(() => {
|
||||
expect(requestService.send).toHaveBeenCalledWith(
|
||||
new PostRequest(
|
||||
requestId,
|
||||
restApiEndpoint,
|
||||
itemRequest,
|
||||
{
|
||||
headers: new HttpHeaders().set('x-captcha-payload', captchaPayload),
|
||||
},
|
||||
),
|
||||
false,
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfiguredAccessPeriods', () => {
|
||||
it('should return parsed integer values from config', () => {
|
||||
service.getConfiguredAccessPeriods().subscribe(periods => {
|
||||
expect(periods).toEqual(['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,
|
||||
isNotEmpty,
|
||||
} from '../../shared/empty.util';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { RequestParam } from '../cache/models/request-param.model';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
import { Bitstream } from '../shared/bitstream.model';
|
||||
import { ConfigurationProperty } from '../shared/configuration-property.model';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { ItemRequest } from '../shared/item-request.model';
|
||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||
import { sendRequest } from '../shared/request.operators';
|
||||
import { IdentifiableDataService } from './base/identifiable-data.service';
|
||||
import {
|
||||
SearchData,
|
||||
SearchDataImpl,
|
||||
} from './base/search-data';
|
||||
import { ConfigurationDataService } from './configuration-data.service';
|
||||
import { AuthorizationDataService } from './feature-authorization/authorization-data.service';
|
||||
import { FeatureID } from './feature-authorization/feature-id';
|
||||
import { FindListOptions } from './find-list-options.model';
|
||||
import { PaginatedList } from './paginated-list.model';
|
||||
import { RemoteData } from './remote-data';
|
||||
import {
|
||||
PostRequest,
|
||||
@@ -34,14 +47,20 @@ import { RequestService } from './request.service';
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> {
|
||||
export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> implements SearchData<ItemRequest> {
|
||||
|
||||
private searchData: SearchDataImpl<ItemRequest>;
|
||||
|
||||
constructor(
|
||||
protected requestService: RequestService,
|
||||
protected rdbService: RemoteDataBuildService,
|
||||
protected objectCache: ObjectCacheService,
|
||||
protected halService: HALEndpointService,
|
||||
protected configService: ConfigurationDataService,
|
||||
protected authorizationService: AuthorizationDataService,
|
||||
) {
|
||||
super('itemrequests', requestService, rdbService, objectCache, halService);
|
||||
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
|
||||
}
|
||||
|
||||
getItemRequestEndpoint(): Observable<string> {
|
||||
@@ -61,17 +80,26 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
||||
/**
|
||||
* Request a copy of an item
|
||||
* @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 href$ = this.getItemRequestEndpoint();
|
||||
|
||||
// Inject captcha payload into headers
|
||||
const options: HttpOptions = Object.create({});
|
||||
if (captchaPayload) {
|
||||
let headers = new HttpHeaders();
|
||||
headers = headers.set('x-captcha-payload', captchaPayload);
|
||||
options.headers = headers;
|
||||
}
|
||||
|
||||
href$.pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
map((href: string) => {
|
||||
const request = new PostRequest(requestId, href, itemRequest);
|
||||
this.requestService.send(request);
|
||||
const request = new PostRequest(requestId, href, itemRequest, options);
|
||||
this.requestService.send(request, false);
|
||||
}),
|
||||
).subscribe();
|
||||
|
||||
@@ -94,9 +122,10 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
||||
* @param token Token of the {@link ItemRequest}
|
||||
* @param email Email to send back to the user requesting the item
|
||||
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
||||
* @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments)
|
||||
*/
|
||||
grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
|
||||
return this.process(token, email, true, suggestOpenAccess);
|
||||
grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod: string = null): Observable<RemoteData<ItemRequest>> {
|
||||
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 grant Grant or deny the request (true = grant, false = deny)
|
||||
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
||||
* @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments)
|
||||
*/
|
||||
process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
|
||||
process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod: string = null): Observable<RemoteData<ItemRequest>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
this.getItemRequestEndpointByToken(token).pipe(
|
||||
@@ -121,6 +151,7 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
||||
responseMessage: email.message,
|
||||
subject: email.subject,
|
||||
suggestOpenAccess,
|
||||
accessPeriod: accessPeriod,
|
||||
}), options);
|
||||
}),
|
||||
sendRequest(this.requestService),
|
||||
@@ -128,4 +159,81 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
||||
|
||||
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
|
||||
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
|
||||
*/
|
||||
|
@@ -23,4 +23,9 @@ export class MediaViewerItem {
|
||||
* Incoming Bitsream thumbnail
|
||||
*/
|
||||
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>
|
||||
</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>
|
||||
<div class="row">
|
||||
<div class="col-12 text-end">
|
||||
|
@@ -16,19 +16,27 @@ import {
|
||||
ActivatedRoute,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||
import { RestResponse } from '../../../core/cache/response.models';
|
||||
import { BitstreamDataService } from '../../../core/data/bitstream-data.service';
|
||||
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { ItemRequestDataService } from '../../../core/data/item-request-data.service';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { RequestEntry } from '../../../core/data/request-entry.model';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { ITEM } from '../../../core/shared/item.resource-type';
|
||||
import { ItemRequest } from '../../../core/shared/item-request.model';
|
||||
import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock';
|
||||
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import {
|
||||
createFailedRemoteDataObject$,
|
||||
@@ -39,6 +47,9 @@ import { NotificationsServiceStub } from '../../../shared/testing/notifications-
|
||||
import { RouterStub } from '../../../shared/testing/router.stub';
|
||||
import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page.component';
|
||||
|
||||
const mockDataServiceMap: any = new Map([
|
||||
[ITEM.value, () => import('../../../shared/testing/test-data-service.mock').then(m => m.TestDataService)],
|
||||
]);
|
||||
|
||||
describe('BitstreamRequestACopyPageComponent', () => {
|
||||
let component: BitstreamRequestACopyPageComponent;
|
||||
@@ -48,10 +59,11 @@ describe('BitstreamRequestACopyPageComponent', () => {
|
||||
let authorizationService: AuthorizationDataService;
|
||||
let activatedRoute;
|
||||
let router;
|
||||
let itemRequestDataService;
|
||||
let itemRequestDataService: ItemRequestDataService;
|
||||
let notificationsService;
|
||||
let location;
|
||||
let bitstreamDataService;
|
||||
let requestService;
|
||||
|
||||
let item: Item;
|
||||
let bitstream: Bitstream;
|
||||
@@ -75,8 +87,20 @@ describe('BitstreamRequestACopyPageComponent', () => {
|
||||
|
||||
itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', {
|
||||
requestACopy: createSuccessfulRemoteDataObject$({}),
|
||||
isProtectedByCaptcha: observableOf(true),
|
||||
});
|
||||
|
||||
requestService = Object.assign(getMockRequestService(), {
|
||||
getByHref(requestHref: string) {
|
||||
const responseCacheEntry = new RequestEntry();
|
||||
responseCacheEntry.response = new RestResponse(true, 200, 'OK');
|
||||
return observableOf(responseCacheEntry);
|
||||
},
|
||||
removeByHrefSubstring(href: string) {
|
||||
// Do nothing
|
||||
},
|
||||
}) as RequestService;
|
||||
|
||||
location = jasmine.createSpyObj('location', {
|
||||
back: {},
|
||||
});
|
||||
@@ -124,6 +148,9 @@ describe('BitstreamRequestACopyPageComponent', () => {
|
||||
{ provide: NotificationsService, useValue: notificationsService },
|
||||
{ provide: DSONameService, useValue: new DSONameServiceMock() },
|
||||
{ provide: BitstreamDataService, useValue: bitstreamDataService },
|
||||
{ provide: Store, useValue: provideMockStore() },
|
||||
{ provide: RequestService, useValue: requestService },
|
||||
{ provide: APP_DATA_SERVICES_MAP, useValue: mockDataServiceMap },
|
||||
],
|
||||
})
|
||||
.compileComponents();
|
||||
@@ -246,6 +273,7 @@ describe('BitstreamRequestACopyPageComponent', () => {
|
||||
component.email.patchValue('user@name.org');
|
||||
component.allfiles.patchValue('false');
|
||||
component.message.patchValue('I would like to request a copy');
|
||||
component.captchaPayload.patchValue('payload');
|
||||
|
||||
component.onSubmit();
|
||||
const itemRequest = Object.assign(new ItemRequest(),
|
||||
@@ -258,7 +286,7 @@ describe('BitstreamRequestACopyPageComponent', () => {
|
||||
requestMessage: 'I would like to request a copy',
|
||||
});
|
||||
|
||||
expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest);
|
||||
expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest, 'payload');
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
expect(location.back).toHaveBeenCalled();
|
||||
});
|
||||
@@ -280,6 +308,7 @@ describe('BitstreamRequestACopyPageComponent', () => {
|
||||
component.email.patchValue('user@name.org');
|
||||
component.allfiles.patchValue('false');
|
||||
component.message.patchValue('I would like to request a copy');
|
||||
component.captchaPayload.patchValue('payload');
|
||||
|
||||
component.onSubmit();
|
||||
const itemRequest = Object.assign(new ItemRequest(),
|
||||
@@ -292,7 +321,7 @@ describe('BitstreamRequestACopyPageComponent', () => {
|
||||
requestMessage: 'I would like to request a copy',
|
||||
});
|
||||
|
||||
expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest);
|
||||
expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest, 'payload');
|
||||
expect(notificationsService.error).toHaveBeenCalled();
|
||||
expect(location.back).not.toHaveBeenCalled();
|
||||
});
|
||||
|
@@ -1,9 +1,13 @@
|
||||
import 'altcha';
|
||||
|
||||
import {
|
||||
AsyncPipe,
|
||||
Location,
|
||||
} from '@angular/common';
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
@@ -46,6 +50,7 @@ import { BitstreamDataService } from '../../../core/data/bitstream-data.service'
|
||||
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
|
||||
import { ItemRequestDataService } from '../../../core/data/item-request-data.service';
|
||||
import { ProofOfWorkCaptchaDataService } from '../../../core/data/proof-of-work-captcha-data.service';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
@@ -60,7 +65,9 @@ import {
|
||||
isNotEmpty,
|
||||
} from '../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||
import { getItemPageRoute } from '../../item-page-routing-paths';
|
||||
import { AltchaCaptchaComponent } from './altcha-captcha.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-bitstream-request-a-copy-page',
|
||||
@@ -71,7 +78,10 @@ import { getItemPageRoute } from '../../item-page-routing-paths';
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
BtnDisabledDirective,
|
||||
VarDirective,
|
||||
AltchaCaptchaComponent,
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
standalone: true,
|
||||
})
|
||||
/**
|
||||
@@ -92,6 +102,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
|
||||
bitstream: Bitstream;
|
||||
bitstreamName: string;
|
||||
|
||||
// Captcha settings
|
||||
captchaEnabled$: Observable<boolean>;
|
||||
challengeHref$: Observable<string>;
|
||||
|
||||
constructor(private location: Location,
|
||||
private translateService: TranslateService,
|
||||
private route: ActivatedRoute,
|
||||
@@ -103,6 +117,8 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
|
||||
private notificationsService: NotificationsService,
|
||||
private dsoNameService: DSONameService,
|
||||
private bitstreamService: BitstreamDataService,
|
||||
private captchaService: ProofOfWorkCaptchaDataService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -117,8 +133,15 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
|
||||
}),
|
||||
allfiles: new UntypedFormControl(''),
|
||||
message: new UntypedFormControl(''),
|
||||
// Payload here is initialised as "required", but this validator will be cleared
|
||||
// if the config property comes back as 'captcha not enabled'
|
||||
captchaPayload: new UntypedFormControl('', {
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
});
|
||||
|
||||
this.captchaEnabled$ = this.itemRequestDataService.isProtectedByCaptcha();
|
||||
this.challengeHref$ = this.captchaService.getChallengeHref();
|
||||
|
||||
this.item$ = this.route.data.pipe(
|
||||
map((data) => data.dso),
|
||||
@@ -172,6 +195,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
|
||||
return this.requestCopyForm.get('allfiles');
|
||||
}
|
||||
|
||||
get captchaPayload() {
|
||||
return this.requestCopyForm.get('captchaPayload');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the form values based on the current user.
|
||||
*/
|
||||
@@ -185,6 +212,17 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
|
||||
this.bitstream$.pipe(take(1)).subscribe((bitstream) => {
|
||||
this.requestCopyForm.patchValue({ allfiles: 'false' });
|
||||
});
|
||||
this.subs.push(this.captchaEnabled$.pipe(
|
||||
take(1),
|
||||
).subscribe((enabled) => {
|
||||
if (!enabled) {
|
||||
// Captcha not required? Clear validators to allow the form to be submitted normally
|
||||
this.requestCopyForm.get('captchaPayload').clearValidators();
|
||||
this.requestCopyForm.get('captchaPayload').reset();
|
||||
this.requestCopyForm.updateValueAndValidity();
|
||||
}
|
||||
this.changeDetectorRef.detectChanges();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,8 +256,9 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
|
||||
itemRequest.requestEmail = this.email.value;
|
||||
itemRequest.requestName = this.name.value;
|
||||
itemRequest.requestMessage = this.message.value;
|
||||
const captchaPayloadString: string = this.captchaPayload.value;
|
||||
|
||||
this.itemRequestDataService.requestACopy(itemRequest).pipe(
|
||||
this.itemRequestDataService.requestACopy(itemRequest, captchaPayloadString).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
).subscribe((rd) => {
|
||||
if (rd.hasSucceeded) {
|
||||
@@ -231,6 +270,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
handlePayload(event): void {
|
||||
this.requestCopyForm.patchValue({ captchaPayload: event });
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (hasValue(this.subs)) {
|
||||
this.subs.forEach((sub) => {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { Route } from '@angular/router';
|
||||
|
||||
import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths';
|
||||
import { accessTokenResolver } from '../core/auth/access-token.resolver';
|
||||
import { authenticatedGuard } from '../core/auth/authenticated.guard';
|
||||
import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
|
||||
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
|
||||
@@ -11,6 +12,7 @@ import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.c
|
||||
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
|
||||
import { itemPageResolver } from './item-page.resolver';
|
||||
import {
|
||||
ITEM_ACCESS_BY_TOKEN_PATH,
|
||||
ITEM_EDIT_PATH,
|
||||
ORCID_PATH,
|
||||
UPLOAD_BITSTREAM_PATH,
|
||||
@@ -26,6 +28,7 @@ export const ROUTES: Route[] = [
|
||||
path: ':id',
|
||||
resolve: {
|
||||
dso: itemPageResolver,
|
||||
itemRequest: accessTokenResolver,
|
||||
breadcrumb: itemBreadcrumbResolver,
|
||||
},
|
||||
runGuardsAndResolvers: 'always',
|
||||
@@ -64,6 +67,13 @@ export const ROUTES: Route[] = [
|
||||
component: OrcidPageComponent,
|
||||
canActivate: [authenticatedGuard, orcidPageGuard],
|
||||
},
|
||||
{
|
||||
path: ITEM_ACCESS_BY_TOKEN_PATH,
|
||||
component: ThemedFullItemPageComponent,
|
||||
resolve: {
|
||||
menu: accessTokenResolver,
|
||||
},
|
||||
},
|
||||
],
|
||||
data: {
|
||||
menu: {
|
||||
|
@@ -51,3 +51,5 @@ export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory';
|
||||
export const ITEM_VERSION_PATH = 'version';
|
||||
export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new';
|
||||
export const ORCID_PATH = 'orcid';
|
||||
|
||||
export const ITEM_ACCESS_BY_TOKEN_PATH = 'access-by-token';
|
||||
|
@@ -15,6 +15,7 @@ import { Observable } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* This component render an image gallery for the image viewer
|
||||
@@ -99,7 +100,7 @@ export class MediaViewerImageComponent implements OnChanges, OnInit {
|
||||
medium: image.thumbnail
|
||||
? image.thumbnail
|
||||
: this.thumbnailPlaceholder,
|
||||
big: image.bitstream._links.content.href,
|
||||
big: image.bitstream._links.content.href + (hasValue(image.accessToken) ? ('?accessToken=' + image.accessToken) : ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<video
|
||||
crossorigin="anonymous"
|
||||
[src]="medias[currentIndex].bitstream._links.content.href"
|
||||
[src]="constructHref(medias[currentIndex].bitstream._links.content.href)"
|
||||
id="singleVideo"
|
||||
[poster]="
|
||||
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 { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
|
||||
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { CaptionInfo } from './caption-info';
|
||||
import { languageHelper } from './language-helper';
|
||||
|
||||
@@ -64,7 +65,7 @@ export class MediaViewerVideoComponent {
|
||||
for (const media of filteredCapMedias) {
|
||||
const srclang: string = media.name.slice(-6, -4).toLowerCase();
|
||||
capInfos.push(new CaptionInfo(
|
||||
media._links.content.href,
|
||||
this.constructHref(media._links.content.href),
|
||||
srclang,
|
||||
languageHelper[srclang],
|
||||
));
|
||||
@@ -93,4 +94,15 @@ export class MediaViewerVideoComponent {
|
||||
prevMedia() {
|
||||
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';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
@@ -22,6 +23,7 @@ import { MockBitstreamFormat1 } from '../../shared/mocks/item.mock';
|
||||
import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
|
||||
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { ThemeService } from '../../shared/theme-support/theme.service';
|
||||
import { FileSizePipe } from '../../shared/utils/file-size-pipe';
|
||||
@@ -91,6 +93,7 @@ describe('MediaViewerComponent', () => {
|
||||
{ provide: BitstreamDataService, useValue: bitstreamDataService },
|
||||
{ provide: ThemeService, useValue: getMockThemeService() },
|
||||
{ provide: AuthService, useValue: new AuthServiceMock() },
|
||||
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
@@ -6,6 +6,7 @@ import {
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
@@ -25,6 +26,7 @@ import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { ItemRequest } from '../../core/shared/item-request.model';
|
||||
import { MediaViewerItem } from '../../core/shared/media-viewer-item.model';
|
||||
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
@@ -70,9 +72,12 @@ export class MediaViewerComponent implements OnDestroy, OnInit {
|
||||
|
||||
subs: Subscription[] = [];
|
||||
|
||||
itemRequest: ItemRequest;
|
||||
|
||||
constructor(
|
||||
protected bitstreamDataService: BitstreamDataService,
|
||||
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
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.itemRequest = this.route.snapshot.data.itemRequest;
|
||||
const types: string[] = [
|
||||
...(this.mediaOptions.image ? ['image'] : []),
|
||||
...(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.mimetype = format.mimetype;
|
||||
mediaItem.thumbnail = thumbnail ? thumbnail._links.content.href : null;
|
||||
mediaItem.accessToken = this.accessToken;
|
||||
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)) : '';
|
||||
}
|
||||
}
|
@@ -5,7 +5,7 @@
|
||||
@for (file of bitstreams; track file; let last = $last) {
|
||||
<ds-file-download-link [bitstream]="file" [item]="item">
|
||||
<span>
|
||||
@if (primaryBitsreamId === file.id) {
|
||||
@if (primaryBitstreamId === file.id) {
|
||||
<span class="badge bg-primary">{{ 'item.page.bitstreams.primary' | translate }}</span>
|
||||
}
|
||||
{{ dsoNameService.getName(file) }}
|
||||
|
@@ -111,17 +111,17 @@ describe('FileSectionComponent', () => {
|
||||
}));
|
||||
|
||||
it('should set the id of primary bitstream', () => {
|
||||
comp.primaryBitsreamId = undefined;
|
||||
comp.primaryBitstreamId = undefined;
|
||||
bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(mockBitstream));
|
||||
comp.ngOnInit();
|
||||
expect(comp.primaryBitsreamId).toBe(mockBitstream.id);
|
||||
expect(comp.primaryBitstreamId).toBe(mockBitstream.id);
|
||||
});
|
||||
|
||||
it('should not set the id of primary bitstream', () => {
|
||||
comp.primaryBitsreamId = undefined;
|
||||
comp.primaryBitstreamId = undefined;
|
||||
bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(null));
|
||||
comp.ngOnInit();
|
||||
expect(comp.primaryBitsreamId).toBeUndefined();
|
||||
expect(comp.primaryBitstreamId).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('when the bitstreams are loading', () => {
|
||||
|
@@ -67,7 +67,7 @@ export class FileSectionComponent implements OnInit {
|
||||
|
||||
pageSize: number;
|
||||
|
||||
primaryBitsreamId: string;
|
||||
primaryBitstreamId: string;
|
||||
|
||||
constructor(
|
||||
protected bitstreamDataService: BitstreamDataService,
|
||||
@@ -89,7 +89,7 @@ export class FileSectionComponent implements OnInit {
|
||||
if (!primaryBitstream) {
|
||||
return;
|
||||
}
|
||||
this.primaryBitsreamId = primaryBitstream?.id;
|
||||
this.primaryBitstreamId = primaryBitstream?.id;
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -3,7 +3,9 @@
|
||||
<div class="item-page" @fadeInOut>
|
||||
@if (itemRD?.payload; as item) {
|
||||
<div>
|
||||
|
||||
<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-notify-requests-status [itemUuid]="item.uuid"></ds-notify-requests-status>
|
||||
<ds-item-versions-notice [item]="item"></ds-item-versions-notice>
|
||||
|
@@ -4,3 +4,4 @@
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -39,10 +39,14 @@ import {
|
||||
} from '../../core/services/link-head.service';
|
||||
import { ServerResponseService } from '../../core/services/server-response.service';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { ItemRequest } from '../../core/shared/item-request.model';
|
||||
import { getAllSucceededRemoteDataPayload } from '../../core/shared/operators';
|
||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||
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 { ThemedLoadingComponent } from '../../shared/loading/themed-loading.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 { ItemVersionsComponent } from '../versions/item-versions.component';
|
||||
import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component';
|
||||
import { AccessByTokenNotificationComponent } from './access-by-token-notification/access-by-token-notification.component';
|
||||
import { NotifyRequestsStatusComponent } from './notify-requests-status/notify-requests-status-component/notify-requests-status.component';
|
||||
import { QaEventNotificationComponent } from './qa-event-notification/qa-event-notification.component';
|
||||
|
||||
@@ -80,6 +85,7 @@ import { QaEventNotificationComponent } from './qa-event-notification/qa-event-n
|
||||
AsyncPipe,
|
||||
NotifyRequestsStatusComponent,
|
||||
QaEventNotificationComponent,
|
||||
AccessByTokenNotificationComponent,
|
||||
],
|
||||
})
|
||||
export class ItemPageComponent implements OnInit, OnDestroy {
|
||||
@@ -94,6 +100,11 @@ export class ItemPageComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -123,6 +134,8 @@ export class ItemPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
coarRestApiUrls: string[] = [];
|
||||
|
||||
protected readonly hasValue = hasValue;
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
@@ -144,6 +157,7 @@ export class ItemPageComponent implements OnInit, OnDestroy {
|
||||
this.itemRD$ = this.route.data.pipe(
|
||||
map((data) => data.dso as RemoteData<Item>),
|
||||
);
|
||||
|
||||
this.itemPageRoute$ = this.itemRD$.pipe(
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
map((item) => getItemPageRoute(item)),
|
||||
@@ -244,4 +258,17 @@ export class ItemPageComponent implements OnInit, OnDestroy {
|
||||
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>
|
||||
<div class="mb-3">
|
||||
<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) {
|
||||
<div class="invalid-feedback">
|
||||
{{ 'grant-deny-request-copy.email.subject.empty' | translate }}
|
||||
@@ -12,6 +13,34 @@
|
||||
<label for="message" class="form-label">{{ 'grant-deny-request-copy.email.message' | translate }}</label>
|
||||
<textarea class="form-control" id="message" rows="8" [(ngModel)]="message" name="message"></textarea>
|
||||
</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>
|
||||
<div class="d-flex flex-row-reverse">
|
||||
<button (click)="submit()"
|
||||
|
@@ -7,6 +7,7 @@ import {
|
||||
} from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { VarDirective } from '../../shared/utils/var.directive';
|
||||
import { EmailRequestCopyComponent } from './email-request-copy.component';
|
||||
@@ -33,6 +34,8 @@ describe('EmailRequestCopyComponent', () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EmailRequestCopyComponent);
|
||||
component = fixture.componentInstance;
|
||||
// Set validAccessPeriods$ before detectChanges calls ngOnInit
|
||||
component.validAccessPeriods$ = of(['FOREVER']);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
@@ -1,4 +1,7 @@
|
||||
import 'altcha';
|
||||
|
||||
import {
|
||||
AsyncPipe,
|
||||
Location,
|
||||
NgClass,
|
||||
} from '@angular/common';
|
||||
@@ -6,31 +9,44 @@ import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
} from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { RequestCopyEmail } from './request-copy-email.model';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'ds-base-email-request-copy',
|
||||
styleUrls: ['./email-request-copy.component.scss'],
|
||||
templateUrl: './email-request-copy.component.html',
|
||||
standalone: true,
|
||||
imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective],
|
||||
imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective, NgbDropdownModule, AsyncPipe],
|
||||
})
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@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
|
||||
*/
|
||||
@@ -41,9 +57,50 @@ export class EmailRequestCopyComponent {
|
||||
*/
|
||||
@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) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -57,4 +114,14 @@ export class EmailRequestCopyComponent {
|
||||
return() {
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the access period when a dropdown menu button is clicked for a value
|
||||
* @param accessPeriod
|
||||
*/
|
||||
selectAccessPeriod(accessPeriod: string) {
|
||||
this.accessPeriod = accessPeriod;
|
||||
this.selectedAccessPeriod.emit(accessPeriod);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import {
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ThemedComponent } from 'src/app/shared/theme-support/themed.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>();
|
||||
|
||||
/**
|
||||
* Event emitter for a selected / changed access period
|
||||
*/
|
||||
@Output() selectedAccessPeriod: EventEmitter<string> = new EventEmitter();
|
||||
|
||||
/**
|
||||
* The subject of the email
|
||||
*/
|
||||
@@ -35,7 +41,13 @@ export class ThemedEmailRequestCopyComponent extends ThemedComponent<EmailReques
|
||||
*/
|
||||
@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 {
|
||||
return 'EmailRequestCopyComponent';
|
||||
|
@@ -1,26 +1,44 @@
|
||||
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
|
||||
<h3 class="mb-4">{{ 'grant-deny-request-copy.header' | translate }}</h3>
|
||||
|
||||
@if (itemRequestRD && itemRequestRD.hasSucceeded) {
|
||||
<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>
|
||||
<p [innerHTML]="'grant-deny-request-copy.intro1' | translate:{ url: (itemUrl$ | async), name: (itemName$ | async) }"></p>
|
||||
<p
|
||||
[innerHTML]="'grant-deny-request-copy.intro1' | translate:{ url: (itemUrl$ | async), name: (itemName$ | async) }"></p>
|
||||
<p>{{ 'grant-deny-request-copy.intro2' | translate }}</p>
|
||||
@if (itemRequestRD.payload.decisionDate) {
|
||||
<p>{{ 'grant-deny-request-copy.previous-decision' | translate }}</p>
|
||||
}
|
||||
<div class="btn-group ">
|
||||
<!-- Don't show accept button for previous requests, we only want to allow revoking old requests -->
|
||||
@if (!itemRequestRD.payload.decisionDate) {
|
||||
<a [routerLink]="grantRoute$ | async"
|
||||
class="btn btn-outline-primary"
|
||||
title="{{'grant-deny-request-copy.grant' | translate }}">
|
||||
{{ 'grant-deny-request-copy.grant' | translate }}
|
||||
</a>
|
||||
|
||||
<a [routerLink]="denyRoute$ | async"
|
||||
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>
|
||||
}
|
||||
@if (itemRequestRD.payload.decisionDate) {
|
||||
<!-- Display the "already handled" message if there is a decision date, and either no access token (attachment was sent in email) or the request was denied -->
|
||||
@if (itemRequestRD.payload.decisionDate && (!itemRequestRD.payload.acceptRequest || !itemRequestRD.payload.accessToken)) {
|
||||
<div class="processed-message">
|
||||
<p>{{ 'grant-deny-request-copy.processed' | translate }}</p>
|
||||
<p class="text-center">
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
fakeAsync,
|
||||
TestBed,
|
||||
tick,
|
||||
waitForAsync,
|
||||
} from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
@@ -138,14 +140,16 @@ describe('GrantDenyRequestCopyComponent', () => {
|
||||
expect(message).toBeNull();
|
||||
});
|
||||
|
||||
it('should be displayed when decisionDate is defined', () => {
|
||||
it('should be displayed when decisionDate is defined', fakeAsync(() => {
|
||||
component.itemRequestRD$ = createSuccessfulRemoteDataObject$(Object.assign(new ItemRequest(), itemRequest, {
|
||||
decisionDate: 'defined-date',
|
||||
}));
|
||||
fixture.detectChanges();
|
||||
tick(); // Simulate passage of time
|
||||
fixture.detectChanges();
|
||||
|
||||
const message = fixture.debugElement.query(By.css('.processed-message'));
|
||||
expect(message).not.toBeNull();
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
@@ -2,13 +2,36 @@
|
||||
<h3 class="mb-4">{{ 'grant-request-copy.header' | translate }}</h3>
|
||||
@if (itemRequestRD && itemRequestRD.hasSucceeded) {
|
||||
<div>
|
||||
<p>{{'grant-request-copy.intro' | translate}}</p>
|
||||
<ds-email-request-copy [subject]="subject$ | async" [message]="message$ | async" (send)="grant($event)">
|
||||
<!-- Show the appropriate intro text depending on whether the email will have an attachment or a web link -->
|
||||
<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>
|
||||
<form class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="" id="permissions" [(ngModel)]="suggestOpenAccess" name="permissions">
|
||||
<label class="form-check-label" for="permissions">{{ 'grant-deny-request-copy.email.permissions.label' | translate }}</label>
|
||||
<input class="form-check-input" type="checkbox" value="" id="permissions" [(ngModel)]="suggestOpenAccess"
|
||||
name="permissions">
|
||||
<label class="form-check-label"
|
||||
for="permissions">{{ 'grant-deny-request-copy.email.permissions.label' | translate }}</label>
|
||||
</div>
|
||||
</form>
|
||||
</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 { ItemRequestDataService } from '../../core/data/item-request-data.service';
|
||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { ItemRequest } from '../../core/shared/item-request.model';
|
||||
import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock';
|
||||
@@ -46,6 +47,7 @@ describe('GrantRequestCopyComponent', () => {
|
||||
let itemDataService: ItemDataService;
|
||||
let itemRequestService: ItemRequestDataService;
|
||||
let notificationsService: NotificationsService;
|
||||
let hardRedirectService: HardRedirectService;
|
||||
|
||||
let itemRequest: ItemRequest;
|
||||
let user: EPerson;
|
||||
@@ -90,7 +92,6 @@ describe('GrantRequestCopyComponent', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
router = jasmine.createSpyObj('router', {
|
||||
navigateByUrl: jasmine.createSpy('navigateByUrl'),
|
||||
});
|
||||
@@ -106,11 +107,17 @@ describe('GrantRequestCopyComponent', () => {
|
||||
itemDataService = jasmine.createSpyObj('itemDataService', {
|
||||
findById: createSuccessfulRemoteDataObject$(item),
|
||||
});
|
||||
itemRequestService = jasmine.createSpyObj('itemRequestService', {
|
||||
itemRequestService = jasmine.createSpyObj('ItemRequestDataService', {
|
||||
getSanitizedRequestByAccessToken: observableOf(createSuccessfulRemoteDataObject(itemRequest)),
|
||||
grant: createSuccessfulRemoteDataObject$(itemRequest),
|
||||
getConfiguredAccessPeriods: observableOf([3600, 7200, 14400]), // Common access periods in seconds
|
||||
});
|
||||
|
||||
authService = jasmine.createSpyObj('authService', {
|
||||
isAuthenticated: observableOf(true),
|
||||
getAuthenticatedUserFromStore: observableOf(user),
|
||||
});
|
||||
notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']);
|
||||
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), GrantRequestCopyComponent, VarDirective],
|
||||
providers: [
|
||||
@@ -121,6 +128,7 @@ describe('GrantRequestCopyComponent', () => {
|
||||
{ provide: DSONameService, useValue: new DSONameServiceMock() },
|
||||
{ provide: ItemRequestDataService, useValue: itemRequestService },
|
||||
{ provide: NotificationsService, useValue: notificationsService },
|
||||
{ provide: HardRedirectService, useValue: hardRedirectService },
|
||||
{ provide: ThemeService, useValue: getMockThemeService() },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
|
@@ -1,4 +1,8 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import {
|
||||
AsyncPipe,
|
||||
CommonModule,
|
||||
NgClass,
|
||||
} from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
@@ -7,6 +11,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
Router,
|
||||
RouterLink,
|
||||
} from '@angular/router';
|
||||
import {
|
||||
TranslateModule,
|
||||
@@ -16,17 +21,21 @@ import { Observable } from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs/operators';
|
||||
|
||||
import { getAccessTokenRequestRoute } from '../../app-routing-paths';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { ItemRequestDataService } from '../../core/data/item-request-data.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||
import { redirectOn4xx } from '../../core/shared/authorized.operators';
|
||||
import { ItemRequest } from '../../core/shared/item-request.model';
|
||||
import {
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
} from '../../core/shared/operators';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { VarDirective } from '../../shared/utils/var.directive';
|
||||
@@ -38,7 +47,7 @@ import { ThemedEmailRequestCopyComponent } from '../email-request-copy/themed-em
|
||||
styleUrls: ['./grant-request-copy.component.scss'],
|
||||
templateUrl: './grant-request-copy.component.html',
|
||||
standalone: true,
|
||||
imports: [VarDirective, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule],
|
||||
imports: [CommonModule, VarDirective, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule, RouterLink, NgClass],
|
||||
})
|
||||
/**
|
||||
* Component for granting an item request
|
||||
@@ -59,11 +68,39 @@ export class GrantRequestCopyComponent implements OnInit {
|
||||
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
|
||||
*/
|
||||
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(
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
@@ -71,17 +108,36 @@ export class GrantRequestCopyComponent implements OnInit {
|
||||
private translateService: TranslateService,
|
||||
private itemRequestService: ItemRequestDataService,
|
||||
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 {
|
||||
// Get item request data via the router (async)
|
||||
this.itemRequestRD$ = this.route.data.pipe(
|
||||
map((data) => data.request as RemoteData<ItemRequest>),
|
||||
getFirstCompletedRemoteData(),
|
||||
tap((rd) => {
|
||||
// If an access token is present then the backend has checked configuration and file sizes
|
||||
// and appropriately created a token to use with a secure link instead of attaching file directly
|
||||
if (rd.hasSucceeded && hasValue(rd.payload.accessToken)) {
|
||||
this.sendAsAttachment = false;
|
||||
this.previewLinkOptions = getAccessTokenRequestRoute(rd.payload.itemId, rd.payload.accessToken);
|
||||
this.previewLink = this.hardRedirectService.getCurrentOrigin()
|
||||
+ this.previewLinkOptions.routerLink + '?accessToken=' + rd.payload.accessToken;
|
||||
}
|
||||
}),
|
||||
redirectOn4xx(this.router, this.authService),
|
||||
);
|
||||
|
||||
// Get configured access periods
|
||||
this.validAccessPeriods$ = this.itemRequestService.getConfiguredAccessPeriods();
|
||||
|
||||
// Get the subject line of the email
|
||||
this.subject$ = this.translateService.get('grant-request-copy.email.subject');
|
||||
}
|
||||
|
||||
@@ -92,7 +148,7 @@ export class GrantRequestCopyComponent implements OnInit {
|
||||
grant(email: RequestCopyEmail) {
|
||||
this.itemRequestRD$.pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess)),
|
||||
switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess, this.accessPeriod)),
|
||||
getFirstCompletedRemoteData(),
|
||||
).subscribe((rd) => {
|
||||
if (rd.hasSucceeded) {
|
||||
@@ -104,4 +160,8 @@ export class GrantRequestCopyComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
selectAccessPeriod(accessPeriod: string) {
|
||||
this.accessPeriod = accessPeriod;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -6,9 +6,14 @@
|
||||
[target]="isBlank ? '_blank': '_self'"
|
||||
[ngClass]="cssClasses"
|
||||
[attr.aria-label]="('file-download-link.download' | translate) + dsoNameService.getName(bitstream)">
|
||||
@if ((canDownload$ |async) !== true) {
|
||||
<span role="img" [attr.aria-label]="'file-download-link.restricted' | translate" class="pe-1"><i class="fas fa-lock"></i></span>
|
||||
@if ((canDownload$ | async) === false && (canDownloadWithToken$ | async) === false) {
|
||||
<!-- 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>
|
||||
</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 { Bitstream } from '../../core/shared/bitstream.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { ItemRequest } from '../../core/shared/item-request.model';
|
||||
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
||||
import { getItemModuleRoute } from '../../item-page/item-page-routing-paths';
|
||||
import { ActivatedRouteStub } from '../testing/active-router.stub';
|
||||
@@ -39,6 +40,15 @@ describe('FileDownloadLinkComponent', () => {
|
||||
let item: Item;
|
||||
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() {
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||
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({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
@@ -71,7 +82,7 @@ describe('FileDownloadLinkComponent', () => {
|
||||
providers: [
|
||||
RouterLinkDirectiveStub,
|
||||
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
|
||||
],
|
||||
@@ -85,6 +96,9 @@ describe('FileDownloadLinkComponent', () => {
|
||||
|
||||
describe('init', () => {
|
||||
describe('getBitstreamPath', () => {
|
||||
|
||||
|
||||
|
||||
describe('when the user has download rights', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
scheduler = getTestScheduler();
|
||||
@@ -113,6 +127,7 @@ describe('FileDownloadLinkComponent', () => {
|
||||
expect(lock).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user has no download rights but has the right to request a copy', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
scheduler = getTestScheduler();
|
||||
@@ -146,6 +161,7 @@ describe('FileDownloadLinkComponent', () => {
|
||||
expect(lock).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user has no download rights and no request a copy rights', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
scheduler = getTestScheduler();
|
||||
@@ -165,7 +181,7 @@ describe('FileDownloadLinkComponent', () => {
|
||||
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();
|
||||
fixture.detectChanges();
|
||||
const link = fixture.debugElement.query(By.css('a'));
|
||||
@@ -174,6 +190,35 @@ describe('FileDownloadLinkComponent', () => {
|
||||
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,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
RouterLink,
|
||||
} from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
combineLatest as observableCombineLatest,
|
||||
@@ -19,6 +22,7 @@ import { map } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
getBitstreamDownloadRoute,
|
||||
getBitstreamDownloadWithAccessTokenRoute,
|
||||
getBitstreamRequestACopyRoute,
|
||||
} from '../../app-routing-paths';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
@@ -26,6 +30,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/
|
||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { ItemRequest } from '../../core/shared/item-request.model';
|
||||
import {
|
||||
hasValue,
|
||||
isNotEmpty,
|
||||
@@ -70,25 +75,35 @@ export class FileDownloadLinkComponent implements OnInit {
|
||||
*/
|
||||
@Input() showAccessStatusBadge = true;
|
||||
|
||||
itemRequest: ItemRequest;
|
||||
|
||||
bitstreamPath$: Observable<{
|
||||
routerLink: string,
|
||||
queryParams: any,
|
||||
}>;
|
||||
|
||||
canDownload$: Observable<boolean>;
|
||||
canDownloadWithToken$: Observable<boolean>;
|
||||
canRequestACopy$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private authorizationService: AuthorizationDataService,
|
||||
public dsoNameService: DSONameService,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.enableRequestACopy) {
|
||||
// Obtain item request data from the route snapshot
|
||||
this.itemRequest = this.route.snapshot.data.itemRequest;
|
||||
// Set up observables to test access rights to a normal bitstream download, a valid token download, and the request-a-copy feature
|
||||
this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined);
|
||||
const canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined);
|
||||
this.bitstreamPath$ = observableCombineLatest([this.canDownload$, canRequestACopy$]).pipe(
|
||||
map(([canDownload, canRequestACopy]) => this.getBitstreamPath(canDownload, canRequestACopy)),
|
||||
this.canDownloadWithToken$ = observableOf((this.itemRequest && this.itemRequest.acceptRequest && !this.itemRequest.accessExpired) ? (this.itemRequest.allfiles !== false || this.itemRequest.bitstreamId === this.bitstream.uuid) : false);
|
||||
this.canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined);
|
||||
// Set up observable to determine the path to the bitstream based on the user's access rights and features as above
|
||||
this.bitstreamPath$ = observableCombineLatest([this.canDownload$, this.canDownloadWithToken$, this.canRequestACopy$]).pipe(
|
||||
map(([canDownload, canDownloadWithToken, canRequestACopy]) => this.getBitstreamPath(canDownload, canDownloadWithToken, canRequestACopy)),
|
||||
);
|
||||
} else {
|
||||
this.bitstreamPath$ = observableOf(this.getBitstreamDownloadPath());
|
||||
@@ -96,13 +111,42 @@ export class FileDownloadLinkComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
getBitstreamPath(canDownload: boolean, canRequestACopy: boolean) {
|
||||
/**
|
||||
* Return a path to the bitstream based on what kind of access and authorization the user has, and whether
|
||||
* they may request a copy
|
||||
*
|
||||
* @param canDownload user can download normally
|
||||
* @param canDownloadWithToken user can download using a token granted by a request approver
|
||||
* @param canRequestACopy user can request approval to access a copy
|
||||
*/
|
||||
getBitstreamPath(canDownload: boolean, canDownloadWithToken, canRequestACopy: boolean) {
|
||||
// No matter what, if the user can download with their own authZ, allow it
|
||||
if (canDownload) {
|
||||
return this.getBitstreamDownloadPath();
|
||||
}
|
||||
// Otherwise, if they access token is valid, use this
|
||||
if (canDownloadWithToken) {
|
||||
return this.getAccessByTokenBitstreamPath(this.itemRequest);
|
||||
}
|
||||
// If the user can't download, but can request a copy, show the request a copy link
|
||||
if (!canDownload && canRequestACopy && hasValue(this.item)) {
|
||||
return getBitstreamRequestACopyRoute(this.item, this.bitstream);
|
||||
}
|
||||
// By default, return the plain path
|
||||
return this.getBitstreamDownloadPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve special bitstream path which includes access token parameter
|
||||
* @param itemRequest the item request object
|
||||
*/
|
||||
getAccessByTokenBitstreamPath(itemRequest: ItemRequest) {
|
||||
return getBitstreamDownloadWithAccessTokenRoute(this.bitstream, itemRequest.accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normal bitstream download path, with no parameters
|
||||
*/
|
||||
getBitstreamDownloadPath() {
|
||||
return {
|
||||
routerLink: getBitstreamDownloadRoute(this.bitstream),
|
||||
|
@@ -62,6 +62,7 @@ export class ActivatedRouteStub {
|
||||
paramMap: convertToParamMap(this.params),
|
||||
queryParamMap: convertToParamMap(this.testParams),
|
||||
queryParams: {} as Params,
|
||||
data: this.testData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -11,4 +11,5 @@ import {
|
||||
})
|
||||
export class RouterLinkDirectiveStub {
|
||||
@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.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.comcol.by.author": "By Author",
|
||||
@@ -1904,6 +1916,8 @@
|
||||
|
||||
"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",
|
||||
|
||||
"footer.copyright": "copyright © 2002-{{ year }}",
|
||||
@@ -2044,7 +2058,9 @@
|
||||
|
||||
"form.repeatable.sort.tip": "Drop the item in the new position",
|
||||
|
||||
"grant-deny-request-copy.deny": "Don't send copy",
|
||||
"grant-deny-request-copy.deny": "Deny access request",
|
||||
|
||||
"grant-deny-request-copy.revoke": "Revoke access",
|
||||
|
||||
"grant-deny-request-copy.email.back": "Back",
|
||||
|
||||
@@ -2062,7 +2078,7 @@
|
||||
|
||||
"grant-deny-request-copy.email.subject.empty": "Please enter a subject",
|
||||
|
||||
"grant-deny-request-copy.grant": "Send copy",
|
||||
"grant-deny-request-copy.grant": "Grant access request",
|
||||
|
||||
"grant-deny-request-copy.header": "Document copy request",
|
||||
|
||||
@@ -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.previous-decision": "This request was previously granted with a secure access token. You may revoke this access now to immediately invalidate the access token",
|
||||
|
||||
"grant-deny-request-copy.processed": "This request has already been processed. You can use the button below to get back to the home page.",
|
||||
|
||||
"grant-request-copy.email.subject": "Request copy of document",
|
||||
@@ -2080,10 +2098,26 @@
|
||||
|
||||
"grant-request-copy.header": "Grant document copy request",
|
||||
|
||||
"grant-request-copy.intro": "A message will be sent to the applicant of the request. The requested document(s) will be attached.",
|
||||
"grant-request-copy.intro.attachment": "A message will be sent to the applicant of the request. The requested document(s) will be attached.",
|
||||
|
||||
"grant-request-copy.intro.link": "A message will be sent to the applicant of the request. A secure link providing access to the requested document(s) will be attached. The link will provide access for the duration of time selected in the \"Access Period\" menu below.",
|
||||
|
||||
"grant-request-copy.intro.link.preview": "Below is a preview of the link that will be sent to the applicant:",
|
||||
|
||||
"grant-request-copy.success": "Successfully granted item request",
|
||||
|
||||
"grant-request-copy.access-period.header": "Access period",
|
||||
|
||||
"grant-request-copy.access-period.+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-page.heading": "Health",
|
||||
|
@@ -6,6 +6,7 @@ import {
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { ThemedItemAlertsComponent } from '../../../../../app/item-page/alerts/themed-item-alerts.component';
|
||||
import { AccessByTokenNotificationComponent } from '../../../../../app/item-page/simple/access-by-token-notification/access-by-token-notification.component';
|
||||
import { ItemPageComponent as BaseComponent } from '../../../../../app/item-page/simple/item-page.component';
|
||||
import { NotifyRequestsStatusComponent } from '../../../../../app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component';
|
||||
import { QaEventNotificationComponent } from '../../../../../app/item-page/simple/qa-event-notification/qa-event-notification.component';
|
||||
@@ -45,6 +46,7 @@ import { ViewTrackerComponent } from '../../../../../app/statistics/angulartics/
|
||||
AsyncPipe,
|
||||
NotifyRequestsStatusComponent,
|
||||
QaEventNotificationComponent,
|
||||
AccessByTokenNotificationComponent,
|
||||
],
|
||||
})
|
||||
export class ItemPageComponent extends BaseComponent {
|
||||
|
@@ -1,6 +1,10 @@
|
||||
import { NgClass } from '@angular/common';
|
||||
import {
|
||||
AsyncPipe,
|
||||
NgClass,
|
||||
} from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
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: './../../../../../app/request-copy/email-request-copy/email-request-copy.component.html',
|
||||
standalone: true,
|
||||
imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective],
|
||||
imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective, AsyncPipe, NgbDropdownModule],
|
||||
})
|
||||
export class EmailRequestCopyComponent
|
||||
extends BaseComponent {
|
||||
|
Reference in New Issue
Block a user