Skip to content

Commit 17f0c8d

Browse files
author
FrancescoMauto
committed
[DSC-1786] add: first implementation of recaptcha for feedback form
1 parent 4381651 commit 17f0c8d

7 files changed

Lines changed: 263 additions & 12 deletions

File tree

src/app/core/feedback/models/feedback.model.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export class Feedback extends DSpaceObject {
3030
@autoserialize
3131
public page: string;
3232

33+
/**
34+
* Google reCAPTCHA token for spam protection
35+
*/
36+
@autoserialize
37+
public captcha: string;
38+
3339
_links: {
3440
self: HALLink;
3541
};

src/app/info/feedback/feedback-form/feedback-form.component.html

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<div class="col-12 col-sm-12 col-md-9">
33
<h1>{{ 'info.feedback.head' | translate }}</h1>
44
<p>{{ 'info.feedback.info' | translate }}</p>
5-
<form [formGroup]="feedbackForm" (ngSubmit)="createFeedback()" class="col p-0">
5+
<form [formGroup]="feedbackForm" class="col p-0">
66
<div class="row mt-3">
77
<div class="control-group col-sm-12">
88
<label class="control-label form-label" for="email">{{ 'info.feedback.email-label' | translate }}&nbsp;</label>
@@ -49,9 +49,28 @@ <h1>{{ 'info.feedback.head' | translate }}</h1>
4949
</div>
5050
</div>
5151

52+
@if (feedbackRecaptchaEnabled && !isRecaptchaCookieAccepted()) {
53+
<ds-alert [type]="AlertTypeEnum.Warning">
54+
<p class="m-0" [innerHTML]="'info.feedback.google-recaptcha.must-accept-cookies' | translate"></p>
55+
<p class="m-0"><a href="javascript:void(0);" (click)="openCookieSettings()">{{ 'info.feedback.google-recaptcha.open-cookie-settings' | translate }}</a></p>
56+
</ds-alert>
57+
}
58+
59+
@if (isRecaptchaCookieAccepted() && (captchaVersion$ | async) === 'v2') {
60+
<div class="my-3">
61+
<ds-google-recaptcha [captchaMode]="(captchaMode$ | async)"
62+
(executeRecaptcha)="createFeedback($event)" (checkboxChecked)="onCheckboxChecked($event)"
63+
(showNotification)="showNotification($event)"></ds-google-recaptcha>
64+
</div>
65+
}
66+
5267
<div class="row mt-3">
5368
<div class="control-group col-sm-12 text-end">
54-
<button [dsBtnDisabled]="!feedbackForm.valid" class="btn btn-primary" name="submit" type="submit">{{ 'info.feedback.send' | translate }}</button>
69+
@if (!feedbackRecaptchaEnabled || (captchaVersion$ | async) !== 'v2') {
70+
<button [dsBtnDisabled]="!feedbackForm.valid || feedbackRecaptchaEnabled && !isRecaptchaCookieAccepted()" class="btn btn-primary" name="submit" type="button" (click)="createFeedback()">{{ 'info.feedback.send' | translate }}</button>
71+
} @else {
72+
<button [dsBtnDisabled]="!feedbackForm.valid || disableUntilChecked" class="btn btn-primary" name="submit" type="button" (click)="executeRecaptcha()">{{ 'info.feedback.send' | translate }}</button>
73+
}
5574
</div>
5675
</div>
5776
</form>

src/app/info/feedback/feedback-form/feedback-form.component.spec.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,26 @@ import { TranslateModule } from '@ngx-translate/core';
1414
import { of } from 'rxjs';
1515

1616
import { AuthService } from '../../../core/auth/auth.service';
17+
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
1718
import { FeedbackDataService } from '../../../core/feedback/feedback-data.service';
1819
import { Feedback } from '../../../core/feedback/models/feedback.model';
20+
import { GoogleRecaptchaService } from '../../../core/google-recaptcha/google-recaptcha.service';
21+
import { CookieService } from '../../../core/services/cookie.service';
1922
import { RouteService } from '../../../core/services/route.service';
2023
import { NativeWindowService } from '../../../core/services/window.service';
24+
import { AlertComponent } from '../../../shared/alert/alert.component';
2125
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
2226
import { ErrorComponent } from '../../../shared/error/error.component';
27+
import { GoogleRecaptchaComponent } from '../../../shared/google-recaptcha/google-recaptcha.component';
28+
import { CookieServiceMock } from '../../../shared/mocks/cookie.service.mock';
2329
import { NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref';
2430
import { RouterMock } from '../../../shared/mocks/router.mock';
2531
import { NotificationsService } from '../../../shared/notifications/notifications.service';
2632
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
2733
import { EPersonMock } from '../../../shared/testing/eperson.mock';
2834
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
2935
import { routeServiceStub } from '../../../shared/testing/route-service.stub';
36+
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
3037
import { FeedbackFormComponent } from './feedback-form.component';
3138

3239

@@ -45,6 +52,11 @@ describe('FeedbackFormComponent', () => {
4552
});
4653
const routerStub = new RouterMock();
4754

55+
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
56+
findByPropertyName: jasmine.createSpy('findByPropertyName'),
57+
});
58+
const confResponseDisabled$ = createSuccessfulRemoteDataObject$({ values: ['false'] });
59+
4860
beforeEach(waitForAsync(() => {
4961
TestBed.configureTestingModule({
5062
imports: [TranslateModule.forRoot(), FeedbackFormComponent, BtnDisabledDirective],
@@ -56,12 +68,18 @@ describe('FeedbackFormComponent', () => {
5668
{ provide: AuthService, useValue: authService },
5769
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
5870
{ provide: Router, useValue: routerStub },
71+
{ provide: ConfigurationDataService, useValue: configurationDataService },
72+
{ provide: CookieService, useValue: new CookieServiceMock() },
73+
{ provide: GoogleRecaptchaService, useValue: {} },
5974
],
6075
schemas: [NO_ERRORS_SCHEMA],
61-
}).overrideComponent(FeedbackFormComponent, { remove: { imports: [ErrorComponent] } }).compileComponents();
76+
}).overrideComponent(FeedbackFormComponent, {
77+
remove: { imports: [ErrorComponent, GoogleRecaptchaComponent, AlertComponent] },
78+
}).compileComponents();
6279
}));
6380

