Skip to content

Commit 93efe5d

Browse files
author
FrancescoMauto
committed
[DSC-1786] add: implement Google reCAPTCHA in feedback form with enhanced validation and notification handling
1 parent 17f0c8d commit 93efe5d

6 files changed

Lines changed: 322 additions & 172 deletions

File tree

src/app/core/feedback/feedback-data.service.ts

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { Injectable } from '@angular/core';
22
import { Store } from '@ngrx/store';
3-
import { Observable } from 'rxjs';
3+
import { Observable, map } from 'rxjs';
44

55
import { NotificationsService } from '../../shared/notifications/notifications.service';
66
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
77
import { RequestParam } from '../cache/models/request-param.model';
88
import { ObjectCacheService } from '../cache/object-cache.service';
9-
import {
10-
CreateData,
11-
CreateDataImpl,
12-
} from '../data/base/create-data';
9+
import { CreateData, CreateDataImpl } from '../data/base/create-data';
1310
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
1411
import { RemoteData } from '../data/remote-data';
1512
import { RequestService } from '../data/request.service';
@@ -19,12 +16,23 @@ import {
1916
getRemoteDataPayload,
2017
} from '../shared/operators';
2118
import { Feedback } from './models/feedback.model';
19+
import { HttpHeaders } from '@angular/common/http';
20+
import { distinctUntilChanged, take, takeWhile } from 'rxjs/operators';
21+
import { hasValue, isNotEmptyOperator } from '../../shared/empty.util';
22+
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
23+
import { getClassForType } from '../cache/builders/build-decorators';
24+
import { CreateRequest } from '../data/request.models';
25+
import { DSpaceSerializer } from '../dspace-rest/dspace.serializer';
26+
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
2227

