diff --git a/package.json b/package.json index 9f509966f1..ae4abd2e41 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "json5": "^2.1.0", "jsonschema": "1.2.2", "jwt-decode": "^2.2.0", + "klaro": "^0.6.3", "moment": "^2.22.1", "morgan": "^1.9.1", "ng-mocks": "^8.1.0", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index fae2df3220..43ae0534ad 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,11 +1,11 @@ -import { delay, map, distinctUntilChanged } from 'rxjs/operators'; +import { delay, map, distinctUntilChanged, filter, take } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, - OnInit, + OnInit, Optional, ViewEncapsulation } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; @@ -31,8 +31,8 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { environment } from '../environments/environment'; import { models } from './core/core.module'; import { LocaleService } from './core/locale/locale.service'; - -export const LANG_COOKIE = 'language_cookie'; +import { hasValue } from './shared/empty.util'; +import { KlaroService } from './shared/cookies/klaro.service'; @Component({ selector: 'ds-app', @@ -69,8 +69,10 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService, - private localeService: LocaleService + private localeService: LocaleService, + @Optional() private cookiesService: KlaroService ) { + /* Use models object so all decorators are actually called */ this.models = models; // Load all the languages that are defined as active from the config file @@ -91,6 +93,7 @@ export class AppComponent implements OnInit, AfterViewInit { console.info(environment); } this.storeCSSVariables(); + } ngOnInit() { @@ -98,6 +101,12 @@ export class AppComponent implements OnInit, AfterViewInit { map((isBlocking: boolean) => isBlocking === false), distinctUntilChanged() ); + this.isNotAuthBlocking$ + .pipe( + filter((notBlocking: boolean) => notBlocking), + take(1) + ).subscribe(() => this.initializeKlaro()); + const env: string = environment.production ? 'Production' : 'Development'; const color: string = environment.production ? 'red' : 'green'; console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); @@ -158,4 +167,9 @@ export class AppComponent implements OnInit, AfterViewInit { ); } + private initializeKlaro() { + if (hasValue(this.cookiesService)) { + this.cookiesService.initialize() + } + } } diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 528889cd83..e3f367c8bf 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -382,6 +382,14 @@ export abstract class DataService implements UpdateDa ); } + createPatchFromCache(object: T): Observable { + const oldVersion$ = this.findByHref(object._links.self.href); + return oldVersion$.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((oldVersion: T) => this.comparator.diff(oldVersion, object))); + } + /** * Send a PUT request for the specified object * @@ -410,18 +418,16 @@ export abstract class DataService implements UpdateDa * @param {DSpaceObject} object The given object */ update(object: T): Observable> { - const oldVersion$ = this.findByHref(object._links.self.href); - return oldVersion$.pipe( - getSucceededRemoteData(), - getRemoteDataPayload(), - mergeMap((oldVersion: T) => { - const operations = this.comparator.diff(oldVersion, object); - if (isNotEmpty(operations)) { - this.objectCache.addPatch(object._links.self.href, operations); + return this.createPatchFromCache(object) + .pipe( + mergeMap((operations: Operation[]) => { + if (isNotEmpty(operations)) { + this.objectCache.addPatch(object._links.self.href, operations); + } + return this.findByHref(object._links.self.href); } - return this.findByHref(object._links.self.href); - } - )); + ) + ); } /** diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index ade5c1f864..315fc02833 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -10,7 +10,7 @@ import { Observable, of as observableOf, combineLatest } from 'rxjs'; import { map, take, flatMap } from 'rxjs/operators'; import { NativeWindowService, NativeWindowRef } from '../services/window.service'; -export const LANG_COOKIE = 'language_cookie'; +export const LANG_COOKIE = 'dsLanguage'; /** * This enum defines the possible origin of the languages diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index a9256fbb7f..3abb9bceed 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -184,4 +184,25 @@ export class DSpaceObject extends ListableObject implements CacheableObject { getRenderTypes(): Array> { return [this.constructor as GenericConstructor]; } + + setMetadata(key: string, language?: string, ...values: string[]) { + const mdValues: MetadataValue[] = values.map((value: string, index: number) => { + const md = new MetadataValue(); + md.value = value; + md.authority = null; + md.confidence = -1; + md.language = language || null; + md.place = index; + return md; + }); + if (hasNoValue(this.metadata)) { + this.metadata = Object.create({}); + } + this.metadata[key] = mdValues; + } + + removeMetadata(key: string) { + delete this.metadata[key]; + } + } diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index fec75b2fd3..6d30302f80 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -1,10 +1,21 @@ diff --git a/src/app/footer/footer.component.scss b/src/app/footer/footer.component.scss index 51201774d5..fb51fc258d 100644 --- a/src/app/footer/footer.component.scss +++ b/src/app/footer/footer.component.scss @@ -4,15 +4,37 @@ $footer-padding: $spacer * 1.5; $footer-logo-height: 55px; .footer { - background-color: $footer-bg; - border-top: $footer-border; - text-align: center; - padding: $footer-padding; + background-color: $footer-bg; + border-top: $footer-border; + text-align: center; + padding: $footer-padding; + padding-bottom: $spacer; - p { - margin: 0; - } - img { - height: $footer-logo-height; - } + p { + margin: 0; + } + + img { + height: $footer-logo-height; + } + + ul { + padding-top: $spacer * 0.5; + + li { + display: inline-flex; + a { + padding: 0 $spacer/2; + color: inherit + } + + &:not(:last-child) { + &:after { + content: ''; + border-right: 1px map-get($theme-colors, secondary) solid; + } + + } + } + } } diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index 94b239d204..6ece2cf08b 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -1,4 +1,6 @@ -import { Component } from '@angular/core'; +import { Component, Optional } from '@angular/core'; +import { hasValue } from '../shared/empty.util'; +import { KlaroService } from '../shared/cookies/klaro.service'; @Component({ selector: 'ds-footer', @@ -6,7 +8,15 @@ import { Component } from '@angular/core'; templateUrl: 'footer.component.html' }) export class FooterComponent { - dateObj: number = Date.now(); + constructor(@Optional() private cookies: KlaroService) { + } + + showCookieSettings() { + if (hasValue(this.cookies)) { + this.cookies.showSettings(); + } + return false; + } } diff --git a/src/app/shared/cookies/browser-klaro.service.spec.ts b/src/app/shared/cookies/browser-klaro.service.spec.ts new file mode 100644 index 0000000000..2654dd65c7 --- /dev/null +++ b/src/app/shared/cookies/browser-klaro.service.spec.ts @@ -0,0 +1,237 @@ +import { TestBed } from '@angular/core/testing'; + +import { BrowserKlaroService, COOKIE_MDFIELD } from './browser-klaro.service'; +import { getMockTranslateService } from '../mocks/translate.service.mock'; +import { of as observableOf } from 'rxjs' +import { RestResponse } from '../../core/cache/response.models'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { TranslateService } from '@ngx-translate/core'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { CookieService } from '../../core/services/cookie.service'; +import { getTestScheduler } from 'jasmine-marbles'; +import { MetadataValue } from '../../core/shared/metadata.models'; +import { cloneDeep } from 'lodash'; + +describe('BrowserKlaroService', () => { + let translateService; + let ePersonService; + let authService; + let cookieService; + + let user; + let service: BrowserKlaroService; + + let mockConfig; + let appName; + let purpose; + let testKey; + + beforeEach(() => { + user = new EPerson(); + + translateService = getMockTranslateService(); + ePersonService = jasmine.createSpyObj('ePersonService', { + createPatchFromCache: observableOf([]), + patch: observableOf(new RestResponse(true, 200, 'Ok')) + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(user) + }); + cookieService = jasmine.createSpyObj('cookieService', { + get: '{%22token_item%22:true%2C%22impersonation%22:true%2C%22redirect%22:true%2C%22language%22:true%2C%22klaro%22:true%2C%22has_agreed_end_user%22:true%2C%22google-analytics%22:true}', + set: () => { + /* empty */ + } + }); + + TestBed.configureTestingModule({ + providers: [ + BrowserKlaroService, + { + provide: TranslateService, + useValue: translateService + }, + { + provide: EPersonDataService, + useValue: ePersonService, + }, + { + provide: AuthService, + useValue: authService + }, + { + provide: CookieService, + useValue: cookieService + } + ] + }); + service = TestBed.get(BrowserKlaroService); + appName = 'testName'; + purpose = 'test purpose'; + testKey = 'this.is.a.fake.message.key'; + + mockConfig = { + translations: { + en: { + purposes: {}, + test: { + testeritis: testKey + } + } + }, + apps: [{ + name: appName, + purposes: [purpose] + }], + + }; + + service.klaroConfig = mockConfig; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('initialize with user', () => { + beforeEach(() => { + spyOn((service as any), 'getUser$').and.returnValue(observableOf(user)); + translateService.get.and.returnValue(observableOf('loading...')); + spyOn(service, 'addAppMessages'); + spyOn((service as any), 'initializeUser'); + spyOn(service, 'translateConfiguration'); + }); + it('to call the initialize user method and other methods', () => { + service.initialize(); + expect((service as any).initializeUser).toHaveBeenCalledWith(user); + expect(service.addAppMessages).toHaveBeenCalled(); + expect(service.translateConfiguration).toHaveBeenCalled(); + }); + }); + + describe('to not call the initialize user method, but the other methods', () => { + beforeEach(() => { + spyOn((service as any), 'getUser$').and.returnValue(observableOf(undefined)); + translateService.get.and.returnValue(observableOf('loading...')); + spyOn(service, 'addAppMessages'); + spyOn((service as any), 'initializeUser'); + spyOn(service, 'translateConfiguration'); + }); + it('to call all ', () => { + service.initialize(); + expect((service as any).initializeUser).not.toHaveBeenCalledWith(user); + expect(service.addAppMessages).toHaveBeenCalled(); + expect(service.translateConfiguration).toHaveBeenCalled(); + }); + }); + + it('addAppMessages', () => { + service.addAppMessages(); + expect(mockConfig.translations.en[appName]).toBeDefined(); + expect(mockConfig.translations.en.purposes[purpose]).toBeDefined(); + }); + + it('translateConfiguration', () => { + service.translateConfiguration(); + expect((service as any).translateService.instant).toHaveBeenCalledWith(testKey); + }); + + describe('initializeUser when there is a metadata field value', () => { + beforeEach(() => { + user.setMetadata(COOKIE_MDFIELD, undefined, '{}'); + spyOn(service, 'restoreSettingsForUsers'); + }); + + it('initializeUser', () => { + (service as any).initializeUser(user); + expect(service.restoreSettingsForUsers).toHaveBeenCalledWith(user); + }); + }); + + describe('initializeUser when there is no metadata field value but there is an anonymous cookie', () => { + const cookie = '{test: \'testt\'}'; + beforeEach(() => { + (service as any).cookieService.get.and.returnValue(cookie); + spyOn(service, 'updateSettingsForUsers'); + }); + + it('initializeUser', () => { + (service as any).initializeUser(user); + expect((service as any).cookieService.set).toHaveBeenCalledWith(service.getStorageName(user.uuid), cookie) + expect(service.updateSettingsForUsers).toHaveBeenCalledWith(user); + }); + }); + + describe('getUser$ when there is no one authenticated', () => { + beforeEach(() => { + (service as any).authService.isAuthenticated.and.returnValue(observableOf(false)); + }); + it('should return undefined', () => { + getTestScheduler().expectObservable((service as any).getUser$()).toBe('(a|)', { a: undefined }); + }); + }); + + describe('getUser$ when there someone is authenticated', () => { + beforeEach(() => { + (service as any).authService.isAuthenticated.and.returnValue(observableOf(true)); + (service as any).authService.getAuthenticatedUserFromStore.and.returnValue(observableOf(user)); + }); + it('should return the user', () => { + getTestScheduler().expectObservable((service as any).getUser$()).toBe('(a|)', { a: user }); + }); + }); + + describe('getSettingsForUser', () => { + const cookieConsentString = '{test: \'testt\'}'; + beforeEach(() => { + user.metadata = {}; + user.metadata[COOKIE_MDFIELD] = [Object.assign(new MetadataValue(), { value: cookieConsentString })]; + spyOn(JSON, 'parse'); + }); + it('should return the cookie consents object', () => { + service.getSettingsForUser(user); + expect(JSON.parse).toHaveBeenCalledWith(cookieConsentString); + }); + }); + + describe('setSettingsForUser when there are changes', () => { + const cookieConsent = { test: 'testt' }; + const cookieConsentString = '{test: \'testt\'}'; + const operation = { op: 'add', path: 'metadata/dc.agreements.cookie', value: cookieConsentString }; + let updatedUser; + + beforeEach(() => { + updatedUser = cloneDeep(user); + + spyOn(updatedUser, 'setMetadata'); + spyOn(JSON, 'stringify').and.returnValue(cookieConsentString); + ePersonService.createPatchFromCache.and.returnValue(observableOf([operation])) + }); + it('should call patch on the data service', () => { + service.setSettingsForUser(updatedUser, cookieConsent); + expect(updatedUser.setMetadata).toHaveBeenCalledWith(COOKIE_MDFIELD, undefined, cookieConsentString); + expect(ePersonService.patch).toHaveBeenCalledWith(updatedUser, [operation]) + }); + }); + + describe('setSettingsForUser when there are no changes', () => { + const cookieConsent = { test: 'testt' }; + const cookieConsentString = '{test: \'testt\'}'; + let updatedUser; + + beforeEach(() => { + updatedUser = cloneDeep(user); + + spyOn(updatedUser, 'setMetadata'); + spyOn(JSON, 'stringify').and.returnValue(cookieConsentString); + ePersonService.createPatchFromCache.and.returnValue(observableOf([])) + }); + it('should not call patch on the data service', () => { + service.setSettingsForUser(updatedUser, cookieConsent); + expect(updatedUser.setMetadata).toHaveBeenCalledWith(COOKIE_MDFIELD, undefined, cookieConsentString); + expect(ePersonService.patch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts new file mode 100644 index 0000000000..b3488105f4 --- /dev/null +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -0,0 +1,257 @@ +import { Injectable } from '@angular/core'; +import * as Klaro from 'klaro' +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { AuthService } from '../../core/auth/auth.service'; +import { TranslateService } from '@ngx-translate/core'; +import { environment } from '../../../environments/environment'; +import { switchMap, take } from 'rxjs/operators'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { KlaroService } from './klaro.service'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { CookieService } from '../../core/services/cookie.service'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { cloneDeep, debounce } from 'lodash'; +import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration'; +import { Operation } from 'fast-json-patch'; + +/** + * Metadata field to store a user's cookie consent preferences in + */ +export const COOKIE_MDFIELD = 'dspace.agreements.cookies'; + +/** + * Prefix key for app title messages + */ +const cookieNameMessagePrefix = 'cookies.consent.app.title.'; + +/** + * Prefix key for app description messages + */ +const cookieDescriptionMessagePrefix = 'cookies.consent.app.description.'; + +/** + * Prefix key for app purpose messages + */ +const cookiePurposeMessagePrefix = 'cookies.consent.purpose.'; + +/** + * Update request debounce in ms + */ +const updateDebounce = 300; +/** + * Browser implementation for the KlaroService, representing a service for handling Klaro consent preferences and UI + */ +@Injectable() +export class BrowserKlaroService extends KlaroService { + /** + * Initial Klaro configuration + */ + klaroConfig = klaroConfiguration; + + constructor( + private translateService: TranslateService, + private authService: AuthService, + private ePersonService: EPersonDataService, + private cookieService: CookieService) { + super(); + } + /** + * Initializes the service: + * - Retrieves the current authenticated user + * - Checks if the translation service is ready + * - Initialize configuration for users + * - Add and translate klaro configuration messages + */ + initialize() { + this.translateService.setDefaultLang(environment.defaultLanguage); + + const user$: Observable = this.getUser$(); + + const translationServiceReady$ = this.translateService.get('loading.default').pipe(take(1)); + + observableCombineLatest(user$, translationServiceReady$) + .subscribe(([user, translation]: [EPerson, string]) => { + user = cloneDeep(user); + + if (hasValue(user)) { + this.initializeUser(user); + } + + /** + * Add all message keys for apps and purposes + */ + this.addAppMessages(); + + /** + * Subscribe on a message to make sure the translation service is ready + * Translate all keys in the translation section of the configuration + * Show the configuration if the configuration has not been confirmed + */ + this.translateConfiguration(); + Klaro.renderKlaro(this.klaroConfig, false); + Klaro.initialize(); + }); + + } + + /** + * Initialize configuration for the logged in user + * @param user The authenticated user + */ + private initializeUser(user: EPerson) { + this.klaroConfig.callback = debounce((consent, app) => this.updateSettingsForUsers(user), updateDebounce); + this.klaroConfig.storageName = this.getStorageName(user.uuid); + + const anonCookie = this.cookieService.get(ANONYMOUS_STORAGE_NAME_KLARO); + if (hasValue(this.getSettingsForUser(user))) { + this.restoreSettingsForUsers(user); + } else if (hasValue(anonCookie)) { + this.cookieService.set(this.getStorageName(user.uuid), anonCookie); + this.updateSettingsForUsers(user); + } + } + + /** + * Retrieves the currently logged in user + * Returns undefined when no one is logged in + */ + private getUser$() { + return this.authService.isAuthenticated() + .pipe( + take(1), + switchMap((loggedIn: boolean) => { + if (loggedIn) { + return this.authService.getAuthenticatedUserFromStore(); + } + return observableOf(undefined); + }), + take(1) + ); + } + + /** + * Create a title translation key + * @param title + */ + private getTitleTranslation(title: string) { + return cookieNameMessagePrefix + title; + } + + /** + * Create a description translation key + * @param description + */ + private getDescriptionTranslation(description: string) { + return cookieDescriptionMessagePrefix + description; + } + + /** + * Create a purpose translation key + * @param purpose + */ + private getPurposeTranslation(purpose: string) { + return cookiePurposeMessagePrefix + purpose; + } + + /** + * Show the cookie consent form + */ + showSettings() { + Klaro.show(this.klaroConfig); + } + + /** + * Add message keys for all apps and purposes + */ + addAppMessages() { + this.klaroConfig.apps.forEach((app) => { + this.klaroConfig.translations.en[app.name] = { title: this.getTitleTranslation(app.name), description: this.getDescriptionTranslation(app.name) }; + app.purposes.forEach((purpose) => { + this.klaroConfig.translations.en.purposes[purpose] = this.getPurposeTranslation(purpose); + }) + }); + } + + /** + * Translate the translation section from the Klaro configuration + */ + translateConfiguration() { + /** + * Make sure the fallback language is english + */ + this.translateService.setDefaultLang(environment.defaultLanguage); + + this.translate(this.klaroConfig.translations.en); + } + + /** + * Translate string values in an object + * @param object The object containing translation keys + */ + private translate(object) { + if (typeof (object) === 'string') { + return this.translateService.instant(object); + } + Object.entries(object).forEach(([key, value]: [string, any]) => { + object[key] = this.translate(value); + }); + return object; + } + + /** + * Retrieves the stored Klaro consent settings for a user + * @param user The user to resolve the consent for + */ + getSettingsForUser(user: EPerson) { + const mdValue = user.firstMetadataValue(COOKIE_MDFIELD); + return hasValue(mdValue) ? JSON.parse(mdValue) : undefined; + } + + /** + * Stores the Klaro consent settings for a user in a metadata field + * @param user The user to save the settings for + * @param config The consent settings for the user to save + */ + setSettingsForUser(user: EPerson, config: object) { + if (isNotEmpty(config)) { + user.setMetadata(COOKIE_MDFIELD, undefined, JSON.stringify(config)); + } else { + user.removeMetadata(COOKIE_MDFIELD); + } + this.ePersonService.createPatchFromCache(user) + .pipe( + take(1), + switchMap((operations: Operation[]) => { + if (isNotEmpty(operations)) { + return this.ePersonService.patch(user, operations) + } + return observableOf(undefined) + } + ) + ).subscribe(); + } + + /** + * Restores the users consent settings cookie based on the user's stored consent settings + * @param user The user to save the settings for + */ + restoreSettingsForUsers(user: EPerson) { + this.cookieService.set(this.getStorageName(user.uuid), this.getSettingsForUser(user)); + } + + /** + * Stores the consent settings for a user based on the current cookie for this user + * @param user + */ + updateSettingsForUsers(user: EPerson) { + this.setSettingsForUser(user, this.cookieService.get(this.getStorageName(user.uuid))) + } + + /** + * Create the storage name for klaro cookies based on the user's identifier + * @param identifier The user's uuid + */ + getStorageName(identifier: string) { + return 'klaro-' + identifier + } +} diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts new file mode 100644 index 0000000000..f57146c674 --- /dev/null +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -0,0 +1,159 @@ +import { TOKENITEM } from '../../core/auth/models/auth-token-info.model'; +import { IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; +import { LANG_COOKIE } from '../../core/locale/locale.service'; + +/** + * Cookie for has_agreed_end_user + */ +export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; + +/** + * Storage name used to store klaro cookie + */ +export const ANONYMOUS_STORAGE_NAME_KLARO = 'klaro-anonymous'; + +/** + * Klaro configuration + * For more information see https://kiprotect.com/docs/klaro/annotated-config + */ +export const klaroConfiguration: any = { + storageName: ANONYMOUS_STORAGE_NAME_KLARO, + + privacyPolicy: '/info/privacy', + + /* + Setting 'hideLearnMore' to 'true' will hide the "learn more / customize" link in + the consent notice. We strongly advise against using this under most + circumstances, as it keeps the user from customizing his/her consent choices. + */ + hideLearnMore: false, + + /* + Setting 'acceptAll' to 'true' will show an "accept all" button in the notice and + modal, which will enable all third-party apps if the user clicks on it. If set + to 'false', there will be an "accept" button that will only enable the apps that + are enabled in the consent modal. + */ + acceptAll: true, + + /* + You can also set a custom expiration time for the Klaro cookie. By default, it + will expire after 30 days. Only relevant if 'storageMethod' is set to 'cookie'. + */ + cookieExpiresAfterDays: 365, + + htmlTexts: true, + + /* + You can overwrite existing translations and add translations for your app + descriptions and purposes. See `src/translations/` for a full list of + translations that can be overwritten: + https://github.com/KIProtect/klaro/tree/master/src/translations + */ + translations: { + en: { + acceptAll: 'cookies.consent.accept-all', + acceptSelected: 'cookies.consent.accept-selected', + app: { + optOut: { + description: 'cookies.consent.app.opt-out.description', + title: 'cookies.consent.app.opt-out.title' + }, + purpose: 'cookies.consent.app.purpose', + purposes: 'cookies.consent.app.purposes', + required: { + description: 'cookies.consent.app.required.description', + title: 'cookies.consent.app.required.title' + } + }, + close: 'cookies.consent.close', + decline: 'cookies.consent.decline', + changeDescription: 'cookies.consent.update', + consentNotice: { + description: 'cookies.consent.content-notice.description', + learnMore: 'cookies.consent.content-notice.learnMore' + }, + consentModal: { + description: 'cookies.consent.content-modal.description', + privacyPolicy: { + name: 'cookies.consent.content-modal.privacy-policy.name', + text: 'cookies.consent.content-modal.privacy-policy.text' + }, + title: 'cookies.consent.content-modal.title' + }, + purposes: {} + } + }, + apps: [ + { + name: 'authentication', + purposes: ['functional'], + required: true, + cookies: [ + TOKENITEM, + IMPERSONATING_COOKIE, + REDIRECT_COOKIE + ] + }, + { + name: 'preferences', + purposes: ['functional'], + required: true, + cookies: [ + LANG_COOKIE + ] + }, + { + name: 'acknowledgement', + purposes: ['functional'], + required: true, + cookies: [ + [/^klaro-.+$/], + HAS_AGREED_END_USER + ] + }, + { + name: 'google-analytics', + purposes: ['statistical'], + required: false, + cookies: [ + // /* + // you an either only provide a cookie name or regular expression (regex) or a list + // consisting of a name or regex, a path and a cookie domain. Providing a path and + // domain is necessary if you have apps that set cookies for a path that is not + // "/", or a domain that is not the current domain. If you do not set these values + // properly, the cookie can't be deleted by Klaro, as there is no way to access the + // path or domain of a cookie in JS. Notice that it is not possible to delete + // cookies that were set on a third-party domain, or cookies that have the HTTPOnly + // attribute: https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#new- + // cookie_domain + // */ + // + // /* + // This rule will match cookies that contain the string '_pk_' and that are set on + // the path '/' and the domain 'klaro.kiprotect.com' + // */ + [/^_ga.?$/], + [/^_gid$/], + // + // /* + // Same as above, only for the 'localhost' domain + // */ + // [/^_pk_.*$/, '/', 'localhost'], + // + // /* + // This rule will match all cookies named 'piwik_ignore' that are set on the path + // '/' on the current domain + // */ + // 'piwik_ignore', + ], + /* + If 'onlyOnce' is set to 'true', the app will only be executed once regardless + how often the user toggles it on and off. This is relevant e.g. for tracking + scripts that would generate new page view events every time Klaro disables and + re-enables them due to a consent change by the user. + */ + onlyOnce: true, + }, + ], +}; diff --git a/src/app/shared/cookies/klaro.service.ts b/src/app/shared/cookies/klaro.service.ts new file mode 100644 index 0000000000..64dee85b65 --- /dev/null +++ b/src/app/shared/cookies/klaro.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; + +/** + * Abstract class representing a service for handling Klaro consent preferences and UI + */ +@Injectable() +export abstract class KlaroService { + /** + * Initializes the service + */ + abstract initialize(); + + /** + * Shows a the dialog with the current consent preferences + */ + abstract showSettings(); +} diff --git a/src/app/shared/mocks/translate.service.mock.ts b/src/app/shared/mocks/translate.service.mock.ts index 8106b3788f..0bc172b408 100644 --- a/src/app/shared/mocks/translate.service.mock.ts +++ b/src/app/shared/mocks/translate.service.mock.ts @@ -3,6 +3,7 @@ import { TranslateService } from '@ngx-translate/core'; export function getMockTranslateService(): TranslateService { return jasmine.createSpyObj('translateService', { get: jasmine.createSpy('get'), - instant: jasmine.createSpy('instant') + instant: jasmine.createSpy('instant'), + setDefaultLang: jasmine.createSpy('setDefaultLang') }); } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 4148c6e5c9..600ce0aed1 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -937,6 +937,68 @@ + "cookies.consent.accept-all": "Accept all", + + "cookies.consent.accept-selected": "Accept selected", + + "cookies.consent.app.opt-out.description": "This app is loaded by default (but you can opt out)", + + "cookies.consent.app.opt-out.title": "(opt-out)", + + "cookies.consent.app.purpose": "purpose", + + "cookies.consent.app.required.description": "This application is always required", + + "cookies.consent.app.required.title": "(always required)", + + "cookies.consent.update": "There were changes since your last visit, please update your consent.", + + "cookies.consent.close": "Close", + + "cookies.consent.decline": "Decline", + + "cookies.consent.content-notice.description": "We collect and process your personal information for the following purposes: Authentication, Preferences, Acknowledgement and Statistics.
To learn more, please read our {privacyPolicy}.", + + "cookies.consent.content-notice.learnMore": "Customize", + + "cookies.consent.content-modal.description": "Here you can see and customize the information that we collect about you.", + + "cookies.consent.content-modal.privacy-policy.name": "privacy policy", + + "cookies.consent.content-modal.privacy-policy.text": "To learn more, please read our {privacyPolicy}.", + + "cookies.consent.content-modal.title": "Information that we collect", + + + + "cookies.consent.app.title.authentication": "Authentication", + + "cookies.consent.app.description.authentication": "Required for signing you in", + + + "cookies.consent.app.title.preferences": "Preferences", + + "cookies.consent.app.description.preferences": "Required for saving your preferences", + + + + "cookies.consent.app.title.acknowledgement": "Acknowledgement", + + "cookies.consent.app.description.acknowledgement": "Required for saving your acknowledgements and consents", + + + + "cookies.consent.app.title.google-analytics": "Google Analytics", + + "cookies.consent.app.description.google-analytics": "Allows us to track statistical data", + + + + "cookies.consent.purpose.functional": "Functional", + + "cookies.consent.purpose.statistical": "Statistical", + + "curation-task.task.checklinks.label": "Check Links in Metadata", "curation-task.task.noop.label": "NOOP", @@ -1051,6 +1113,13 @@ "footer.link.duraspace": "DuraSpace", + "footer.link.cookies": "Cookie settings", + + "footer.link.privacy-policy": "Privacy policy", + + "footer.link.end-user-agreement":"End User Agreement", + + "forgot-email.form.header": "Forgot Password", diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 08ce2737c6..295e78b932 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -21,6 +21,8 @@ import { AuthService } from '../../app/core/auth/auth.service'; import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; import { SubmissionService } from '../../app/submission/submission.service'; import { StatisticsModule } from '../../app/statistics/statistics.module'; +import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service'; +import { KlaroService } from '../../app/shared/cookies/klaro.service'; import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; import { BrowserHardRedirectService, @@ -80,6 +82,10 @@ export function getRequest(transferState: TransferState): any { provide: CookieService, useClass: ClientCookieService }, + { + provide: KlaroService, + useClass: BrowserKlaroService + }, { provide: SubmissionService, useClass: SubmissionService diff --git a/webpack.server.config.js b/webpack.server.config.js index 264ae71939..7df6d6b83c 100644 --- a/webpack.server.config.js +++ b/webpack.server.config.js @@ -25,7 +25,8 @@ module.exports = { module: { noParse: /polyfills-.*\.js/, rules: [ - { test: /\.ts$/, loader: 'ts-loader', + { + test: /\.ts$/, loader: 'ts-loader', options: { configFile: "tsconfig.server.json" } }, diff --git a/yarn.lock b/yarn.lock index 2b452d7974..d44182cc51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6211,6 +6211,11 @@ kind-of@^6.0.0, kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +klaro@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/klaro/-/klaro-0.6.3.tgz#b2aaa810d17f073c9a1b5eab618a9f13d5f40fa3" + integrity sha512-rRP37FaJaHHSScHIe3YUdMZJ1asxOF5+C/RMrFB2RzhAUfGVMM5/GiucECM3Si1lhW2LL0xGVymE8JhYZl2Bjg== + last-call-webpack-plugin@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555"