6481
beforeEach(() => {
82+
configurationDataService.findByPropertyName.and.returnValue(confResponseDisabled$);
6583
fixture = TestBed.createComponent(FeedbackFormComponent);
6684
component = fixture.componentInstance;
6785
de = fixture.debugElement;

src/app/info/feedback/feedback-form/feedback-form.component.ts

Lines changed: 194 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import {
33
Component,
44
Inject,
5+
OnDestroy,
56
OnInit,
7+
Optional,
68
} from '@angular/core';
79
import {
810
FormsModule,
@@ -15,41 +17,71 @@ import {
1517
TranslateModule,
1618
TranslateService,
1719
} from '@ngx-translate/core';
18-
import { take } from 'rxjs/operators';
20+
import {
21+
BehaviorSubject,
22+
combineLatest,
23+
Observable,
24+
of,
25+
Subscription,
26+
} from 'rxjs';
27+
import {
28+
map,
29+
startWith,
30+
switchMap,
31+
take,
32+
} from 'rxjs/operators';
1933

2034
import { getHomePageRoute } from '../../../app-routing-paths';
2135
import { AuthService } from '../../../core/auth/auth.service';
2236
import { RemoteData } from '../../../core/data/remote-data';
2337
import { EPerson } from '../../../core/eperson/models/eperson.model';
2438
import { FeedbackDataService } from '../../../core/feedback/feedback-data.service';
39+
import {
40+
CAPTCHA_NAME,
41+
GoogleRecaptchaService,
42+
} from '../../../core/google-recaptcha/google-recaptcha.service';
2543
import { RouteService } from '../../../core/services/route.service';
2644
import {
2745
NativeWindowRef,
2846
NativeWindowService,
2947
} from '../../../core/services/window.service';
3048
import { NoContent } from '../../../core/shared/NoContent.model';
31-
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
49+
import {
50+
getFirstCompletedRemoteData,
51+
getFirstSucceededRemoteDataPayload,
52+
} from '../../../core/shared/operators';
3253
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
3354
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
55+
import { AlertComponent } from '../../../shared/alert/alert.component';
56+
import { AlertType } from '../../../shared/alert/alert-type';
3457
import { ErrorComponent } from '../../../shared/error/error.component';
58+
import { GoogleRecaptchaComponent } from '../../../shared/google-recaptcha/google-recaptcha.component';
3559
import { NotificationsService } from '../../../shared/notifications/notifications.service';
60+
import { OrejimeService } from '../../../shared/cookies/orejime.service';
61+
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
62+
import { CookieService } from '../../../core/services/cookie.service';
63+
import { isNotEmpty } from '../../../shared/empty.util';
64+
import { AsyncPipe } from '@angular/common';
3665

3766
@Component({
3867
selector: 'ds-base-feedback-form',
3968
templateUrl: './feedback-form.component.html',
4069
styleUrls: ['./feedback-form.component.scss'],
4170
imports: [
71+
AlertComponent,
4272
BtnDisabledDirective,
4373
ErrorComponent,
4474
FormsModule,
75+
GoogleRecaptchaComponent,
4576
ReactiveFormsModule,
4677
TranslateModule,
78+
AsyncPipe,
4779
],
4880
})
4981
/**
5082
* Component displaying the contents of the Feedback Statement
5183
*/
52-
export class FeedbackFormComponent implements OnInit {
84+
export class FeedbackFormComponent implements OnInit, OnDestroy {
5385

5486
/**
5587
* Form builder created used from the feedback from
@@ -60,6 +92,31 @@ export class FeedbackFormComponent implements OnInit {
6092
page: [''],
6193
});
6294

95+
/**
96+
* Return true if the user completed the reCaptcha verification (checkbox mode)
97+
*/
98+
checkboxCheckedSubject$ = new BehaviorSubject<boolean>(false);
99+
100+
disableUntilChecked = true;
101+
102+
captchaVersion$: Observable<string>;
103+
104+
captchaMode$: Observable<string>;
105+
106+
/**
107+
* Whether reCAPTCHA verification is enabled for feedback
108+
*/
109+
feedbackRecaptchaEnabled = false;
110+
111+
/**
112+
* AlertType enumeration
113+
*/
114+
public AlertTypeEnum = AlertType;
115+
116+
subscriptions: Subscription[] = [];
117+
118+
readonly FEEDBACK_VERIFICATION_PROP_KEY = 'feedback.verification.enabled';
119+
63120
constructor(
64121
@Inject(NativeWindowService) protected _window: NativeWindowRef,
65122
public routeService: RouteService,
@@ -68,13 +125,20 @@ export class FeedbackFormComponent implements OnInit {
68125
protected translate: TranslateService,
69126
private feedbackDataService: FeedbackDataService,
70127
private authService: AuthService,
71-
private router: Router) {
128+
private router: Router,
129+
public googleRecaptchaService: GoogleRecaptchaService,
130+
private configService: ConfigurationDataService,
131+
private cookieService: CookieService,
132+
@Optional() public orejimeService: OrejimeService,
133+
) {
72134
}
73135

74136
/**
75137
* On init check if user is logged in and use its email if so
76138
*/
77139
ngOnInit() {
140+
this.captchaVersion$ = this.googleRecaptchaService.captchaVersion();
141+
this.captchaMode$ = this.googleRecaptchaService.captchaMode();
78142

79143
this.authService.getAuthenticatedUserFromStore().pipe(take(1)).subscribe((user: EPerson) => {
80144
if (user) {
@@ -90,14 +154,79 @@ export class FeedbackFormComponent implements OnInit {
90154
this.feedbackForm.patchValue({ page: relatedUrl });
91155
});
92156

157+
this.subscriptions.push(this.configService.findByPropertyName(this.FEEDBACK_VERIFICATION_PROP_KEY).pipe(
158+
getFirstSucceededRemoteDataPayload(),
159+
map((res) => res?.values[0].toLowerCase() === 'true'),
160+
take(1),
161+
).subscribe((enabled: boolean) => {
162+
this.feedbackRecaptchaEnabled = enabled;
163+
if (enabled) {
164+
this.refreshRecaptchaScript();
165+
this.subscriptions.push(this.disableUntilCheckedFcn().subscribe((res) => {
166+
this.disableUntilChecked = res;
167+
}));
168+
}
169+
}));
170+
}
171+
172+
ngOnDestroy(): void {
173+
this.subscriptions.forEach((sub: Subscription) => sub.unsubscribe());
174+
}
175+
176+
/**
177+
* Try to load the reCAPTCHA script if not already loaded via registration config
178+
*/
179+
private refreshRecaptchaScript(): void {
180+
if (this._window.nativeWindow.refreshCaptchaScript) {
181+
this._window.nativeWindow.refreshCaptchaScript();
182+
} else {
183+
this.googleRecaptchaService.loadRecaptchaProperties().subscribe();
184+
}
93185
}
94186

95187
/**
96188
* Function to create the feedback from form values
97189
*/
98-
createFeedback(): void {
190+
createFeedback(captchaToken?: string): void {
191+
if (this.feedbackRecaptchaEnabled) {
192+
combineLatest([this.captchaVersion$, this.captchaMode$]).pipe(
193+
switchMap(([captchaVersion, captchaMode]) => {
194+
if (captchaVersion === 'v3') {
195+
return this.googleRecaptchaService.getRecaptchaToken('feedback');
196+
} else if (captchaVersion === 'v2' && captchaMode === 'checkbox') {
197+
return of(this.googleRecaptchaService.getRecaptchaTokenResponse());
198+
} else if (captchaVersion === 'v2' && captchaMode === 'invisible') {
199+
return of(captchaToken);
200+
} else {
201+
this.showNotification('error');
202+
return of(null);
203+
}
204+
}),
205+
take(1),
206+
).subscribe((token) => {
207+
if (isNotEmpty(token)) {
208+
this.submitFeedback(token);
209+
} else {
210+
this.showNotification('error');
211+
}
212+
});
213+
} else {
214+
this.submitFeedback();
215+
}
216+
}
217+
218+
/**
219+
* Send the feedback with the captcha token
220+
*/
221+
private submitFeedback(captcha?: string): void {
99222
const url = this.feedbackForm.value.page.replace(this._window.nativeWindow.origin, '');
100-
this.feedbackDataService.create(this.feedbackForm.value).pipe(getFirstCompletedRemoteData()).subscribe((response: RemoteData<NoContent>) => {
223+
const feedback = { ...this.feedbackForm.value };
224+
if (isNotEmpty(captcha)) {
225+
feedback.captcha = captcha;
226+
}
227+
this.feedbackDataService.create(feedback).pipe(
228+
getFirstCompletedRemoteData(),
229+
).subscribe((response: RemoteData<NoContent>) => {
101230
if (response.isSuccess) {
102231
this.notificationsService.success(this.translate.instant('info.feedback.create.success'));
103232
this.feedbackForm.reset();
@@ -106,4 +235,63 @@ export class FeedbackFormComponent implements OnInit {
106235
});
107236
}
108237

238+
/**
239+
* Return true if the user has accepted the required cookies for reCaptcha
240+
*/
241+
isRecaptchaCookieAccepted(): boolean {
242+
const orejimeAnonymousCookie = this.cookieService.get('orejime-anonymous');
243+
return isNotEmpty(orejimeAnonymousCookie) ? orejimeAnonymousCookie[CAPTCHA_NAME] : false;
244+
}
245+
246+
/**
247+
* Return true if the user has not completed the reCaptcha verification (checkbox mode)
248+
*/
249+
disableUntilCheckedFcn(): Observable<boolean> {
250+
const checked$ = this.checkboxCheckedSubject$.asObservable();
251+
return combineLatest([this.captchaVersion$, this.captchaMode$, checked$]).pipe(
252+
switchMap(([captchaVersion, captchaMode, checked]) =>
253+
captchaVersion === 'v2' && captchaMode === 'checkbox' ? of(!checked) : of(false),
254+
),
255+
startWith(true),
256+
);
257+
}
258+
259+
onCheckboxChecked(checked: boolean) {
260+
this.checkboxCheckedSubject$.next(checked);
261+
}
262+
263+
/**
264+
* execute the captcha function for v2 invisible
265+
*/
266+
executeRecaptcha() {
267+
this.googleRecaptchaService.executeRecaptcha();
268+
}
269+
270+
/**
271+
* Open the cookie settings dialog
272+
*/
273+
openCookieSettings(): void {
274+
if (this.orejimeService) {
275+
this.orejimeService.showSettings();
276+
}
277+
}
278+
279+
/**
280+
* Show a notification to the user
281+
*/
282+
showNotification(key: string) {
283+
const notificationTitle = this.translate.instant('info.feedback.google-recaptcha.notification.title');
284+
const notificationErrorMsg = this.translate.instant('info.feedback.google-recaptcha.notification.message.error');
285+
const notificationExpiredMsg = this.translate.instant('info.feedback.google-recaptcha.notification.message.expired');
286+
switch (key) {
287+
case 'expired':
288+
this.notificationsService.warning(notificationTitle, notificationExpiredMsg);
289+
break;
290+
case 'error':
291+
this.notificationsService.error(notificationTitle, notificationErrorMsg);
292+
break;
293+
default:
294+
console.warn(`Unimplemented notification '${key}' from reCaptcha service`);
295+
}
296+
}
109297
}

0 commit comments

Comments
 (0)