fixed SSR issue, added code for storing the users cookie consents and restoring them

This commit is contained in:
lotte
2020-08-28 16:55:56 +02:00
parent b3b934033c
commit f6e8448164
12 changed files with 342 additions and 348 deletions

View File

@@ -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",

View File

@@ -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',

View File

@@ -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',

View File

@@ -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();
});
});

View File

@@ -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<string> = new BehaviorSubject<string>('');
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<EPerson> = 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
}
}

View File

@@ -0,0 +1,3 @@
export class KlaroEffects {
}

View File

@@ -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<string> = new BehaviorSubject<string>('');
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<string> = 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();
}

View File

@@ -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,

View File

@@ -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/*"
],
}
}

View File

@@ -25,11 +25,6 @@ module.exports = {
module: {
noParse: /polyfills-.*\.js/,
rules: [
{
test: /\.js$/,
exclude: [/node_modules\/klaro/],
},
{
test: /\.ts$/, loader: 'ts-loader',
options: {

View File

@@ -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/,

View File

@@ -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"