Request-a-copy: Refactor for angular control flow changes

This commit is contained in:
Kim Shepherd
2025-03-13 11:26:57 +01:00
parent 60bbcf3420
commit 0c58a5bf05
14 changed files with 581 additions and 60 deletions

View File

@@ -1,10 +1,17 @@
import { HttpHeaders } from '@angular/common/http';
import { of as observableOf } from 'rxjs';
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
import { MockBitstream1 } from '../../shared/mocks/item.mock';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ConfigurationProperty } from '../shared/configuration-property.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ItemRequest } from '../shared/item-request.model';
import { ConfigurationDataService } from './configuration-data.service';
import { AuthorizationDataService } from './feature-authorization/authorization-data.service';
import { FeatureID } from './feature-authorization/feature-id';
import { FindListOptions } from './find-list-options.model';
import { ItemRequestDataService } from './item-request-data.service';
import { PostRequest } from './request.models';
import { RequestService } from './request.service';
@@ -16,12 +23,36 @@ describe('ItemRequestDataService', () => {
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let halService: HALEndpointService;
let configService: ConfigurationDataService;
let authorizationDataService: AuthorizationDataService;
const restApiEndpoint = 'rest/api/endpoint/';
const requestId = 'request-id';
let itemRequest: ItemRequest;
beforeEach(() => {
configService = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']);
(configService.findByPropertyName as jasmine.Spy).and.callFake((propertyName: string) => {
switch (propertyName) {
case 'request.item.create.captcha':
return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'request.item.create.captcha',
values: ['true'],
}));
case 'request.item.grant.link.period':
return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'request.item.grant.link.period',
values: ['3600', '7200', '86400'],
}));
default:
return createSuccessfulRemoteDataObject$(new ConfigurationProperty());
}
});
authorizationDataService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(false),
});
itemRequest = Object.assign(new ItemRequest(), {
token: 'item-request-token',
});
@@ -36,13 +67,42 @@ describe('ItemRequestDataService', () => {
getEndpoint: observableOf(restApiEndpoint),
});
service = new ItemRequestDataService(requestService, rdbService, null, halService);
service = new ItemRequestDataService(requestService, rdbService, null, halService, configService, authorizationDataService);
});
describe('searchBy', () => {
it('should use searchData to perform search operations', () => {
const searchMethod = 'testMethod';
const options = new FindListOptions();
const searchDataSpy = spyOn((service as any).searchData, 'searchBy').and.returnValue(observableOf(null));
service.searchBy(searchMethod, options);
expect(searchDataSpy).toHaveBeenCalledWith(
searchMethod,
options,
undefined,
undefined,
);
});
});
describe('requestACopy', () => {
it('should send a POST request containing the provided item request', (done) => {
service.requestACopy(itemRequest).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest));
const captchaPayload = 'payload';
service.requestACopy(itemRequest, captchaPayload).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(
new PostRequest(
requestId,
restApiEndpoint,
itemRequest,
{
headers: new HttpHeaders().set('x-captcha-payload', captchaPayload),
},
),
false,
);
done();
});
});
@@ -59,11 +119,16 @@ describe('ItemRequestDataService', () => {
service.grant(itemRequest.token, email, true).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT,
href: `${restApiEndpoint}/${itemRequest.token}`,
body: JSON.stringify({
acceptRequest: true,
responseMessage: email.message,
subject: email.subject,
suggestOpenAccess: true,
accessPeriod: 0,
}),
options: jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
}),
}));
done();
@@ -82,15 +147,70 @@ describe('ItemRequestDataService', () => {
service.deny(itemRequest.token, email).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT,
href: `${restApiEndpoint}/${itemRequest.token}`,
body: JSON.stringify({
acceptRequest: false,
responseMessage: email.message,
subject: email.subject,
suggestOpenAccess: false,
accessPeriod: 0,
}),
options: jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
}),
}));
done();
});
});
});
describe('requestACopy', () => {
it('should send a POST request containing the provided item request', (done) => {
const captchaPayload = 'payload';
service.requestACopy(itemRequest, captchaPayload).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(
new PostRequest(
requestId,
restApiEndpoint,
itemRequest,
{
headers: new HttpHeaders().set('x-captcha-payload', captchaPayload),
},
),
false,
);
done();
});
});
});
describe('getConfiguredAccessPeriods', () => {
it('should return parsed integer values from config', () => {
service.getConfiguredAccessPeriods().subscribe(periods => {
expect(periods).toEqual([3600, 7200, 86400]);
});
});
});
describe('isProtectedByCaptcha', () => {
it('should return true when config value is "true"', () => {
const mockConfigProperty = {
name: 'request.item.create.captcha',
values: ['true'],
} as ConfigurationProperty;
service.isProtectedByCaptcha().subscribe(result => {
expect(result).toBe(true);
});
});
});
describe('canDownload', () => {
it('should check authorization for bitstream download', () => {
service.canDownload(MockBitstream1).subscribe(result => {
expect(authorizationDataService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanDownload, MockBitstream1.self);
expect(result).toBe(false);
});
});
});
});

