From 472154c0df032a55ea4585eca3b1422bbba1feef Mon Sep 17 00:00:00 2001 From: lotte Date: Fri, 21 Aug 2020 17:50:43 +0200 Subject: [PATCH 01/14] 72635: Cookie preferences first committ --- package.json | 1 + src/app/app.component.ts | 10 +- .../shared/cookies/cookies.service.spec.ts | 12 ++ src/app/shared/cookies/cookies.service.ts | 192 ++++++++++++++++++ src/assets/i18n/en.json5 | 35 ++++ src/modules/app/browser-app.module.ts | 5 + yarn.lock | 5 + 7 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 src/app/shared/cookies/cookies.service.spec.ts create mode 100644 src/app/shared/cookies/cookies.service.ts diff --git a/package.json b/package.json index 52afb7c4c0..c58eb40d0a 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.5.34", "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..4ee0da048e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,7 +5,7 @@ import { Component, HostListener, Inject, - OnInit, + OnInit, Optional, ViewEncapsulation } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; @@ -31,8 +31,7 @@ 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 { CookiesService } from './shared/cookies/cookies.service'; @Component({ selector: 'ds-app', @@ -69,7 +68,8 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService, - private localeService: LocaleService + private localeService: LocaleService, + @Optional() private cookiesService: CookiesService ) { /* Use models object so all decorators are actually called */ this.models = models; @@ -91,6 +91,8 @@ export class AppComponent implements OnInit, AfterViewInit { console.info(environment); } this.storeCSSVariables(); + + this.cookiesService.initialize(); } ngOnInit() { diff --git a/src/app/shared/cookies/cookies.service.spec.ts b/src/app/shared/cookies/cookies.service.spec.ts new file mode 100644 index 0000000000..aebf8ab956 --- /dev/null +++ b/src/app/shared/cookies/cookies.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { CookiesService } from './cookies.service'; + +describe('CookiesService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: CookiesService = TestBed.get(CookiesService); + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/shared/cookies/cookies.service.ts b/src/app/shared/cookies/cookies.service.ts new file mode 100644 index 0000000000..7f625681c5 --- /dev/null +++ b/src/app/shared/cookies/cookies.service.ts @@ -0,0 +1,192 @@ +import { Injectable } from '@angular/core'; +import * as Klaro from 'klaro' +import { BehaviorSubject } from 'rxjs'; +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'; +import { TranslateService } from '@ngx-translate/core'; + +export const HAS_AGREED_END_USER = 'hasAgreedEndUser'; +export const KLARO = 'klaro'; + +const cookieNameMessagePrefix = 'cookies.consent.app.title.'; +const cookieDescriptionMessagePrefix = 'cookies.consent.app.description.'; +const cookiePurposeMessagePrefix = 'cookies.consent.purpose.'; + +@Injectable({ + providedIn: 'root' +}) + +export class CookiesService { + + message$: BehaviorSubject = new BehaviorSubject(''); + + klaroConfig = { + /* + 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, + + htmlText: 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: { + consentNotice: { + description: 'We collect and process your personal information for the following purposes: {purposes}.
To learn more, please read our {privacyPolicy}' + }, + purposes: {} + } + }, + apps: [ + { + name: 'token_item', + purposes: ['authentication'], + required: true, + cookies: [ + TOKENITEM + ] + }, + { + name: 'impersonation', + purposes: ['authentication'], + required: true, + cookies: [ + IMPERSONATING_COOKIE + ] + }, + { + name: 'redirect', + purposes: ['authentication'], + required: true, + cookies: [ + REDIRECT_COOKIE + ] + }, + { + name: 'language', + purposes: ['preferences'], + required: true, + cookies: [ + LANG_COOKIE + ] + }, + { + name: 'klaro', + purposes: ['acknowledgement'], + required: true, + cookies: [ + KLARO + ] + }, + { + name: 'has_agreed_end_user', + purposes: ['acknowledgement'], + required: true, + cookies: [ + HAS_AGREED_END_USER + ] + }, + { + name: 'google-analytics', + purposes: ['statistics'], + 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', + ], + + /* + You can define an optional callback function that will be called each time the + consent state for the given app changes. The consent value will be passed as the + first parameter to the function (true=consented). The `app` config will be + passed as the second parameter. + */ + callback: (consent, app) => { + this.message$.next('User consent for app ' + app.name + ': consent=' + consent); + }, + /* + 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, + }, + ] + }; + + constructor(private translateService: TranslateService) { + } + + initialize() { + 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); + }) + }); + + Klaro.show(this.klaroConfig); + } + + private getTitleTranslation(title: string) { + return this.translateService.instant(cookieNameMessagePrefix + title); + } + + private getDescriptionTranslation(description: string) { + return this.translateService.instant(cookieDescriptionMessagePrefix + description); + } + + private getPurposeTranslation(purpose: string) { + return this.translateService.instant(cookiePurposeMessagePrefix + purpose); + } +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index e437548ae9..54a7ec7d2e 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -937,6 +937,41 @@ + "cookies.consent.app.title.token_item": "User Token", + + "cookies.consent.app.description.token_item": "User login identification", + + + + "cookies.consent.app.title.impersonation": "Impersonation", + + "cookies.consent.app.description.impersonation": "Switching users", + + + + "cookies.consent.app.title.klaro": "Klaro", + + "cookies.consent.app.description.klaro": "Cookie consent preferences", + + + + + "cookies.consent.app.title.has_agreed_end_user": "Has agreed end user", + + "cookies.consent.app.description.has_agreed_end_user": "", + + + + "cookies.consent.purpose.authentication": "Authentication", + + "cookies.consent.purpose.statistics": "Statistics", + + "cookies.consent.purpose.preferences": "Preferences", + + "cookies.consent.purpose.acknowledgement": "Acknowledgement", + + + "curation-task.task.checklinks.label": "Check Links in Metadata", "curation-task.task.noop.label": "NOOP", diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 7306e7db6c..be4402fc74 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -23,6 +23,7 @@ import { SubmissionService } from '../../app/submission/submission.service'; import { StatisticsModule } from '../../app/statistics/statistics.module'; import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; import { BrowserHardRedirectService } from '../../app/core/services/browser-hard-redirect.service'; +import { CookiesService } from '../../app/shared/cookies/cookies.service'; export const REQ_KEY = makeStateKey('req'); @@ -83,6 +84,10 @@ export function locationProvider(): Location { provide: CookieService, useClass: ClientCookieService }, + { + provide: CookiesService, + useClass: CookiesService + }, { provide: SubmissionService, useClass: SubmissionService diff --git a/yarn.lock b/yarn.lock index 0b02259f38..5f918fd7e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6165,6 +6165,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.5.34: + version "0.5.34" + resolved "https://registry.yarnpkg.com/klaro/-/klaro-0.5.34.tgz#0b524be96a1bb177fe88ff2603e1be75f494fc98" + integrity sha512-M6KHqlBWpMyYoxOK1icoJMeYsaPT7YhzJIAQ3wdxZWGgBc0sV7xQsf0PsgMUVnuTD0AeC58QegCGEv0qYeq4gw== + 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" From 0b3b82b4853acc1ac5444f5ff32ca1eb824eb856 Mon Sep 17 00:00:00 2001 From: lotte Date: Mon, 24 Aug 2020 15:18:20 +0200 Subject: [PATCH 02/14] finished basic Klaro setup --- src/app/app.component.ts | 5 +- src/app/footer/footer.component.html | 27 +++++--- src/app/footer/footer.component.scss | 39 ++++++++--- src/app/footer/footer.component.ts | 8 +++ src/app/shared/cookies/cookies.service.ts | 81 +++++++++++++++++++---- src/assets/i18n/en.json5 | 58 +++++++++++++++- 6 files changed, 186 insertions(+), 32 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4ee0da048e..d576780d59 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -71,6 +71,7 @@ export class AppComponent implements OnInit, AfterViewInit { private localeService: LocaleService, @Optional() private cookiesService: CookiesService ) { + /* 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 @@ -82,6 +83,8 @@ export class AppComponent implements OnInit, AfterViewInit { // set the current language code this.localeService.setCurrentLanguageCode(); + this.cookiesService.initialize(); + angulartics2GoogleAnalytics.startTracking(); angulartics2DSpace.startTracking(); @@ -92,7 +95,6 @@ export class AppComponent implements OnInit, AfterViewInit { } this.storeCSSVariables(); - this.cookiesService.initialize(); } ngOnInit() { @@ -147,6 +149,7 @@ export class AppComponent implements OnInit, AfterViewInit { this.isLoading$.next(false); } }); + this.translate.get('loading.default').subscribe(t => console.log(t)); } @HostListener('window:resize', ['$event']) 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..c8adf9909f 100644 --- a/src/app/footer/footer.component.scss +++ b/src/app/footer/footer.component.scss @@ -4,15 +4,34 @@ $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 li { + padding-top: $spacer * 0.5; + + a { + color: inherit + } + + &:not(:last-child) { + &:after { + content: ''; + border-right: 1px map-get($theme-colors, secondary) solid; + margin: 0 $spacer/2; + } + + } + } } diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index 94b239d204..da71ac635d 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; +import { CookiesService } from '../shared/cookies/cookies.service'; @Component({ selector: 'ds-footer', @@ -6,7 +7,14 @@ import { Component } from '@angular/core'; templateUrl: 'footer.component.html' }) export class FooterComponent { + constructor(private cookies: CookiesService) { + + } dateObj: number = Date.now(); + showCookieSettings() { + this.cookies.showSettings(); + return false; + } } diff --git a/src/app/shared/cookies/cookies.service.ts b/src/app/shared/cookies/cookies.service.ts index 7f625681c5..86f2288fc9 100644 --- a/src/app/shared/cookies/cookies.service.ts +++ b/src/app/shared/cookies/cookies.service.ts @@ -5,6 +5,8 @@ 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'; import { TranslateService } from '@ngx-translate/core'; +import { environment } from '../../../environments/environment'; +import { take } from 'rxjs/operators'; export const HAS_AGREED_END_USER = 'hasAgreedEndUser'; export const KLARO = 'klaro'; @@ -22,6 +24,8 @@ export class CookiesService { message$: BehaviorSubject = new BehaviorSubject(''); klaroConfig = { + 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 @@ -43,7 +47,7 @@ export class CookiesService { */ cookieExpiresAfterDays: 365, - htmlText: true, + htmlTexts: true, /* You can overwrite existing translations and add translations for your app @@ -53,8 +57,33 @@ export class CookiesService { */ 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', consentNotice: { - description: 'We collect and process your personal information for the following purposes: {purposes}.
To learn more, please read our {privacyPolicy}' + 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: {} } @@ -168,25 +197,53 @@ export class CookiesService { } initialize() { + this.addAppMessages(); + + this.translateService.setDefaultLang(environment.defaultLanguage); + this.translateService.get('loading.default').pipe(take(1)).subscribe(() => { + this.translateConfiguration(); + if (!Klaro.getManager(this.klaroConfig).confirmed) { + Klaro.show(this.klaroConfig, false); + } + }) + } + + private getTitleTranslation(title: string) { + return cookieNameMessagePrefix + title; + } + + private getDescriptionTranslation(description: string) { + return cookieDescriptionMessagePrefix + description; + } + + private getPurposeTranslation(purpose: string) { + return cookiePurposeMessagePrefix + purpose; + } + + showSettings() { + Klaro.show(this.klaroConfig); + } + + 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); }) }); - - Klaro.show(this.klaroConfig); } - private getTitleTranslation(title: string) { - return this.translateService.instant(cookieNameMessagePrefix + title); + translateConfiguration() { + this.translate(this.klaroConfig.translations.en); } - private getDescriptionTranslation(description: string) { - return this.translateService.instant(cookieDescriptionMessagePrefix + description); - } - - private getPurposeTranslation(purpose: string) { - return this.translateService.instant(cookiePurposeMessagePrefix + purpose); + 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; } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 54a7ec7d2e..8beb133758 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -937,6 +937,38 @@ + "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.close": "Close", + + "cookies.consent.decline": "Decline", + + "cookies.consent.content-notice.description": "We collect and process your personal information for the following purposes: {purposes}.
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.token_item": "User Token", "cookies.consent.app.description.token_item": "User login identification", @@ -949,19 +981,36 @@ + "cookies.consent.app.title.redirect": "Redirect", + + "cookies.consent.app.description.redirect": "", + + + + "cookies.consent.app.title.language": "Language", + + "cookies.consent.app.description.language": "", + + + "cookies.consent.app.title.klaro": "Klaro", "cookies.consent.app.description.klaro": "Cookie consent preferences", - "cookies.consent.app.title.has_agreed_end_user": "Has agreed end user", "cookies.consent.app.description.has_agreed_end_user": "", + "cookies.consent.app.title.google-analytics": "Google Analytics", + + "cookies.consent.app.description.google-analytics": "", + + + "cookies.consent.purpose.authentication": "Authentication", "cookies.consent.purpose.statistics": "Statistics", @@ -1086,6 +1135,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", From 33aeccdd43b870ca5d6ad64a3c4d612e4bebecd6 Mon Sep 17 00:00:00 2001 From: lotte Date: Mon, 24 Aug 2020 16:33:44 +0200 Subject: [PATCH 03/14] 72635: Fixed initialize code and started on storageName --- src/app/shared/cookies/cookies.service.ts | 41 ++++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/app/shared/cookies/cookies.service.ts b/src/app/shared/cookies/cookies.service.ts index 86f2288fc9..cfed63420e 100644 --- a/src/app/shared/cookies/cookies.service.ts +++ b/src/app/shared/cookies/cookies.service.ts @@ -2,11 +2,12 @@ import { Injectable } from '@angular/core'; import * as Klaro from 'klaro' import { BehaviorSubject } from 'rxjs'; import { TOKENITEM } from '../../core/auth/models/auth-token-info.model'; -import { IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; +import { AuthService, IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; import { LANG_COOKIE } from '../../core/locale/locale.service'; import { TranslateService } from '@ngx-translate/core'; import { environment } from '../../../environments/environment'; import { take } from 'rxjs/operators'; +import { EPerson } from '../../core/eperson/models/eperson.model'; export const HAS_AGREED_END_USER = 'hasAgreedEndUser'; export const KLARO = 'klaro'; @@ -24,6 +25,8 @@ export class CookiesService { message$: BehaviorSubject = new BehaviorSubject(''); klaroConfig = { + storageName: 'klaro-anonymous', + privacyPolicy: '/info/privacy', /* @@ -193,18 +196,37 @@ export class CookiesService { ] }; - constructor(private translateService: TranslateService) { + constructor( + private translateService: TranslateService, + private authService: AuthService, + ) { } initialize() { + this.authService.getAuthenticatedUserFromStore() + .subscribe((user: EPerson) => { + this.klaroConfig.storageName = 'klaro-' + (user.uuid); + + }); + /** + * Add all message keys for apps and purposes + */ this.addAppMessages(); + /** + * Make sure the fallback language is english + */ this.translateService.setDefaultLang(environment.defaultLanguage); + + /** + * 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.translateService.get('loading.default').pipe(take(1)).subscribe(() => { this.translateConfiguration(); - if (!Klaro.getManager(this.klaroConfig).confirmed) { - Klaro.show(this.klaroConfig, false); - } + Klaro.renderKlaro(this.klaroConfig, false); + Klaro.initialize(); }) } @@ -220,10 +242,16 @@ export class CookiesService { 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) }; @@ -233,6 +261,9 @@ export class CookiesService { }); } + /** + * Translate the translation section from the Klaro configuration + */ translateConfiguration() { this.translate(this.klaroConfig.translations.en); } From 3e1fb243a118d1ee3453581020933acbd5ac8487 Mon Sep 17 00:00:00 2001 From: lotte Date: Thu, 27 Aug 2020 16:40:39 +0200 Subject: [PATCH 04/14] solving SSR issue --- src/app/app.component.ts | 12 +- src/app/core/locale/locale.service.ts | 2 +- src/app/core/shared/dspace-object.model.ts | 21 ++++ src/app/footer/footer.component.ts | 16 +-- ....service.spec.ts => klaro.service.spec.ts} | 6 +- .../{cookies.service.ts => klaro.service.ts} | 103 ++++++++++++------ src/modules/app/browser-app.module.ts | 6 +- tsconfig.server.json | 12 +- 8 files changed, 127 insertions(+), 51 deletions(-) rename src/app/shared/cookies/{cookies.service.spec.ts => klaro.service.spec.ts} (55%) rename src/app/shared/cookies/{cookies.service.ts => klaro.service.ts} (75%) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d576780d59..e3b7149740 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -31,7 +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'; -import { CookiesService } from './shared/cookies/cookies.service'; +import { KlaroService } from './shared/cookies/klaro.service'; +import { hasValue } from './shared/empty.util'; @Component({ selector: 'ds-app', @@ -69,7 +70,7 @@ export class AppComponent implements OnInit, AfterViewInit { private menuService: MenuService, private windowService: HostWindowService, private localeService: LocaleService, - @Optional() private cookiesService: CookiesService + @Optional() private cookiesService: KlaroService ) { /* Use models object so all decorators are actually called */ @@ -83,7 +84,7 @@ export class AppComponent implements OnInit, AfterViewInit { // set the current language code this.localeService.setCurrentLanguageCode(); - this.cookiesService.initialize(); + this.initializeKlaro(); angulartics2GoogleAnalytics.startTracking(); angulartics2DSpace.startTracking(); @@ -163,4 +164,9 @@ export class AppComponent implements OnInit, AfterViewInit { ); } + private initializeKlaro() { + if (hasValue(this.cookiesService)) { + this.cookiesService.initialize() + } + } } diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index b7f9314a33..32f782ef01 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.ts b/src/app/footer/footer.component.ts index da71ac635d..ef11093c7b 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -1,5 +1,6 @@ -import { Component } from '@angular/core'; -import { CookiesService } from '../shared/cookies/cookies.service'; +import { Component, Optional } from '@angular/core'; +import { KlaroService } from '../shared/cookies/klaro.service'; +import { hasValue } from '../shared/empty.util'; @Component({ selector: 'ds-footer', @@ -7,14 +8,15 @@ import { CookiesService } from '../shared/cookies/cookies.service'; templateUrl: 'footer.component.html' }) export class FooterComponent { - constructor(private cookies: CookiesService) { - - } - dateObj: number = Date.now(); + constructor(@Optional() private cookies: KlaroService) { + } + showCookieSettings() { - this.cookies.showSettings(); + if (hasValue(this.cookies)) { + this.cookies.showSettings(); + } return false; } } diff --git a/src/app/shared/cookies/cookies.service.spec.ts b/src/app/shared/cookies/klaro.service.spec.ts similarity index 55% rename from src/app/shared/cookies/cookies.service.spec.ts rename to src/app/shared/cookies/klaro.service.spec.ts index aebf8ab956..0c1f5b368e 100644 --- a/src/app/shared/cookies/cookies.service.spec.ts +++ b/src/app/shared/cookies/klaro.service.spec.ts @@ -1,12 +1,12 @@ import { TestBed } from '@angular/core/testing'; -import { CookiesService } from './cookies.service'; +import { KlaroService } from './klaro.service'; -describe('CookiesService', () => { +describe('KlaroService', () => { beforeEach(() => TestBed.configureTestingModule({})); it('should be created', () => { - const service: CookiesService = TestBed.get(CookiesService); + const service: KlaroService = TestBed.get(KlaroService); expect(service).toBeTruthy(); }); }); diff --git a/src/app/shared/cookies/cookies.service.ts b/src/app/shared/cookies/klaro.service.ts similarity index 75% rename from src/app/shared/cookies/cookies.service.ts rename to src/app/shared/cookies/klaro.service.ts index cfed63420e..ffae51969c 100644 --- a/src/app/shared/cookies/cookies.service.ts +++ b/src/app/shared/cookies/klaro.service.ts @@ -1,26 +1,23 @@ import { Injectable } from '@angular/core'; import * as Klaro from 'klaro' -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { TOKENITEM } from '../../core/auth/models/auth-token-info.model'; import { AuthService, IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; import { LANG_COOKIE } from '../../core/locale/locale.service'; import { TranslateService } from '@ngx-translate/core'; import { environment } from '../../../environments/environment'; -import { take } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { EPerson } from '../../core/eperson/models/eperson.model'; +import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; -export const HAS_AGREED_END_USER = 'hasAgreedEndUser'; -export const KLARO = 'klaro'; - +export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; +export const COOKIE_MDFIELD = 'dspace.agreements.cookies'; const cookieNameMessagePrefix = 'cookies.consent.app.title.'; const cookieDescriptionMessagePrefix = 'cookies.consent.app.description.'; const cookiePurposeMessagePrefix = 'cookies.consent.purpose.'; -@Injectable({ - providedIn: 'root' -}) - -export class CookiesService { +@Injectable() +export class KlaroService { message$: BehaviorSubject = new BehaviorSubject(''); @@ -129,7 +126,7 @@ export class CookiesService { purposes: ['acknowledgement'], required: true, cookies: [ - KLARO + [/^klaro-.+$/], ] }, { @@ -183,6 +180,7 @@ export class CookiesService { passed as the second parameter. */ callback: (consent, app) => { + console.log(consent, app); this.message$.next('User consent for app ' + app.name + ': consent=' + consent); }, /* @@ -193,41 +191,72 @@ export class CookiesService { */ onlyOnce: true, }, - ] + ], + /* + You can define an optional callback function that will be called each time the + consent state for any given app changes. The consent value will be passed as the + first parameter to the function (true=consented). The `app` config will be + passed as the second parameter. + */ + callback: (consent, app) => { + + /* + You can define an optional callback function that will be called each time the + consent state for any given app changes. The consent value will be passed as the + first parameter to the function (true=consented). The `app` config will be + passed as the second parameter. + */ + console.log( + 'User consent for app ' + app.name + ': consent=' + consent + ); + // this.updateUserCookieConsent(); + }, }; constructor( private translateService: TranslateService, - private authService: AuthService, - ) { + private authService: AuthService) { } initialize() { - this.authService.getAuthenticatedUserFromStore() - .subscribe((user: EPerson) => { - this.klaroConfig.storageName = 'klaro-' + (user.uuid); - - }); - /** - * Add all message keys for apps and purposes - */ - this.addAppMessages(); /** * Make sure the fallback language is english */ this.translateService.setDefaultLang(environment.defaultLanguage); - /** - * 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.translateService.get('loading.default').pipe(take(1)).subscribe(() => { - this.translateConfiguration(); - Klaro.renderKlaro(this.klaroConfig, false); - Klaro.initialize(); - }) + const storageName$: Observable = this.authService.isAuthenticated() + .pipe( + take(1), + switchMap((loggedIn: boolean) => { + if (loggedIn) { + return this.authService.getAuthenticatedUserFromStore().pipe(map((user: EPerson) => 'klaro-' + user.uuid), take(1)) + } else { + return observableOf('klaro-anonymous') + } + }) + ); + const translationServiceReady$ = this.translateService.get('loading.default').pipe(take(1)); + + observableCombineLatest(storageName$, translationServiceReady$) + .subscribe(([name, translation]: string[]) => { + this.klaroConfig.storageName = name; + + /** + * 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(); + }); + } private getTitleTranslation(title: string) { @@ -277,4 +306,12 @@ export class CookiesService { }); return object; } + + getSettingsForUser(user: EPerson) { + return JSON.parse(user.firstMetadataValue(COOKIE_MDFIELD)); + } + + setSettingsForUser(user: EPerson, config: object) { + return user.setMetadata(COOKIE_MDFIELD, undefined, JSON.stringify(config)); + } } diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index be4402fc74..e00f998ba6 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -21,9 +21,9 @@ 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 { KlaroService } from '../../app/shared/cookies/klaro.service'; import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; import { BrowserHardRedirectService } from '../../app/core/services/browser-hard-redirect.service'; -import { CookiesService } from '../../app/shared/cookies/cookies.service'; export const REQ_KEY = makeStateKey('req'); @@ -85,8 +85,8 @@ export function locationProvider(): Location { useClass: ClientCookieService }, { - provide: CookiesService, - useClass: CookiesService + provide: KlaroService, + useClass: KlaroService }, { provide: SubmissionService, diff --git a/tsconfig.server.json b/tsconfig.server.json index 1329b32ace..2d23226548 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -6,5 +6,15 @@ }, "angularCompilerOptions": { "entryModule": "./src/modules/app/server-app.module#ServerAppModule" - } + }, + "exclude": [ + "src/test.ts", + "src/**/*.spec.ts", + "src/**/*.mock.ts", + "src/**/*.test.ts", + "src/**/*.stub.ts", + "src/**/testing/*", + "src/**/mocks/*", + "node_modules/klaro" + ], } From b3b934033c971be776d72a59b9d865123dfdfcee Mon Sep 17 00:00:00 2001 From: lotte Date: Thu, 27 Aug 2020 17:38:31 +0200 Subject: [PATCH 05/14] added klaro exclusion to webpack/tsconfig --- tsconfig.server.json | 2 +- webpack.server.config.js | 8 +++++++- webpack/webpack.prod.ts | 9 +++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/tsconfig.server.json b/tsconfig.server.json index 2d23226548..979f5c2743 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -15,6 +15,6 @@ "src/**/*.stub.ts", "src/**/testing/*", "src/**/mocks/*", - "node_modules/klaro" + "node_modules/klaro/*" ], } diff --git a/webpack.server.config.js b/webpack.server.config.js index 264ae71939..e762c14cce 100644 --- a/webpack.server.config.js +++ b/webpack.server.config.js @@ -25,7 +25,13 @@ module.exports = { module: { noParse: /polyfills-.*\.js/, rules: [ - { test: /\.ts$/, loader: 'ts-loader', + { + test: /\.js$/, + exclude: [/node_modules\/klaro/], + }, + + { + test: /\.ts$/, loader: 'ts-loader', options: { configFile: "tsconfig.server.json" } }, diff --git a/webpack/webpack.prod.ts b/webpack/webpack.prod.ts index e9fba2e65e..ddb7f03403 100644 --- a/webpack/webpack.prod.ts +++ b/webpack/webpack.prod.ts @@ -18,6 +18,15 @@ module.exports = Object.assign({}, commonExports, { recordsOutputPath: projectRoot('webpack.records.json'), entry: buildRoot('./main.server.ts'), target: 'node', + module: { + rules: [ + ...commonExports.module.rules, + { + test: /\.js$/, + exclude: [/node_modules\/klaro/], + } + ] + }, externals: [nodeExternals({ whitelist: [ /@angular/, From f6e8448164d45ae807f4c7346f75f24739213698 Mon Sep 17 00:00:00 2001 From: lotte Date: Fri, 28 Aug 2020 16:55:56 +0200 Subject: [PATCH 06/14] fixed SSR issue, added code for storing the users cookie consents and restoring them --- package.json | 2 +- src/app/app.component.ts | 2 +- src/app/footer/footer.component.ts | 2 +- ....spec.ts => browser-klaro.service.spec.ts} | 4 +- .../shared/cookies/browser-klaro.service.ts | 324 ++++++++++++++++++ src/app/shared/cookies/klaro.effects.ts | 3 + src/app/shared/cookies/klaro.service.ts | 316 +---------------- src/modules/app/browser-app.module.ts | 3 +- tsconfig.server.json | 12 +- webpack.server.config.js | 5 - webpack/webpack.prod.ts | 9 - yarn.lock | 8 +- 12 files changed, 342 insertions(+), 348 deletions(-) rename src/app/shared/cookies/{klaro.service.spec.ts => browser-klaro.service.spec.ts} (61%) create mode 100644 src/app/shared/cookies/browser-klaro.service.ts create mode 100644 src/app/shared/cookies/klaro.effects.ts diff --git a/package.json b/package.json index c58eb40d0a..dab22314d3 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "json5": "^2.1.0", "jsonschema": "1.2.2", "jwt-decode": "^2.2.0", - "klaro": "^0.5.34", + "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 e3b7149740..dc164476d4 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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'; -import { KlaroService } from './shared/cookies/klaro.service'; import { hasValue } from './shared/empty.util'; +import { KlaroService } from './shared/cookies/klaro.service'; @Component({ selector: 'ds-app', diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index ef11093c7b..6ece2cf08b 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -1,6 +1,6 @@ import { Component, Optional } from '@angular/core'; -import { KlaroService } from '../shared/cookies/klaro.service'; import { hasValue } from '../shared/empty.util'; +import { KlaroService } from '../shared/cookies/klaro.service'; @Component({ selector: 'ds-footer', diff --git a/src/app/shared/cookies/klaro.service.spec.ts b/src/app/shared/cookies/browser-klaro.service.spec.ts similarity index 61% rename from src/app/shared/cookies/klaro.service.spec.ts rename to src/app/shared/cookies/browser-klaro.service.spec.ts index 0c1f5b368e..e33f497d73 100644 --- a/src/app/shared/cookies/klaro.service.spec.ts +++ b/src/app/shared/cookies/browser-klaro.service.spec.ts @@ -1,12 +1,12 @@ import { TestBed } from '@angular/core/testing'; -import { KlaroService } from './klaro.service'; +import { BrowserKlaroService } from './browser-klaro.service'; describe('KlaroService', () => { beforeEach(() => TestBed.configureTestingModule({})); it('should be created', () => { - const service: KlaroService = TestBed.get(KlaroService); + const service: BrowserKlaroService = TestBed.get(BrowserKlaroService); expect(service).toBeTruthy(); }); }); 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..d62e0a29cd --- /dev/null +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -0,0 +1,324 @@ +import { Injectable } from '@angular/core'; +import * as Klaro from 'klaro' +import { BehaviorSubject, Observable } from 'rxjs'; +import { TOKENITEM } from '../../core/auth/models/auth-token-info.model'; +import { AuthService, IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; +import { LANG_COOKIE } from '../../core/locale/locale.service'; +import { TranslateService } from '@ngx-translate/core'; +import { environment } from '../../../environments/environment'; +import { map, switchMap, take, tap } from 'rxjs/operators'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { KlaroService } from './klaro.service'; +import { hasValue } from '../empty.util'; +import { CookieService } from '../../core/services/cookie.service'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; + +export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; +export const COOKIE_MDFIELD = 'dspace.agreements.cookies'; +const cookieNameMessagePrefix = 'cookies.consent.app.title.'; +const cookieDescriptionMessagePrefix = 'cookies.consent.app.description.'; +const cookiePurposeMessagePrefix = 'cookies.consent.purpose.'; + +@Injectable() +export class BrowserKlaroService extends KlaroService { + + message$: BehaviorSubject = new BehaviorSubject(''); + + klaroConfig: any = { + storageName: this.getStorageName('anonymous'), + + 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', + 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: 'token_item', + purposes: ['authentication'], + required: true, + cookies: [ + TOKENITEM + ] + }, + { + name: 'impersonation', + purposes: ['authentication'], + required: true, + cookies: [ + IMPERSONATING_COOKIE + ] + }, + { + name: 'redirect', + purposes: ['authentication'], + required: true, + cookies: [ + REDIRECT_COOKIE + ] + }, + { + name: 'language', + purposes: ['preferences'], + required: true, + cookies: [ + LANG_COOKIE + ] + }, + { + name: 'klaro', + purposes: ['acknowledgement'], + required: true, + cookies: [ + [/^klaro-.+$/], + ] + }, + { + name: 'has_agreed_end_user', + purposes: ['acknowledgement'], + required: true, + cookies: [ + HAS_AGREED_END_USER + ] + }, + { + name: 'google-analytics', + purposes: ['statistics'], + 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', + ], + + /* + You can define an optional callback function that will be called each time the + consent state for the given app changes. The consent value will be passed as the + first parameter to the function (true=consented). The `app` config will be + passed as the second parameter. + */ + callback: (consent, app) => { + console.log(consent, app); + this.message$.next('User consent for app ' + app.name + ': consent=' + consent); + }, + /* + 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, + }, + ], + }; + + constructor( + private translateService: TranslateService, + private authService: AuthService, + private ePersonService: EPersonDataService, + private cookieService: CookieService) { + super(); + } + + initialize() { + + /** + * Make sure the fallback language is english + */ + this.translateService.setDefaultLang(environment.defaultLanguage); + + const user$: Observable = this.authService.isAuthenticated() + .pipe( + take(1), + switchMap((loggedIn: boolean) => { + if (loggedIn) { + return this.authService.getAuthenticatedUserFromStore(); + } + return observableOf(undefined); + }), + take(1) + ); + + const translationServiceReady$ = this.translateService.get('loading.default').pipe(take(1)); + + observableCombineLatest(user$, translationServiceReady$) + .subscribe(([user, translation]: [EPerson, string]) => { + if (hasValue(user)) { + this.klaroConfig.callback = (consent, app) => this.updateSettingsForUsers(user); + this.klaroConfig.storageName = this.getStorageName(user.uuid); + } + + /** + * 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(); + }); + + } + + private getTitleTranslation(title: string) { + return cookieNameMessagePrefix + title; + } + + private getDescriptionTranslation(description: string) { + return cookieDescriptionMessagePrefix + description; + } + + 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() { + this.translate(this.klaroConfig.translations.en); + } + + 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; + } + + getSettingsForUser(user: EPerson) { + return JSON.parse(user.firstMetadataValue(COOKIE_MDFIELD)); + } + + setSettingsForUser(user: EPerson, config: object) { + user.setMetadata(COOKIE_MDFIELD, undefined, JSON.stringify(config)); + this.ePersonService.update(user); + } + + restoreSettingsForUsers(user: EPerson) { + console.log('restore klaro', user); + this.cookieService.set(this.getStorageName(user.uuid), this.getSettingsForUser(user)); + } + + updateSettingsForUsers(user: EPerson) { + console.log('update klaro', user); + this.setSettingsForUser(user, this.cookieService.get(this.getStorageName(user.uuid))) + } + + getStorageName(identifier: string) { + return 'klaro-' + identifier + } +} diff --git a/src/app/shared/cookies/klaro.effects.ts b/src/app/shared/cookies/klaro.effects.ts new file mode 100644 index 0000000000..5409779978 --- /dev/null +++ b/src/app/shared/cookies/klaro.effects.ts @@ -0,0 +1,3 @@ +export class KlaroEffects { + +} diff --git a/src/app/shared/cookies/klaro.service.ts b/src/app/shared/cookies/klaro.service.ts index ffae51969c..1e4f348d2b 100644 --- a/src/app/shared/cookies/klaro.service.ts +++ b/src/app/shared/cookies/klaro.service.ts @@ -1,317 +1,7 @@ import { Injectable } from '@angular/core'; -import * as Klaro from 'klaro' -import { BehaviorSubject, Observable } from 'rxjs'; -import { TOKENITEM } from '../../core/auth/models/auth-token-info.model'; -import { AuthService, IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; -import { LANG_COOKIE } from '../../core/locale/locale.service'; -import { TranslateService } from '@ngx-translate/core'; -import { environment } from '../../../environments/environment'; -import { map, switchMap, take } from 'rxjs/operators'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; - -export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; -export const COOKIE_MDFIELD = 'dspace.agreements.cookies'; -const cookieNameMessagePrefix = 'cookies.consent.app.title.'; -const cookieDescriptionMessagePrefix = 'cookies.consent.app.description.'; -const cookiePurposeMessagePrefix = 'cookies.consent.purpose.'; @Injectable() -export class KlaroService { - - message$: BehaviorSubject = new BehaviorSubject(''); - - klaroConfig = { - storageName: 'klaro-anonymous', - - 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', - 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: 'token_item', - purposes: ['authentication'], - required: true, - cookies: [ - TOKENITEM - ] - }, - { - name: 'impersonation', - purposes: ['authentication'], - required: true, - cookies: [ - IMPERSONATING_COOKIE - ] - }, - { - name: 'redirect', - purposes: ['authentication'], - required: true, - cookies: [ - REDIRECT_COOKIE - ] - }, - { - name: 'language', - purposes: ['preferences'], - required: true, - cookies: [ - LANG_COOKIE - ] - }, - { - name: 'klaro', - purposes: ['acknowledgement'], - required: true, - cookies: [ - [/^klaro-.+$/], - ] - }, - { - name: 'has_agreed_end_user', - purposes: ['acknowledgement'], - required: true, - cookies: [ - HAS_AGREED_END_USER - ] - }, - { - name: 'google-analytics', - purposes: ['statistics'], - 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', - ], - - /* - You can define an optional callback function that will be called each time the - consent state for the given app changes. The consent value will be passed as the - first parameter to the function (true=consented). The `app` config will be - passed as the second parameter. - */ - callback: (consent, app) => { - console.log(consent, app); - this.message$.next('User consent for app ' + app.name + ': consent=' + consent); - }, - /* - 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, - }, - ], - /* - You can define an optional callback function that will be called each time the - consent state for any given app changes. The consent value will be passed as the - first parameter to the function (true=consented). The `app` config will be - passed as the second parameter. - */ - callback: (consent, app) => { - - /* - You can define an optional callback function that will be called each time the - consent state for any given app changes. The consent value will be passed as the - first parameter to the function (true=consented). The `app` config will be - passed as the second parameter. - */ - console.log( - 'User consent for app ' + app.name + ': consent=' + consent - ); - // this.updateUserCookieConsent(); - }, - }; - - constructor( - private translateService: TranslateService, - private authService: AuthService) { - } - - initialize() { - - /** - * Make sure the fallback language is english - */ - this.translateService.setDefaultLang(environment.defaultLanguage); - - const storageName$: Observable = this.authService.isAuthenticated() - .pipe( - take(1), - switchMap((loggedIn: boolean) => { - if (loggedIn) { - return this.authService.getAuthenticatedUserFromStore().pipe(map((user: EPerson) => 'klaro-' + user.uuid), take(1)) - } else { - return observableOf('klaro-anonymous') - } - }) - ); - const translationServiceReady$ = this.translateService.get('loading.default').pipe(take(1)); - - observableCombineLatest(storageName$, translationServiceReady$) - .subscribe(([name, translation]: string[]) => { - this.klaroConfig.storageName = name; - - /** - * 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(); - }); - - } - - private getTitleTranslation(title: string) { - return cookieNameMessagePrefix + title; - } - - private getDescriptionTranslation(description: string) { - return cookieDescriptionMessagePrefix + description; - } - - 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() { - this.translate(this.klaroConfig.translations.en); - } - - 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; - } - - getSettingsForUser(user: EPerson) { - return JSON.parse(user.firstMetadataValue(COOKIE_MDFIELD)); - } - - setSettingsForUser(user: EPerson, config: object) { - return user.setMetadata(COOKIE_MDFIELD, undefined, JSON.stringify(config)); - } +export abstract class KlaroService { + abstract initialize(); + abstract showSettings(); } diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index e00f998ba6..fad4a07b9d 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -21,6 +21,7 @@ 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 } from '../../app/core/services/browser-hard-redirect.service'; @@ -86,7 +87,7 @@ export function locationProvider(): Location { }, { provide: KlaroService, - useClass: KlaroService + useClass: BrowserKlaroService }, { provide: SubmissionService, diff --git a/tsconfig.server.json b/tsconfig.server.json index 979f5c2743..1329b32ace 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -6,15 +6,5 @@ }, "angularCompilerOptions": { "entryModule": "./src/modules/app/server-app.module#ServerAppModule" - }, - "exclude": [ - "src/test.ts", - "src/**/*.spec.ts", - "src/**/*.mock.ts", - "src/**/*.test.ts", - "src/**/*.stub.ts", - "src/**/testing/*", - "src/**/mocks/*", - "node_modules/klaro/*" - ], + } } diff --git a/webpack.server.config.js b/webpack.server.config.js index e762c14cce..7df6d6b83c 100644 --- a/webpack.server.config.js +++ b/webpack.server.config.js @@ -25,11 +25,6 @@ module.exports = { module: { noParse: /polyfills-.*\.js/, rules: [ - { - test: /\.js$/, - exclude: [/node_modules\/klaro/], - }, - { test: /\.ts$/, loader: 'ts-loader', options: { diff --git a/webpack/webpack.prod.ts b/webpack/webpack.prod.ts index ddb7f03403..e9fba2e65e 100644 --- a/webpack/webpack.prod.ts +++ b/webpack/webpack.prod.ts @@ -18,15 +18,6 @@ module.exports = Object.assign({}, commonExports, { recordsOutputPath: projectRoot('webpack.records.json'), entry: buildRoot('./main.server.ts'), target: 'node', - module: { - rules: [ - ...commonExports.module.rules, - { - test: /\.js$/, - exclude: [/node_modules\/klaro/], - } - ] - }, externals: [nodeExternals({ whitelist: [ /@angular/, diff --git a/yarn.lock b/yarn.lock index 5f918fd7e0..b06ce40b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6165,10 +6165,10 @@ 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.5.34: - version "0.5.34" - resolved "https://registry.yarnpkg.com/klaro/-/klaro-0.5.34.tgz#0b524be96a1bb177fe88ff2603e1be75f494fc98" - integrity sha512-M6KHqlBWpMyYoxOK1icoJMeYsaPT7YhzJIAQ3wdxZWGgBc0sV7xQsf0PsgMUVnuTD0AeC58QegCGEv0qYeq4gw== +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" From 19ca179e7a62353d69b39abdfcd9778155e32214 Mon Sep 17 00:00:00 2001 From: lotte Date: Mon, 31 Aug 2020 16:02:26 +0200 Subject: [PATCH 07/14] commit before rebase --- src/app/app.component.ts | 10 +++++++--- .../shared/cookies/browser-klaro.service.ts | 19 ++++++++----------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index dc164476d4..318bad8895 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { delay, map, distinctUntilChanged } from 'rxjs/operators'; +import { delay, map, distinctUntilChanged, filter, take } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -84,7 +84,6 @@ export class AppComponent implements OnInit, AfterViewInit { // set the current language code this.localeService.setCurrentLanguageCode(); - this.initializeKlaro(); angulartics2GoogleAnalytics.startTracking(); angulartics2DSpace.startTracking(); @@ -103,6 +102,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;`); @@ -150,7 +155,6 @@ export class AppComponent implements OnInit, AfterViewInit { this.isLoading$.next(false); } }); - this.translate.get('loading.default').subscribe(t => console.log(t)); } @HostListener('window:resize', ['$event']) diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index d62e0a29cd..7df91d48e1 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -176,17 +176,6 @@ export class BrowserKlaroService extends KlaroService { // */ // 'piwik_ignore', ], - - /* - You can define an optional callback function that will be called each time the - consent state for the given app changes. The consent value will be passed as the - first parameter to the function (true=consented). The `app` config will be - passed as the second parameter. - */ - callback: (consent, app) => { - console.log(consent, app); - this.message$.next('User consent for app ' + app.name + ': consent=' + consent); - }, /* 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 @@ -217,6 +206,7 @@ export class BrowserKlaroService extends KlaroService { .pipe( take(1), switchMap((loggedIn: boolean) => { + console.log('loggedIn', loggedIn); if (loggedIn) { return this.authService.getAuthenticatedUserFromStore(); } @@ -232,6 +222,13 @@ export class BrowserKlaroService extends KlaroService { if (hasValue(user)) { this.klaroConfig.callback = (consent, app) => this.updateSettingsForUsers(user); this.klaroConfig.storageName = this.getStorageName(user.uuid); + + const anonCookie = this.cookieService.get(this.getStorageName('anonymous')); + if (hasValue(this.getSettingsForUser(user))) { + this.restoreSettingsForUsers(user); + } else if (hasValue(anonCookie)) { + this.cookieService.set(this.getStorageName(user.uuid), anonCookie); + } } /** From c19f49fb968efffa96e7dd187fcea253dfcdbba6 Mon Sep 17 00:00:00 2001 From: lotte Date: Tue, 1 Sep 2020 17:59:34 +0200 Subject: [PATCH 08/14] added tests --- src/app/app.component.ts | 1 - src/app/core/data/data.service.ts | 30 +- .../cookies/browser-klaro.service.spec.ts | 202 +++++++++++++- .../shared/cookies/browser-klaro.service.ts | 257 ++++-------------- src/app/shared/cookies/klaro-configuration.ts | 166 +++++++++++ src/app/shared/cookies/klaro.effects.ts | 3 - .../shared/mocks/translate.service.mock.ts | 3 +- 7 files changed, 441 insertions(+), 221 deletions(-) create mode 100644 src/app/shared/cookies/klaro-configuration.ts delete mode 100644 src/app/shared/cookies/klaro.effects.ts diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 318bad8895..43ae0534ad 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -84,7 +84,6 @@ export class AppComponent implements OnInit, AfterViewInit { // set the current language code this.localeService.setCurrentLanguageCode(); - angulartics2GoogleAnalytics.startTracking(); angulartics2DSpace.startTracking(); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 0d818f2030..9c4a95b1b9 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take, tap } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -378,6 +378,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 * @@ -406,18 +414,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/shared/cookies/browser-klaro.service.spec.ts b/src/app/shared/cookies/browser-klaro.service.spec.ts index e33f497d73..51da132173 100644 --- a/src/app/shared/cookies/browser-klaro.service.spec.ts +++ b/src/app/shared/cookies/browser-klaro.service.spec.ts @@ -1,12 +1,206 @@ import { TestBed } from '@angular/core/testing'; -import { BrowserKlaroService } from './browser-klaro.service'; +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('KlaroService', () => { - beforeEach(() => TestBed.configureTestingModule({})); +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', () => { - const service: BrowserKlaroService = TestBed.get(BrowserKlaroService); expect(service).toBeTruthy(); }); + + 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 index 7df91d48e1..88ebead410 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -1,191 +1,29 @@ import { Injectable } from '@angular/core'; import * as Klaro from 'klaro' -import { BehaviorSubject, Observable } from 'rxjs'; -import { TOKENITEM } from '../../core/auth/models/auth-token-info.model'; -import { AuthService, IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; -import { LANG_COOKIE } from '../../core/locale/locale.service'; +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 { map, switchMap, take, tap } from 'rxjs/operators'; +import { switchMap, take } from 'rxjs/operators'; import { EPerson } from '../../core/eperson/models/eperson.model'; -import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; import { KlaroService } from './klaro.service'; -import { hasValue } from '../empty.util'; +import { hasValue, isNotEmpty } from '../empty.util'; import { CookieService } from '../../core/services/cookie.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { cloneDeep } from 'lodash'; +import { klaroConfiguration } from './klaro-configuration'; +import { Operation } from 'fast-json-patch'; export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; export const COOKIE_MDFIELD = 'dspace.agreements.cookies'; +export const ANONYMOUS_STORAGE_NAME_KLARO = 'klaro-anonymous'; const cookieNameMessagePrefix = 'cookies.consent.app.title.'; const cookieDescriptionMessagePrefix = 'cookies.consent.app.description.'; const cookiePurposeMessagePrefix = 'cookies.consent.purpose.'; @Injectable() export class BrowserKlaroService extends KlaroService { - - message$: BehaviorSubject = new BehaviorSubject(''); - - klaroConfig: any = { - storageName: this.getStorageName('anonymous'), - - 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', - 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: 'token_item', - purposes: ['authentication'], - required: true, - cookies: [ - TOKENITEM - ] - }, - { - name: 'impersonation', - purposes: ['authentication'], - required: true, - cookies: [ - IMPERSONATING_COOKIE - ] - }, - { - name: 'redirect', - purposes: ['authentication'], - required: true, - cookies: [ - REDIRECT_COOKIE - ] - }, - { - name: 'language', - purposes: ['preferences'], - required: true, - cookies: [ - LANG_COOKIE - ] - }, - { - name: 'klaro', - purposes: ['acknowledgement'], - required: true, - cookies: [ - [/^klaro-.+$/], - ] - }, - { - name: 'has_agreed_end_user', - purposes: ['acknowledgement'], - required: true, - cookies: [ - HAS_AGREED_END_USER - ] - }, - { - name: 'google-analytics', - purposes: ['statistics'], - 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, - }, - ], - }; + klaroConfig = klaroConfiguration; constructor( private translateService: TranslateService, @@ -196,39 +34,17 @@ export class BrowserKlaroService extends KlaroService { } initialize() { - - /** - * Make sure the fallback language is english - */ this.translateService.setDefaultLang(environment.defaultLanguage); - const user$: Observable = this.authService.isAuthenticated() - .pipe( - take(1), - switchMap((loggedIn: boolean) => { - console.log('loggedIn', loggedIn); - if (loggedIn) { - return this.authService.getAuthenticatedUserFromStore(); - } - return observableOf(undefined); - }), - take(1) - ); + 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.klaroConfig.callback = (consent, app) => this.updateSettingsForUsers(user); - this.klaroConfig.storageName = this.getStorageName(user.uuid); - - const anonCookie = this.cookieService.get(this.getStorageName('anonymous')); - if (hasValue(this.getSettingsForUser(user))) { - this.restoreSettingsForUsers(user); - } else if (hasValue(anonCookie)) { - this.cookieService.set(this.getStorageName(user.uuid), anonCookie); - } + this.initializeUser(user); } /** @@ -248,6 +64,33 @@ export class BrowserKlaroService extends KlaroService { } + private initializeUser(user: EPerson) { + this.klaroConfig.callback = (consent, app) => this.updateSettingsForUsers(user); + 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); + } + } + + private getUser$() { + return this.authService.isAuthenticated() + .pipe( + take(1), + switchMap((loggedIn: boolean) => { + if (loggedIn) { + return this.authService.getAuthenticatedUserFromStore(); + } + return observableOf(undefined); + }), + take(1) + ); + } + private getTitleTranslation(title: string) { return cookieNameMessagePrefix + title; } @@ -283,6 +126,11 @@ export class BrowserKlaroService extends KlaroService { * 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); } @@ -297,21 +145,30 @@ export class BrowserKlaroService extends KlaroService { } getSettingsForUser(user: EPerson) { - return JSON.parse(user.firstMetadataValue(COOKIE_MDFIELD)); + const mdValue = user.firstMetadataValue(COOKIE_MDFIELD); + return hasValue(mdValue) ? JSON.parse(mdValue) : undefined; } setSettingsForUser(user: EPerson, config: object) { user.setMetadata(COOKIE_MDFIELD, undefined, JSON.stringify(config)); - this.ePersonService.update(user); + this.ePersonService.createPatchFromCache(user) + .pipe( + take(1), + switchMap((operations: Operation[]) => { + if (isNotEmpty(operations)) { + return this.ePersonService.patch(user, operations) + } + return observableOf(undefined) + } + ) + ).subscribe(); } restoreSettingsForUsers(user: EPerson) { - console.log('restore klaro', user); this.cookieService.set(this.getStorageName(user.uuid), this.getSettingsForUser(user)); } updateSettingsForUsers(user: EPerson) { - console.log('update klaro', user); this.setSettingsForUser(user, this.cookieService.get(this.getStorageName(user.uuid))) } diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts new file mode 100644 index 0000000000..b97bede986 --- /dev/null +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -0,0 +1,166 @@ +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'; +import { ANONYMOUS_STORAGE_NAME_KLARO, HAS_AGREED_END_USER } from './browser-klaro.service'; + +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', + 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: 'token_item', + purposes: ['authentication'], + required: true, + cookies: [ + TOKENITEM + ] + }, + { + name: 'impersonation', + purposes: ['authentication'], + required: true, + cookies: [ + IMPERSONATING_COOKIE + ] + }, + { + name: 'redirect', + purposes: ['authentication'], + required: true, + cookies: [ + REDIRECT_COOKIE + ] + }, + { + name: 'language', + purposes: ['preferences'], + required: true, + cookies: [ + LANG_COOKIE + ] + }, + { + name: 'klaro', + purposes: ['acknowledgement'], + required: true, + cookies: [ + [/^klaro-.+$/], + ] + }, + { + name: 'has_agreed_end_user', + purposes: ['acknowledgement'], + required: true, + cookies: [ + HAS_AGREED_END_USER + ] + }, + { + name: 'google-analytics', + purposes: ['statistics'], + 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.effects.ts b/src/app/shared/cookies/klaro.effects.ts deleted file mode 100644 index 5409779978..0000000000 --- a/src/app/shared/cookies/klaro.effects.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class KlaroEffects { - -} 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') }); } From 8d29090f248dfce063c3a9c2575c19076a00150a Mon Sep 17 00:00:00 2001 From: lotte Date: Thu, 3 Sep 2020 10:17:03 +0200 Subject: [PATCH 09/14] small fixes --- .../cookies/browser-klaro.service.spec.ts | 33 ++++++++++++++++++- .../shared/cookies/browser-klaro.service.ts | 6 ++-- src/app/shared/cookies/klaro-configuration.ts | 4 ++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/app/shared/cookies/browser-klaro.service.spec.ts b/src/app/shared/cookies/browser-klaro.service.spec.ts index 51da132173..2654dd65c7 100644 --- a/src/app/shared/cookies/browser-klaro.service.spec.ts +++ b/src/app/shared/cookies/browser-klaro.service.spec.ts @@ -95,6 +95,38 @@ describe('BrowserKlaroService', () => { 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(); @@ -203,4 +235,3 @@ describe('BrowserKlaroService', () => { }); }); }); - diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index 88ebead410..8ca495de36 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -11,12 +11,12 @@ import { hasValue, isNotEmpty } from '../empty.util'; import { CookieService } from '../../core/services/cookie.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { cloneDeep } from 'lodash'; -import { klaroConfiguration } from './klaro-configuration'; +import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration'; import { Operation } from 'fast-json-patch'; export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; export const COOKIE_MDFIELD = 'dspace.agreements.cookies'; -export const ANONYMOUS_STORAGE_NAME_KLARO = 'klaro-anonymous'; + const cookieNameMessagePrefix = 'cookies.consent.app.title.'; const cookieDescriptionMessagePrefix = 'cookies.consent.app.description.'; const cookiePurposeMessagePrefix = 'cookies.consent.purpose.'; @@ -34,6 +34,7 @@ export class BrowserKlaroService extends KlaroService { } initialize() { + console.log(this.klaroConfig) this.translateService.setDefaultLang(environment.defaultLanguage); const user$: Observable = this.getUser$(); @@ -43,6 +44,7 @@ export class BrowserKlaroService extends KlaroService { observableCombineLatest(user$, translationServiceReady$) .subscribe(([user, translation]: [EPerson, string]) => { user = cloneDeep(user); + if (hasValue(user)) { this.initializeUser(user); } diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index b97bede986..8534fc0b4b 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -1,7 +1,9 @@ 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'; -import { ANONYMOUS_STORAGE_NAME_KLARO, HAS_AGREED_END_USER } from './browser-klaro.service'; +import { HAS_AGREED_END_USER } from './browser-klaro.service'; + +export const ANONYMOUS_STORAGE_NAME_KLARO = 'klaro-anonymous'; export const klaroConfiguration: any = { storageName: ANONYMOUS_STORAGE_NAME_KLARO, From d7f6885232ecbcef368ef3c8fb7354d3ff28b0a1 Mon Sep 17 00:00:00 2001 From: lotte Date: Thu, 3 Sep 2020 11:37:33 +0200 Subject: [PATCH 10/14] updated consent configuration --- src/app/shared/cookies/klaro-configuration.ts | 40 ++++---------- src/assets/i18n/en.json5 | 52 ++++++------------- 2 files changed, 25 insertions(+), 67 deletions(-) diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index 8534fc0b4b..45b5f8013f 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -57,6 +57,7 @@ export const klaroConfiguration: any = { }, 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' @@ -74,56 +75,35 @@ export const klaroConfiguration: any = { }, apps: [ { - name: 'token_item', - purposes: ['authentication'], - required: true, - cookies: [ - TOKENITEM - ] - }, - { - name: 'impersonation', - purposes: ['authentication'], - required: true, - cookies: [ - IMPERSONATING_COOKIE - ] - }, - { - name: 'redirect', - purposes: ['authentication'], + name: 'authentication', + purposes: ['functional'], required: true, cookies: [ + TOKENITEM, + IMPERSONATING_COOKIE, REDIRECT_COOKIE ] }, { - name: 'language', - purposes: ['preferences'], + name: 'preferences', + purposes: ['functional'], required: true, cookies: [ LANG_COOKIE ] }, { - name: 'klaro', - purposes: ['acknowledgement'], + name: 'acknowledgement', + purposes: ['functional'], required: true, cookies: [ [/^klaro-.+$/], - ] - }, - { - name: 'has_agreed_end_user', - purposes: ['acknowledgement'], - required: true, - cookies: [ HAS_AGREED_END_USER ] }, { name: 'google-analytics', - purposes: ['statistics'], + purposes: ['statistical'], required: false, cookies: [ // /* diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 8beb133758..bcb58a6f17 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -951,11 +951,13 @@ "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: {purposes}.
To learn more, please read our {privacyPolicy}.", + "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", @@ -969,56 +971,32 @@ - "cookies.consent.app.title.token_item": "User Token", + "cookies.consent.app.title.authentication": "Authentication", - "cookies.consent.app.description.token_item": "User login identification", + "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.impersonation": "Impersonation", + "cookies.consent.app.title.acknowledgement": "Acknowledgement", - "cookies.consent.app.description.impersonation": "Switching users", - - - - "cookies.consent.app.title.redirect": "Redirect", - - "cookies.consent.app.description.redirect": "", - - - - "cookies.consent.app.title.language": "Language", - - "cookies.consent.app.description.language": "", - - - - "cookies.consent.app.title.klaro": "Klaro", - - "cookies.consent.app.description.klaro": "Cookie consent preferences", - - - - "cookies.consent.app.title.has_agreed_end_user": "Has agreed end user", - - "cookies.consent.app.description.has_agreed_end_user": "", + "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": "", + "cookies.consent.app.description.google-analytics": "Required for storing statistical data", - "cookies.consent.purpose.authentication": "Authentication", - - "cookies.consent.purpose.statistics": "Statistics", - - "cookies.consent.purpose.preferences": "Preferences", - - "cookies.consent.purpose.acknowledgement": "Acknowledgement", + "cookies.consent.purpose.functional": "Functional", + "cookies.consent.purpose.statistical": "Statistical", "curation-task.task.checklinks.label": "Check Links in Metadata", From 48ffb2105fdbef0765fdbf90b6c6098f978fa84e Mon Sep 17 00:00:00 2001 From: lotte Date: Fri, 4 Sep 2020 12:30:29 +0200 Subject: [PATCH 11/14] added missing typedoc --- .../shared/cookies/browser-klaro.service.ts | 78 ++++++++++++++++++- src/app/shared/cookies/klaro-configuration.ts | 7 ++ src/app/shared/cookies/klaro.service.ts | 10 +++ src/assets/i18n/en.json5 | 2 +- 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index 8ca495de36..bf5a51e674 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -14,15 +14,39 @@ import { cloneDeep } from 'lodash'; import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration'; import { Operation } from 'fast-json-patch'; +/** + * Cookie for has_agreed_end_user + */ export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; + +/** + * 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.'; +/** + * 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( @@ -32,9 +56,14 @@ export class BrowserKlaroService extends KlaroService { 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() { - console.log(this.klaroConfig) this.translateService.setDefaultLang(environment.defaultLanguage); const user$: Observable = this.getUser$(); @@ -66,6 +95,10 @@ export class BrowserKlaroService extends KlaroService { } + /** + * Initialize configuration for the logged in user + * @param user The authenticated user + */ private initializeUser(user: EPerson) { this.klaroConfig.callback = (consent, app) => this.updateSettingsForUsers(user); this.klaroConfig.storageName = this.getStorageName(user.uuid); @@ -79,6 +112,10 @@ export class BrowserKlaroService extends KlaroService { } } + /** + * Retrieves the currently logged in user + * Returns undefined when no one is logged in + */ private getUser$() { return this.authService.isAuthenticated() .pipe( @@ -93,14 +130,26 @@ export class BrowserKlaroService extends KlaroService { ); } + /** + * 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; } @@ -136,6 +185,10 @@ export class BrowserKlaroService extends KlaroService { 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); @@ -146,11 +199,20 @@ export class BrowserKlaroService extends KlaroService { 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) { user.setMetadata(COOKIE_MDFIELD, undefined, JSON.stringify(config)); this.ePersonService.createPatchFromCache(user) @@ -166,14 +228,26 @@ export class BrowserKlaroService extends KlaroService { ).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 index 45b5f8013f..04a870a6f4 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -3,8 +3,15 @@ import { IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.serv import { LANG_COOKIE } from '../../core/locale/locale.service'; import { HAS_AGREED_END_USER } from './browser-klaro.service'; +/** + * 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, diff --git a/src/app/shared/cookies/klaro.service.ts b/src/app/shared/cookies/klaro.service.ts index 1e4f348d2b..64dee85b65 100644 --- a/src/app/shared/cookies/klaro.service.ts +++ b/src/app/shared/cookies/klaro.service.ts @@ -1,7 +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/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index bcb58a6f17..3b5dc0d9ee 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -990,7 +990,7 @@ "cookies.consent.app.title.google-analytics": "Google Analytics", - "cookies.consent.app.description.google-analytics": "Required for storing statistical data", + "cookies.consent.app.description.google-analytics": "Allows us to track statistical data", From 90f0597baa8ebfdb811ce700dc73e0828c704c5c Mon Sep 17 00:00:00 2001 From: lotte Date: Fri, 4 Sep 2020 12:58:50 +0200 Subject: [PATCH 12/14] bugfix for users without metadata value for cookie --- src/app/shared/cookies/browser-klaro.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index bf5a51e674..659c74552a 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -7,7 +7,7 @@ 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 { hasValue, isEmpty, isNotEmpty } from '../empty.util'; import { CookieService } from '../../core/services/cookie.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { cloneDeep } from 'lodash'; @@ -214,7 +214,11 @@ export class BrowserKlaroService extends KlaroService { * @param config The consent settings for the user to save */ setSettingsForUser(user: EPerson, config: object) { - user.setMetadata(COOKIE_MDFIELD, undefined, JSON.stringify(config)); + if (isNotEmpty(config)) { + user.setMetadata(COOKIE_MDFIELD, undefined, JSON.stringify(config)); + } else { + user.removeMetadata(COOKIE_MDFIELD); + } this.ePersonService.createPatchFromCache(user) .pipe( take(1), From d4027cc1d111c8207cf207e0c605be06f42cbb82 Mon Sep 17 00:00:00 2001 From: lotte Date: Fri, 4 Sep 2020 14:44:00 +0200 Subject: [PATCH 13/14] debounce for update requests --- src/app/footer/footer.component.scss | 23 +++++++++++-------- .../shared/cookies/browser-klaro.service.ts | 15 ++++++------ src/app/shared/cookies/klaro-configuration.ts | 6 ++++- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/app/footer/footer.component.scss b/src/app/footer/footer.component.scss index c8adf9909f..fb51fc258d 100644 --- a/src/app/footer/footer.component.scss +++ b/src/app/footer/footer.component.scss @@ -18,20 +18,23 @@ $footer-logo-height: 55px; height: $footer-logo-height; } - ul li { + ul { padding-top: $spacer * 0.5; - a { - color: inherit - } - - &:not(:last-child) { - &:after { - content: ''; - border-right: 1px map-get($theme-colors, secondary) solid; - margin: 0 $spacer/2; + 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/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index 659c74552a..b3488105f4 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -7,18 +7,13 @@ 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, isEmpty, isNotEmpty } from '../empty.util'; +import { hasValue, isNotEmpty } from '../empty.util'; import { CookieService } from '../../core/services/cookie.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, debounce } from 'lodash'; import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration'; import { Operation } from 'fast-json-patch'; -/** - * Cookie for has_agreed_end_user - */ -export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; - /** * Metadata field to store a user's cookie consent preferences in */ @@ -39,6 +34,10 @@ const cookieDescriptionMessagePrefix = 'cookies.consent.app.description.'; */ 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 */ @@ -100,7 +99,7 @@ export class BrowserKlaroService extends KlaroService { * @param user The authenticated user */ private initializeUser(user: EPerson) { - this.klaroConfig.callback = (consent, app) => this.updateSettingsForUsers(user); + 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); diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index 04a870a6f4..f57146c674 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -1,7 +1,11 @@ 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'; -import { HAS_AGREED_END_USER } from './browser-klaro.service'; + +/** + * Cookie for has_agreed_end_user + */ +export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; /** * Storage name used to store klaro cookie From 99b84223e252045bb8b52ed34e41c0e8aca5c2d1 Mon Sep 17 00:00:00 2001 From: lotte Date: Tue, 8 Sep 2020 09:26:38 +0200 Subject: [PATCH 14/14] fixed lgtm error --- src/app/core/data/data.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 9c4a95b1b9..70c5b00441 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../shared/notifications/notifications.service';