2328
/**
2429
* Service for checking and managing the feedback
2530
*/
2631
@Injectable({ providedIn: 'root' })
27-
export class FeedbackDataService extends IdentifiableDataService<Feedback> implements CreateData<Feedback> {
32+
export class FeedbackDataService
33+
extends IdentifiableDataService<Feedback>
34+
implements CreateData<Feedback>
35+
{
2836
private createData: CreateDataImpl<Feedback>;
2937

3038
constructor(
@@ -37,7 +45,15 @@ export class FeedbackDataService extends IdentifiableDataService<Feedback> imple
3745
) {
3846
super('feedbacks', requestService, rdbService, objectCache, halService);
3947

40-
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
48+
this.createData = new CreateDataImpl(
49+
this.linkPath,
50+
requestService,
51+
rdbService,
52+
objectCache,
53+
halService,
54+
notificationsService,
55+
this.responseMsToLive,
56+
);
4157
}
4258

4359
/**
@@ -51,14 +67,71 @@ export class FeedbackDataService extends IdentifiableDataService<Feedback> imple
5167
);
5268
}
5369

54-
5570
/**
5671
* Create a new object on the server, and store the response in the object cache
5772
*
5873
* @param object The object to create
5974
* @param params Array with additional params to combine with query string
6075
*/
61-
public create(object: Feedback, ...params: RequestParam[]): Observable<RemoteData<Feedback>> {
76+
public create(
77+
object: Feedback,
78+
...params: RequestParam[]
79+
): Observable<RemoteData<Feedback>> {
6280
return this.createData.create(object, ...params);
6381
}
82+
83+
createWithCaptcha(
84+
object: Feedback,
85+
captchaToken: string = null,
86+
...params: RequestParam[]
87+
): Observable<RemoteData<Feedback>> {
88+
const requestId = this.requestService.generateRequestId();
89+
90+
const endpoint$ = this.getEndpoint().pipe(
91+
isNotEmptyOperator(),
92+
distinctUntilChanged(),
93+
map((endpoint: string) => this.buildHrefWithParams(endpoint, params)),
94+
);
95+
96+
const serializedObject = new DSpaceSerializer(
97+
getClassForType(object.type),
98+
).serialize(object);
99+
100+
endpoint$.pipe(take(1)).subscribe((endpoint: string) => {
101+
const options: HttpOptions = Object.create({});
102+
let headers = new HttpHeaders();
103+
if (captchaToken) {
104+
headers = headers.set('x-recaptcha-token', captchaToken);
105+
}
106+
options.headers = headers;
107+
108+
const request = new CreateRequest(
109+
requestId,
110+
endpoint,
111+
JSON.stringify(serializedObject),
112+
options,
113+
);
114+
115+
if (hasValue(this.responseMsToLive)) {
116+
request.responseMsToLive = this.responseMsToLive;
117+
}
118+
this.requestService.send(request);
119+
});
120+
121+
const result$ = this.rdbService.buildFromRequestUUID<Feedback>(requestId);
122+
123+
result$
124+
.pipe(takeWhile((rd: RemoteData<Feedback>) => rd.isLoading, true))
125+
.subscribe((rd: RemoteData<Feedback>) => {
126+
if (rd.hasFailed) {
127+
this.notificationsService.error(
128+
'Server Error:',
129+
rd.errorMessage,
130+
new NotificationOptions(-1),
131+
);
132+
}
133+
});
134+
135+
return result$;
136+
}
64137
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Component } from '@angular/core';
2+
import { TranslateService } from '@ngx-translate/core';
3+
import {
4+
BehaviorSubject,
5+
combineLatest,
6+
Observable,
7+
of,
8+
} from 'rxjs';
9+
import {
10+
startWith,
11+
switchMap,
12+
} from 'rxjs/operators';
13+
14+
import { CookieService } from '../services/cookie.service';
15+
import { isNotEmpty } from './../../shared/empty.util';
16+
import { NotificationsService } from './../../shared/notifications/notifications.service';
17+
import {
18+
CAPTCHA_NAME,
19+
GoogleRecaptchaService,
20+
} from './google-recaptcha.service';
21+
22+
@Component({
23+
template: '',
24+
})
25+
export abstract class GoogleRecaptchaBaseComponent {
26+
27+
/**
28+
* The message prefix - default value is 'submit-form'.
29+
* Add translations if a different value is used.
30+
*/
31+
MESSAGE_PREFIX = 'submit-form';
32+
33+
constructor(
34+
public googleRecaptchaService: GoogleRecaptchaService,
35+
public cookieService: CookieService,
36+
public notificationsService: NotificationsService,
37+
public translate: TranslateService,
38+
) {}
39+
40+
/**
41+
* Return true if the user completed the reCaptcha verification (checkbox mode)
42+
*/
43+
checkboxCheckedSubject$ = new BehaviorSubject<boolean>(false);
44+
45+
/**
46+
* Disable the form until the user has completed the reCaptcha verification (checkbox mode)
47+
*/
48+
disableUntilChecked = true;
49+
50+
captchaVersion(): Observable<string> {
51+
return this.googleRecaptchaService.captchaVersion();
52+
}
53+
54+
captchaMode(): Observable<string> {
55+
return this.googleRecaptchaService.captchaMode();
56+
}
57+
58+
/**
59+
* Return true if the user has not completed the reCaptcha verification (checkbox mode)
60+
*/
61+
disableUntilCheckedFcn(): Observable<boolean> {
62+
const checked$ = this.checkboxCheckedSubject$.asObservable();
63+
return combineLatest([
64+
this.captchaVersion(),
65+
this.captchaMode(),
66+
checked$,
67+
]).pipe(
68+
// disable if checkbox is not checked or if reCaptcha is not in v2 checkbox mode
69+
switchMap(([captchaVersion, captchaMode, checked]) =>
70+
captchaVersion === 'v2' && captchaMode === 'checkbox'
71+
? of(!checked)
72+
: of(false),
73+
),
74+
startWith(true),
75+
);
76+
}
77+
78+
/**
79+
* Return true if the user has accepted the required cookies for reCaptcha
80+
*/
81+
isRecaptchaCookieAccepted(): boolean {
82+
const orejimeAnonymousCookie = this.cookieService.get('orejime-anonymous');
83+
return isNotEmpty(orejimeAnonymousCookie)
84+
? orejimeAnonymousCookie[CAPTCHA_NAME]
85+
: false;
86+
}
87+
88+
/**
89+
* Set the checkbox checked status to the given value
90+
*/
91+
onCheckboxChecked(checked: boolean) {
92+
this.checkboxCheckedSubject$.next(checked);
93+
}
94+
95+
/**
96+
* Show a notification to the user
97+
* @param key expected values: 'expired' or 'error'
98+
*/
99+
showNotification(key) {
100+
const notificationTitle = this.translate.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.title');
101+
const notificationErrorMsg = this.translate.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.message.error');
102+
const notificationExpiredMsg = this.translate.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.message.expired');
103+
switch (key) {
104+
case 'expired':
105+
this.notificationsService.warning(notificationTitle, notificationExpiredMsg);
106+
break;
107+
case 'error':
108+
this.notificationsService.error(notificationTitle, notificationErrorMsg);
109+
break;
110+
default:
111+
console.warn(`Unimplemented notification '${key}' from reCaptcha service`);
112+
}
113+
}
114+
115+
/**
116+
* execute the captcha function
117+
*/
118+
executeRecaptcha() {
119+
this.googleRecaptchaService.executeRecaptcha();
120+
}
121+
}

src/app/core/google-recaptcha/google-recaptcha.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { ConfigurationProperty } from '../shared/configuration-property.model';
3131
import { getFirstCompletedRemoteData } from '../shared/operators';
3232

3333
export const CAPTCHA_COOKIE = '_GRECAPTCHA';
34+
export const CAPTCHA_REGISTRATION_NAME = 'google-recaptcha-registration';
35+
3436
export const CAPTCHA_NAME = 'google-recaptcha';
3537

3638
/**
@@ -105,7 +107,10 @@ export class GoogleRecaptchaService {
105107
tap(([recaptchaVersionRD, recaptchaModeRD, recaptchaKeyRD]) => {
106108

107109
if (
108-
this.cookieService.get('orejime-anonymous') && this.cookieService.get('orejime-anonymous')[CAPTCHA_NAME] &&
110+
this.cookieService.get('orejime-anonymous') && (
111+
this.cookieService.get('orejime-anonymous')[CAPTCHA_REGISTRATION_NAME] ||
112+
this.cookieService.get('klaro-anonymous')[CAPTCHA_NAME]
113+
) &&
109114
recaptchaKeyRD.hasSucceeded && recaptchaVersionRD.hasSucceeded &&
110115
isNotEmpty(recaptchaVersionRD.payload?.values) && isNotEmpty(recaptchaKeyRD.payload?.values)
111116
) {

0 commit comments

Comments
 (0)