Skip to content

Commit 3563ebd

Browse files
author
FrancescoMauto
committed
[DSC-1786] [DSC-1786] add: first implementation of recaptcha for feedback form
1 parent a61e2ed commit 3563ebd

7 files changed

Lines changed: 261 additions & 15 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: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<div class="col-xs-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" for="email">{{ 'info.feedback.email-label' | translate }}&nbsp;</label>
@@ -39,9 +39,28 @@ <h1>{{ 'info.feedback.head' | translate }}</h1>
3939
</div>
4040
</div>
4141

42+
@if (feedbackRecaptchaEnabled && !isRecaptchaCookieAccepted()) {
43+
<ds-alert [type]="AlertTypeEnum.Warning">
44+
<p class="m-0" [innerHTML]="'info.feedback.google-recaptcha.must-accept-cookies' | translate"></p>
45+
<p class="m-0"><a href="javascript:void(0);" (click)="openCookieSettings()">{{ 'info.feedback.google-recaptcha.open-cookie-settings' | translate }}</a></p>
46+
</ds-alert>
47+
}
48+
49+
@if (isRecaptchaCookieAccepted() && (captchaVersion$ | async) === 'v2') {
50+
<div class="my-3">
51+
<ds-google-recaptcha [captchaMode]="(captchaMode$ | async)"
52+
(executeRecaptcha)="createFeedback($event)" (checkboxChecked)="onCheckboxChecked($event)"
53+
(showNotification)="showNotification($event)"></ds-google-recaptcha>
54+
</div>
55+
}
56+
4257
<div class="row mt-3">
43-
<div class="control-group col-sm-12 text-right">
44-
<button [dsBtnDisabled]="!feedbackForm.valid" class="btn btn-primary" name="submit" type="submit">{{ 'info.feedback.send' | translate }}</button>
58+
<div class="control-group col-sm-12 text-end">
59+
@if (!feedbackRecaptchaEnabled || (captchaVersion$ | async) !== 'v2') {
60+
<button [dsBtnDisabled]="!feedbackForm.valid || feedbackRecaptchaEnabled && !isRecaptchaCookieAccepted()" class="btn btn-primary" name="submit" type="button" (click)="createFeedback()">{{ 'info.feedback.send' | translate }}</button>
61+
} @else {
62+
<button [dsBtnDisabled]="!feedbackForm.valid || disableUntilChecked" class="btn btn-primary" name="submit" type="button" (click)="executeRecaptcha()">{{ 'info.feedback.send' | translate }}</button>
63+
}
4564
</div>
4665
</div>
4766
</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: 192 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { NgIf } from '@angular/common';
22
import {
33
Component,
44
Inject,
5+
OnDestroy,
56
OnInit,
7+
Optional,
68
} from '@angular/core';
79
import {
810
FormsModule,
@@ -15,36 +17,63 @@ 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 { ConfigurationDataService } from '../../../core/data/configuration-data.service';
61+
import { CookieService } from '../../../core/services/cookie.service';
62+
import { isNotEmpty } from '../../../shared/empty.util';
63+
import { AsyncPipe } from '@angular/common';
64+
import { KlaroService } from 'src/app/shared/cookies/klaro.service';
3665

3766
@Component({
3867
selector: 'ds-base-feedback-form',
3968
templateUrl: './feedback-form.component.html',
4069
styleUrls: ['./feedback-form.component.scss'],
4170
standalone: true,
42-
imports: [FormsModule, ReactiveFormsModule, NgIf, ErrorComponent, TranslateModule, BtnDisabledDirective],
71+
imports: [FormsModule, ReactiveFormsModule, NgIf, ErrorComponent, TranslateModule, BtnDisabledDirective, AlertComponent, GoogleRecaptchaComponent, AsyncPipe],
4372
})
4473
/**
4574
* Component displaying the contents of the Feedback Statement
4675
*/
47-
export class FeedbackFormComponent implements OnInit {
76+
export class FeedbackFormComponent implements OnInit, OnDestroy {
4877

4978
/**
5079
* Form builder created used from the feedback from
@@ -55,6 +84,31 @@ export class FeedbackFormComponent implements OnInit {
5584
page: [''],
5685
});
5786

87+
/**
88+
* Return true if the user completed the reCaptcha verification (checkbox mode)
89+
*/
90+
checkboxCheckedSubject$ = new BehaviorSubject<boolean>(false);
91+
92+
disableUntilChecked = true;
93+
94+
captchaVersion$: Observable<string>;
95+
96+
captchaMode$: Observable<string>;
97+
98+
/**
99+
* Whether reCAPTCHA verification is enabled for feedback
100+
*/
101+
feedbackRecaptchaEnabled = false;
102+
103+
/**
104+
* AlertType enumeration
105+
*/
106+
public AlertTypeEnum = AlertType;
107+
108+
subscriptions: Subscription[] = [];
109+
110+
readonly FEEDBACK_VERIFICATION_PROP_KEY = 'feedback.verification.enabled';
111+
58112
constructor(
59113
@Inject(NativeWindowService) protected _window: NativeWindowRef,
60114
public routeService: RouteService,
@@ -63,13 +117,20 @@ export class FeedbackFormComponent implements OnInit {
63117
protected translate: TranslateService,
64118
private feedbackDataService: FeedbackDataService,
65119
private authService: AuthService,
66-
private router: Router) {
120+
private router: Router,
121+
public googleRecaptchaService: GoogleRecaptchaService,
122+
private configService: ConfigurationDataService,
123+
private cookieService: CookieService,
124+
@Optional() public klaroService: KlaroService,
125+
) {
67126
}
68127

69128
/**
70129
* On init check if user is logged in and use its email if so
71130
*/
72131
ngOnInit() {
132+
this.captchaVersion$ = this.googleRecaptchaService.captchaVersion();
133+
this.captchaMode$ = this.googleRecaptchaService.captchaMode();
73134

74135
this.authService.getAuthenticatedUserFromStore().pipe(take(1)).subscribe((user: EPerson) => {
75136
if (user) {
@@ -85,14 +146,79 @@ export class FeedbackFormComponent implements OnInit {
85146
this.feedbackForm.patchValue({ page: relatedUrl });
86147
});
87148

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

90179
/**
91180
* Function to create the feedback from form values
92181
*/
93-
createFeedback(): void {
182+
createFeedback(captchaToken?: string): void {
183+
if (this.feedbackRecaptchaEnabled) {
184+
combineLatest([this.captchaVersion$, this.captchaMode$]).pipe(
185+
switchMap(([captchaVersion, captchaMode]) => {
186+
if (captchaVersion === 'v3') {
187+
return this.googleRecaptchaService.getRecaptchaToken('feedback');
188+
} else if (captchaVersion === 'v2' && captchaMode === 'checkbox') {
189+
return of(this.googleRecaptchaService.getRecaptchaTokenResponse());
190+
} else if (captchaVersion === 'v2' && captchaMode === 'invisible') {
191+
return of(captchaToken);
192+
} else {
193+
this.showNotification('error');
194+
return of(null);
195+
}
196+
}),
197+
take(1),
198+
).subscribe((token) => {
199+
if (isNotEmpty(token)) {
200+
this.submitFeedback(token);
201+
} else {
202+
this.showNotification('error');
203+
}
204+
});
205+
} else {
206+
this.submitFeedback();
207+
}
208+
}
209+
210+
/**
211+
* Send the feedback with the captcha token
212+
*/
213+
private submitFeedback(captcha?: string): void {
94214
const url = this.feedbackForm.value.page.replace(this._window.nativeWindow.origin, '');
95-
this.feedbackDataService.create(this.feedbackForm.value).pipe(getFirstCompletedRemoteData()).subscribe((response: RemoteData<NoContent>) => {
215+
const feedback = { ...this.feedbackForm.value };
216+
if (isNotEmpty(captcha)) {
217+
feedback.captcha = captcha;
218+
}
219+
this.feedbackDataService.create(feedback).pipe(
220+
getFirstCompletedRemoteData(),
221+
).subscribe((response: RemoteData<NoContent>) => {
96222
if (response.isSuccess) {
97223
this.notificationsService.success(this.translate.instant('info.feedback.create.success'));
98224
this.feedbackForm.reset();
@@ -101,4 +227,63 @@ export class FeedbackFormComponent implements OnInit {
101227
});
102228
}
103229

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

0 commit comments

Comments
 (0)