diff --git a/src/app/app.component.html b/src/app/app.component.html index 4997c5280..37294ebc1 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,4 @@ + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 983562162..9f0c3c05f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -10,12 +10,13 @@ import { Router, RouterOutlet } from '@angular/router'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; import { ConfirmEmailComponent } from '@shared/components'; +import { CookieConsentComponent } from '@shared/components/cookie-consent/cookie-consent.component'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; @Component({ selector: 'osf-root', - imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent], + imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent, CookieConsentComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.html b/src/app/shared/components/cookie-consent/cookie-consent.component.html new file mode 100644 index 000000000..7d6f5baae --- /dev/null +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.html @@ -0,0 +1,14 @@ + + +
+ {{ message.detail }} +
+ +
+
+
+
diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.scss b/src/app/shared/components/cookie-consent/cookie-consent.component.scss new file mode 100644 index 000000000..6f2a7c8c4 --- /dev/null +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.scss @@ -0,0 +1,15 @@ +:host ::ng-deep .cookie-toast { + width: 900px; + max-width: min(92vw, 960px); + left: 50% !important; + transform: translateX(-50%) !important; +} + +:host ::ng-deep .cookie-toast .p-toast-message { + width: 100%; +} + +:host ::ng-deep .cookie-toast .p-toast-message .p-toast-message-content { + color: #fcf8e3; + width: 100%; +} diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts b/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts new file mode 100644 index 000000000..2c982b16c --- /dev/null +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts @@ -0,0 +1,84 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { MessageService } from 'primeng/api'; + +import { of } from 'rxjs'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CookieConsentService } from '../../services/cookie-consent/cookie-consent.service'; + +import { CookieConsentComponent } from './cookie-consent.component'; + +describe('CookieConsentComponent', () => { + let component: CookieConsentComponent; + let fixture: ComponentFixture; + let mockToastService: jest.Mocked; + let mockConsentService: jest.Mocked; + let mockTranslateService: jest.Mocked; + + beforeEach(async () => { + mockToastService = { + add: jest.fn(), + clear: jest.fn(), + } as unknown as jest.Mocked; + + mockConsentService = { + hasConsent: jest.fn(), + grantConsent: jest.fn(), + } as unknown as jest.Mocked; + + mockTranslateService = { + get: jest.fn(), + } as unknown as jest.Mocked; + + await TestBed.configureTestingModule({ + imports: [CookieConsentComponent], + providers: [ + { provide: MessageService, useValue: mockToastService }, + { provide: CookieConsentService, useValue: mockConsentService }, + { provide: TranslateService, useValue: mockTranslateService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CookieConsentComponent); + component = fixture.componentInstance; + }); + describe('ngAfterViewInit', () => { + it('should show toast if no consent', () => { + mockConsentService.hasConsent.mockReturnValue(false); + mockTranslateService.get.mockReturnValue(of('Please accept cookies')); + + component.ngAfterViewInit(); + + // wait for queueMicrotask to execute + return Promise.resolve().then(() => { + expect(mockTranslateService.get).toHaveBeenCalledWith('toast.cookie-consent.message'); + expect(mockToastService.add).toHaveBeenCalledWith({ + detail: 'Please accept cookies', + key: 'cookie', + sticky: true, + severity: 'warn', + closable: false, + }); + }); + }); + + it('should not show toast if consent already given', () => { + mockConsentService.hasConsent.mockReturnValue(true); + + component.ngAfterViewInit(); + + expect(mockTranslateService.get).not.toHaveBeenCalled(); + expect(mockToastService.add).not.toHaveBeenCalled(); + }); + }); + + describe('acceptCookies', () => { + it('should grant consent and clear toast', () => { + component.acceptCookies(); + expect(mockConsentService.grantConsent).toHaveBeenCalled(); + expect(mockToastService.clear).toHaveBeenCalledWith('cookie'); + }); + }); +}); diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.ts b/src/app/shared/components/cookie-consent/cookie-consent.component.ts new file mode 100644 index 000000000..35402f655 --- /dev/null +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.ts @@ -0,0 +1,43 @@ +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { MessageService, PrimeTemplate } from 'primeng/api'; +import { Button } from 'primeng/button'; +import { Toast } from 'primeng/toast'; + +import { AfterViewInit, Component, inject } from '@angular/core'; + +import { CookieConsentService } from '../../services/cookie-consent/cookie-consent.service'; + +@Component({ + selector: 'osf-cookie-consent', + standalone: true, + templateUrl: './cookie-consent.component.html', + styleUrls: ['./cookie-consent.component.scss'], + imports: [Toast, Button, PrimeTemplate, TranslateModule], +}) +export class CookieConsentComponent implements AfterViewInit { + private readonly toastService = inject(MessageService); + private readonly consentService = inject(CookieConsentService); + private readonly translateService = inject(TranslateService); + + ngAfterViewInit() { + if (!this.consentService.hasConsent()) { + this.translateService.get('toast.cookie-consent.message').subscribe((detail) => { + queueMicrotask(() => + this.toastService.add({ + detail, + key: 'cookie', + sticky: true, + severity: 'warn', + closable: false, + }) + ); + }); + } + } + + acceptCookies() { + this.consentService.grantConsent(); + this.toastService.clear('cookie'); + } +} diff --git a/src/app/shared/components/toast/toast.component.html b/src/app/shared/components/toast/toast.component.html index 2edcc0abe..41fa03355 100644 --- a/src/app/shared/components/toast/toast.component.html +++ b/src/app/shared/components/toast/toast.component.html @@ -1,4 +1,4 @@ - +
{{ message.summary | translate: message.data?.translationParams }}
diff --git a/src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts b/src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts new file mode 100644 index 000000000..84e0aa7d2 --- /dev/null +++ b/src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts @@ -0,0 +1,42 @@ +import { TestBed } from '@angular/core/testing'; + +import { CookieConsentService } from './cookie-consent.service'; + +describe('CookieConsentService', () => { + let service: CookieConsentService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CookieConsentService); + + const store: Record = {}; + jest.spyOn(localStorage, 'getItem').mockImplementation((key: string) => store[key] || null); + jest.spyOn(localStorage, 'setItem').mockImplementation((key: string, value: string) => { + store[key] = value; + }); + jest.spyOn(localStorage, 'removeItem').mockImplementation((key: string) => { + delete store[key]; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return false if no consent is stored', () => { + expect(service.hasConsent()).toBe(false); + }); + + it('should return true after consent is granted', () => { + service.grantConsent(); + expect(service.hasConsent()).toBe(true); + }); + + it('should remove consent when revoked', () => { + service.grantConsent(); + expect(service.hasConsent()).toBe(true); + + service.revokeConsent(); + expect(service.hasConsent()).toBe(false); + }); +}); diff --git a/src/app/shared/services/cookie-consent/cookie-consent.service.ts b/src/app/shared/services/cookie-consent/cookie-consent.service.ts new file mode 100644 index 000000000..196bf75af --- /dev/null +++ b/src/app/shared/services/cookie-consent/cookie-consent.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class CookieConsentService { + private consentKey = 'cookie-consent'; + + hasConsent(): boolean { + return localStorage.getItem(this.consentKey) === 'true'; + } + + grantConsent() { + localStorage.setItem(this.consentKey, 'true'); + } + + revokeConsent() { + localStorage.removeItem(this.consentKey); + } +} diff --git a/src/app/shared/services/toast.service.ts b/src/app/shared/services/toast.service.ts index 850fdef1a..da6ac62e1 100644 --- a/src/app/shared/services/toast.service.ts +++ b/src/app/shared/services/toast.service.ts @@ -9,14 +9,20 @@ export class ToastService { private messageService = inject(MessageService); showSuccess(summary: string, params?: unknown) { - this.messageService.add({ severity: 'success', summary, data: { translationParams: params } }); + this.messageService.add({ severity: 'success', summary, data: { translationParams: params }, key: 'osf' }); } showWarn(summary: string, params?: unknown) { - this.messageService.add({ severity: 'warn', summary, data: { translationParams: params } }); + this.messageService.add({ severity: 'warn', summary, data: { translationParams: params }, key: 'osf' }); } showError(summary: string, params?: unknown) { - this.messageService.add({ severity: 'error', summary, life: 5000, data: { translationParams: params } }); + this.messageService.add({ + severity: 'error', + summary, + life: 5000, + data: { translationParams: params }, + key: 'osf', + }); } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 4b5de450a..ec69e8971 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -184,6 +184,12 @@ "links": "Links", "institutions": "Institutions" }, + "toast": { + "cookie-consent": { + "message": "Notice: This website relies on cookies to help provide a better user experience. By clicking accept or continuing to use the site, you consent to our use of cookies. See our Privacy Policy and Cookie Use for more information.", + "accept": "Accept cookies" + } + }, "auth": { "common": { "email": "Email",