View File

@@ -13,14 +13,27 @@ import {
hasValue,
isNotEmpty,
} from '../../shared/empty.util';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { Bitstream } from '../shared/bitstream.model';
import { ConfigurationProperty } from '../shared/configuration-property.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ItemRequest } from '../shared/item-request.model';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { sendRequest } from '../shared/request.operators';
import { IdentifiableDataService } from './base/identifiable-data.service';
import {
SearchData,
SearchDataImpl,
} from './base/search-data';
import { ConfigurationDataService } from './configuration-data.service';
import { AuthorizationDataService } from './feature-authorization/authorization-data.service';
import { FeatureID } from './feature-authorization/feature-id';
import { FindListOptions } from './find-list-options.model';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import {
PostRequest,
@@ -34,14 +47,21 @@ import { RequestService } from './request.service';
@Injectable({
providedIn: 'root',
})
export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> {
export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> implements SearchData<ItemRequest> {
// TODO: This is only public for access by the test class - smell?
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 +81,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 +123,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 = 0): Observable<RemoteData<ItemRequest>> {
return this.process(token, email, true, suggestOpenAccess, accessPeriod);
}
/**
@@ -105,8 +135,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 = 0): Observable<RemoteData<ItemRequest>> {
const requestId = this.requestService.generateRequestId();
this.getItemRequestEndpointByToken(token).pipe(
@@ -121,6 +152,7 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
responseMessage: email.message,
subject: email.subject,
suggestOpenAccess,
accessPeriod: accessPeriod,
}), options);
}),
sendRequest(this.requestService),
@@ -128,4 +160,102 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
return this.rdbService.buildFromRequestUUID(requestId);
}
// TODO: Remove this, after discussion about implications and compare to bitstream data service byItemHandle
// Reviewers may ask that we instead just wrap the REST response in pagination even though we only expect one obj
/**
* Get a sanitized item request using the searchBy method and the access token sent to the original requester.
*
* @param accessToken access token contained in the secure link sent to a requester
*/
getSanitizedRequestByAccessTokenPaged(accessToken: string): Observable<RemoteData<PaginatedList<ItemRequest>>> {
// We only expect / want one result as access tokens are unique
const findListOptions = Object.assign({}, new FindListOptions(), {
elementsPerPage: 1,
currentPage: 1,
searchParams: [
new RequestParam('accessToken', accessToken),
],
});
// Pipe the paginated searchBy results and return a single item request
return this.searchBy('byAccessToken', findListOptions);
}
/**
* Get a sanitized item request using the searchBy method and the access token sent to the original requester.
*
* @param accessToken access token contained in the secure link sent to a requester
*/
getSanitizedRequestByAccessToken(accessToken: string): Observable<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<number[]> {
return this.configService.findByPropertyName('request.item.grant.link.period').pipe(
getFirstCompletedRemoteData(),
map((propertyRD: RemoteData<ConfigurationProperty>) => propertyRD.hasSucceeded ? propertyRD.payload.values : []),
map((values) => values.map(value => parseInt(value, 10))),
);
}
/**
* Is the request copy form protected by a captcha? This will be used to decide whether to render the captcha
* component in bitstream-request-a-copy-page component
*/
isProtectedByCaptcha(): Observable<boolean> {
return this.configService.findByPropertyName('request.item.create.captcha').pipe(
getFirstCompletedRemoteData(),
map((rd) => {
if (rd.hasSucceeded) {
return rd.payload.values.length > 0 && rd.payload.values[0] === 'true';
} else {
return false;
}
}));
}
/**
* Create the HREF for a specific object's search method with given options object
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<ItemRequest>[]): Observable<string> {
return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow);
}
/**
* Authorization check to see if the user already has download access to the given bitstream.
* Wrapped in this service to give it a central place and make it easy to mock for testing.
*
* @param bitstream The bitstream to be downloaded
* @return {Observable<boolean>} true if user may download, false if not
*/
canDownload(bitstream: Bitstream): Observable<boolean> {
return this.authorizationService.isAuthorized(FeatureID.CanDownload, bitstream?.self);
}
}

View File

@@ -80,6 +80,16 @@ export class ItemRequest implements CacheableObject {
*/
@autoserialize
bitstreamId: string;
/**
* Access token of the request (read-only)
*/
@autoserialize
accessToken: string;
/**
* Access period of the request
*/
@autoserialize
accessPeriod: number;
/**
* The {@link HALLink}s for this ItemRequest

View File

@@ -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">

View File

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

View File

@@ -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) => {

View File

@@ -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,18 +13,46 @@
<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) && validAccessPeriods.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">
@for (accessPeriod of validAccessPeriods; 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()"
[dsBtnDisabled]="!subject || subject.length === 0"
class="btn btn-primary"
title="{{'grant-deny-request-copy.email.send' | translate }}">
<i class="fas fa-envelope"></i> {{'grant-deny-request-copy.email.send' | translate }}
[dsBtnDisabled]="!subject || subject.length === 0"
class="btn btn-primary"
title="{{'grant-deny-request-copy.email.send' | translate }}">
<i class="fas fa-envelope"></i> {{ 'grant-deny-request-copy.email.send' | translate }}
</button>
<button (click)="return()"
class="btn btn-outline-secondary me-1"
title="{{'grant-deny-request-copy.email.back' | translate }}">
<i class="fas fa-arrow-left"></i> {{'grant-deny-request-copy.email.back' | translate }}
class="btn btn-outline-secondary me-1"
title="{{'grant-deny-request-copy.email.back' | translate }}">
<i class="fas fa-arrow-left"></i> {{ 'grant-deny-request-copy.email.back' | translate }}
</button>
</div>
</form>

View File

@@ -45,6 +45,7 @@ describe('EmailRequestCopyComponent', () => {
spyOn(component.send, 'emit').and.stub();
component.subject = 'test-subject';
component.message = 'test-message';
component.validAccessPeriods = [0];
component.submit();
expect(component.send.emit).toHaveBeenCalledWith(new RequestCopyEmail('test-subject', 'test-message'));
});

View File

@@ -1,3 +1,5 @@
import 'altcha';
import {
Location,
NgClass,
@@ -6,30 +8,33 @@ import {
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { hasValue } from '../../shared/empty.util';
import { RequestCopyEmail } from './request-copy-email.model';
@Component({
selector: 'ds-base-email-request-copy',
styleUrls: ['./email-request-copy.component.scss'],
templateUrl: './email-request-copy.component.html',
standalone: true,
imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective],
imports: [FormsModule, NgClass, TranslateModule, BtnDisabledDirective, NgbDropdownModule],
})
/**
* A form component for an email to send back to the user requesting an item
*/
export class EmailRequestCopyComponent {
export class EmailRequestCopyComponent implements OnInit {
/**
* Event emitter for sending the email
*/
@Output() send: EventEmitter<RequestCopyEmail> = new EventEmitter<RequestCopyEmail>();
@Output() selectedAccessPeriod: EventEmitter<number> = new EventEmitter();
/**
* The subject of the email
@@ -41,9 +46,28 @@ export class EmailRequestCopyComponent {
*/
@Input() message: string;
/**
* A list of valid access periods to render in a drop-down menu
*/
@Input() validAccessPeriods: number[] = [];
/**
* The selected access period
*/
accessPeriod = 0;
protected readonly hasValue = hasValue;
constructor(protected location: Location) {
}
ngOnInit(): void {
// If access periods are present, set the default to the first in the array
if (hasValue(this.validAccessPeriods) && this.validAccessPeriods.length > 0) {
this.selectAccessPeriod(this.validAccessPeriods[0]);
}
}
/**
* Submit the email
*/
@@ -57,4 +81,14 @@ export class EmailRequestCopyComponent {
return() {
this.location.back();
}
/**
* Update the access period when a dropdown menu button is clicked for a value
* @param accessPeriod
*/
selectAccessPeriod(accessPeriod: number) {
this.accessPeriod = accessPeriod;
this.selectedAccessPeriod.emit(accessPeriod);
}
}

View File

@@ -25,6 +25,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<number> = new EventEmitter();
/**
* The subject of the email
*/
@@ -35,7 +40,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: number[];
protected inAndOutputNames: (keyof EmailRequestCopyComponent & keyof this)[] = ['send', 'subject', 'message', 'validAccessPeriods', 'selectedAccessPeriod'];
protected getComponentName(): string {
return 'EmailRequestCopyComponent';

View File

@@ -1,32 +1,50 @@
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
<h3 class="mb-4">{{'grant-deny-request-copy.header' | translate}}</h3>
<h3 class="mb-4">{{ 'grant-deny-request-copy.header' | translate }}</h3>
@if (itemRequestRD && itemRequestRD.hasSucceeded) {
<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>{{'grant-deny-request-copy.intro2' | translate}}</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 ">
<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>
<!-- 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) {
<div class="processed-message">
<p>{{'grant-deny-request-copy.processed' | translate}}</p>
<p class="text-center">
<a routerLink="/home" class="btn btn-primary">{{'grant-deny-request-copy.home-page' | translate}}</a>
</p>
</div>
<!-- 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 === false || !itemRequestRD.payload.accessToken)) {
<div class="processed-message">
<p>{{ 'grant-deny-request-copy.processed' | translate }}</p>
<p class="text-center">
<a routerLink="/home" class="btn btn-primary">{{ 'grant-deny-request-copy.home-page' | translate }}</a>
</p>
</div>
}
}
</div>
}

View File

@@ -1,14 +1,37 @@
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
<h3 class="mb-4">{{'grant-request-copy.header' | translate}}</h3>
<h3 class="mb-4">{{ 'grant-request-copy.header' | translate }}</h3>
@if (itemRequestRD && itemRequestRD.hasSucceeded) {
<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"
[validAccessPeriods]="(hasValue(itemRequestRD.payload.accessToken) ? (validAccessPeriods$ | async) : [])"
(send)="grant($event)"
(selectedAccessPeriod)="selectAccessPeriod($event)"
>
<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>

View File

@@ -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],

View File

@@ -1,4 +1,8 @@
import { AsyncPipe } from '@angular/common';
import {
AsyncPipe,
CommonModule,
NgClass,
} from '@angular/common';
import {
Component,
OnInit,
@@ -7,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import {
ActivatedRoute,
Router,
RouterLink,
} from '@angular/router';
import {
TranslateModule,
@@ -16,17 +21,21 @@ import { Observable } from 'rxjs';
import {
map,
switchMap,
tap,
} from 'rxjs/operators';
import { getAccessTokenRequestRoute } from '../../app-routing-paths';
import { AuthService } from '../../core/auth/auth.service';
import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { HardRedirectService } from '../../core/services/hard-redirect.service';
import { redirectOn4xx } from '../../core/shared/authorized.operators';
import { ItemRequest } from '../../core/shared/item-request.model';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
} from '../../core/shared/operators';
import { hasValue } from '../../shared/empty.util';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { VarDirective } from '../../shared/utils/var.directive';
@@ -38,7 +47,7 @@ import { ThemedEmailRequestCopyComponent } from '../email-request-copy/themed-em
styleUrls: ['./grant-request-copy.component.scss'],
templateUrl: './grant-request-copy.component.html',
standalone: true,
imports: [VarDirective, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule],
imports: [CommonModule, VarDirective, NgIf, ThemedEmailRequestCopyComponent, FormsModule, ThemedLoadingComponent, AsyncPipe, TranslateModule, RouterLink, NgClass],
})
/**
* Component for granting an item request
@@ -59,11 +68,39 @@ export class GrantRequestCopyComponent implements OnInit {
message$: Observable<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<number[]>;
/**
* The currently selected access period
*/
accessPeriod: any = 0;
/**
* Will this email attach file(s) directly, or send a secure link with an access token to provide temporary access?
* This will be false if the access token is populated, since the configuration and min file size checks are
* done at the time of request creation, with a default of true.
*/
sendAsAttachment = true;
/**
* Preview link to be sent to a request applicant
*/
previewLinkOptions: {
routerLink: string,
queryParams: any,
};
previewLink: string;
protected readonly hasValue = hasValue;
constructor(
private router: Router,
private route: ActivatedRoute,
@@ -71,17 +108,33 @@ export class GrantRequestCopyComponent implements OnInit {
private translateService: TranslateService,
private itemRequestService: ItemRequestDataService,
private notificationsService: NotificationsService,
private hardRedirectService: HardRedirectService,
) {
}
ngOnInit(): void {
// Get item request data via the router (async)
this.itemRequestRD$ = this.route.data.pipe(
map((data) => data.request as RemoteData<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 +145,7 @@ export class GrantRequestCopyComponent implements OnInit {
grant(email: RequestCopyEmail) {
this.itemRequestRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess)),
switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess, this.accessPeriod)),
getFirstCompletedRemoteData(),
).subscribe((rd) => {
if (rd.hasSucceeded) {
@@ -104,4 +157,8 @@ export class GrantRequestCopyComponent implements OnInit {
});
}
selectAccessPeriod(accessPeriod: number) {
this.accessPeriod = accessPeriod;
}
}