From 9da76a160dd9acbf90992e8688e392cece66a92a Mon Sep 17 00:00:00 2001 From: Bilal Shaikh Date: Wed, 15 Dec 2021 16:29:43 -0500 Subject: [PATCH] refactor: organized analytics and consent services, styled consent components refactored consent and analytics to load config from configservice. Added better explanations and examples of the various cookies types. Cleaned up the styling of the consent window. Includes fixes by @jonrkarr closes #3790 Co-authored-by: Jonathan Karr --- apps/platform/src/index.html | 13 -- .../angular-analytics/.eslintrc.json | 1 + .../src/lib/analytics.service.spec.ts | 37 +++++- .../src/lib/analytics.service.ts | 89 +++++++++++--- .../src/lib/angular-analytics.module.ts | 9 +- .../src/lib/consent.service.spec.ts | 34 +++++- .../src/lib/consent.service.ts | 20 +-- .../cookie-consent.component.html | 114 +++++------------- .../cookie-consent.component.scss | 18 ++- .../cookie-consent.component.spec.ts | 28 ++++- .../cookie-consent.component.ts | 64 ++++++---- .../angular-analytics/src/lib/datamodel.ts | 81 +++++++++++++ .../angular/src/lib/config/config.service.ts | 1 + 13 files changed, 347 insertions(+), 162 deletions(-) diff --git a/apps/platform/src/index.html b/apps/platform/src/index.html index a79b7890e0..23aa0adac2 100644 --- a/apps/platform/src/index.html +++ b/apps/platform/src/index.html @@ -190,19 +190,6 @@ analytics_storage: 'denied', advertising_storage: 'denied', }); - - // Make sure any changes are properly reflected in the privacy policy/cookie policy. - - // Disable all advertising/tracking - gtag('set', 'allow_google_signals', false); - - gtag('config', 'G-G3CVBC0V5N', { - send_page_view: false, - cookie_prefix: 'goog_analytics_perf_', - link_attribution: { - cookie_name: 'goog_analytics_la_perf', - }, - }); { let service: AnalyticsService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + imports: [ + MatDialogModule, + MatExpansionModule, + MatSlideToggleModule, + RouterTestingModule, + NoopAnimationsModule, + ], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: undefined, + }, + Storage, + { + provide: ConfigService, + useValue: { + appName: 'testApp', + analyticsId: 'testId', + }, + }, + ], + }); service = TestBed.inject(AnalyticsService); }); - it('should be created', () => { expect(service).toBeTruthy(); }); diff --git a/libs/analytics/angular-analytics/src/lib/analytics.service.ts b/libs/analytics/angular-analytics/src/lib/analytics.service.ts index fd3f12fed6..ebede5b58b 100644 --- a/libs/analytics/angular-analytics/src/lib/analytics.service.ts +++ b/libs/analytics/angular-analytics/src/lib/analytics.service.ts @@ -1,12 +1,12 @@ /** - * @ File - analytics.service.ts - Client to google analytics. - * TODO : Manages consent automatically. - * + * @ File - analytics.service.ts - Client to google analytics. Automatically manages consent. * @ Author -Bilal Shaikh * Inspired heavily by https://www.dottedsquirrel.com/google-analytics-angular/ */ import { Injectable } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { ConfigService } from '@biosimulations/config/angular'; +import { Subscription } from 'rxjs'; import { ConsentService } from './consent.service'; import { Consent } from './datamodel'; @@ -16,34 +16,95 @@ declare let gtag: Function; providedIn: 'root', }) export class AnalyticsService { + private routerSubscription: Subscription | null = null; + private initPageView = false; + private analyticsId: string; public constructor( public router: Router, + private route: ActivatedRoute, + private config: ConfigService, + private consentService: ConsentService, ) { - this.router.events.subscribe((event) => { - if (event instanceof NavigationEnd) { - gtag('set', 'page', event.urlAfterRedirects); - gtag('send', 'pageview'); - } - }); + this.analyticsId = this.config.analyticsId; + // Make sure any changes are properly reflected in the privacy policy/cookie policy. + // Ordering is important here. - this.consentService.consent$.subscribe((consent: Consent) => { + // This should be first so we don't store any cookies until/unless we get consent + this.consentService.consent$.pipe().subscribe((consent: Consent) => { gtag('consent', 'update', consent); + // Disable all advertising/tracking + gtag('set', 'allow_google_signals', false); + + const send_pageview = consent.analytics_storage === 'granted'; + this.init(send_pageview); + + gtag('event', 'consent_recorded', { + eventValue: consent.analytics_storage, + eventLabel: 'consent_recorded', + }); + }); + } + + public async init(sendPageView: boolean): Promise { + // page views will be handled by analytics service. Give cookies clear names to ref in privacy policy. + + console.error('init'); + gtag('config', this.analyticsId, { + send_page_view: false, + cookie_prefix: 'goog_analytics_perf_', + link_attribution: { + cookie_name: 'goog_analytics_la_perf', + }, + }); + + // Only want to have one subscription to the router + if (!this.routerSubscription) { + console.error('subscribe'); + this.routerSubscription = this.router.events.subscribe((event) => { + if (event instanceof NavigationEnd) { + gtag('set', 'page_location', event.urlAfterRedirects); + // TODO get info from activated route + gtag('event', 'page_view'); + } + }); + } + // Only send page view once when module loads + if (sendPageView && !this.initPageView) { + gtag('event', 'page_view'); + this.initPageView = true; + } + } + + // https://developers.google.com/analytics/devguides/collection/gtagjs/events + //https://support.google.com/analytics/answer/1033068#Anatomy&zippy=%2Cin-this-article + public pageviewEmitter( + page_location: string, + page_path: string, + page_title: string, + ): void { + gtag('page_view', { + page_location, + page_path, + page_title, }); } public eventEmitter( eventName: string, - eventCategory: string, + eventCategory: EventCategory, eventAction: string, eventLabel: string | null = null, eventValue: number | null = null, - ) { + nonInteraction: boolean = false, + ): void { gtag('event', eventName, { eventCategory: eventCategory, eventLabel: eventLabel, eventAction: eventAction, - eventValue: eventValue, + value: eventValue, + non_interaction: nonInteraction, }); } } +type EventCategory = 'engagement' | 'custom'; diff --git a/libs/analytics/angular-analytics/src/lib/angular-analytics.module.ts b/libs/analytics/angular-analytics/src/lib/angular-analytics.module.ts index 267faa8c0f..86012278e5 100644 --- a/libs/analytics/angular-analytics/src/lib/angular-analytics.module.ts +++ b/libs/analytics/angular-analytics/src/lib/angular-analytics.module.ts @@ -18,14 +18,11 @@ import { MatButtonModule } from '@angular/material/button'; ], providers: [AnalyticsService], declarations: [CookieConsentComponent], - exports: [CookieConsentComponent], }) export class AngularAnalyticsModule { - constructor( + public constructor( + // Needs to be imported so DI can do its thing and run the constructors private consentService: ConsentService, - // Needs to be imported so DI can do its thing and run the constructor private analyticsService: AnalyticsService, - ) { - this.consentService.initConsent(); - } + ) {} } diff --git a/libs/analytics/angular-analytics/src/lib/consent.service.spec.ts b/libs/analytics/angular-analytics/src/lib/consent.service.spec.ts index 82e9dfa86f..45003cdb8e 100644 --- a/libs/analytics/angular-analytics/src/lib/consent.service.spec.ts +++ b/libs/analytics/angular-analytics/src/lib/consent.service.spec.ts @@ -1,12 +1,44 @@ import { TestBed } from '@angular/core/testing'; import { ConsentService } from './consent.service'; +import { + MatDialogModule, + MAT_DIALOG_DATA, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { Storage } from '@ionic/storage-angular'; +import { ConfigService } from '@biosimulations/config/angular'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; describe('ConsentService', () => { let service: ConsentService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + imports: [ + MatDialogModule, + MatExpansionModule, + MatSlideToggleModule, + NoopAnimationsModule, + ], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: undefined, + }, + Storage, + { + provide: ConfigService, + useValue: { + appName: 'testApp', + analyticsId: 'testId', + }, + }, + ], + }); service = TestBed.inject(ConsentService); }); diff --git a/libs/analytics/angular-analytics/src/lib/consent.service.ts b/libs/analytics/angular-analytics/src/lib/consent.service.ts index e7c6ab0b47..fa71c757d6 100644 --- a/libs/analytics/angular-analytics/src/lib/consent.service.ts +++ b/libs/analytics/angular-analytics/src/lib/consent.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { Consent, ConsentRecord } from './datamodel'; +import { Consent, ConsentRecord, cookieConsentType } from './datamodel'; import { Storage } from '@ionic/storage-angular'; import { CookieConsentComponent } from './cookie-consent/cookie-consent.component'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; @Injectable({ providedIn: 'root', @@ -45,15 +45,15 @@ export class ConsentService { } private startConsentFlow(): void { - const dialogRef = this.dialog.open(CookieConsentComponent, { - hasBackdrop: true, - maxWidth: '900px', - - disableClose: true, - closeOnNavigation: false, - }); + const dialogRef: MatDialogRef = + this.dialog.open(CookieConsentComponent, { + hasBackdrop: true, + maxWidth: '900px', + disableClose: true, + closeOnNavigation: false, + }); dialogRef.afterClosed().subscribe((result) => { - if (result && result.performanceCookies) { + if (result && result.performance) { const consentRecord: ConsentRecord = { date: new Date().toISOString(), consent: { diff --git a/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.html b/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.html index 11ecbf7888..87f146de02 100644 --- a/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.html +++ b/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.html @@ -1,106 +1,58 @@

Privacy Settings

-

- To improve your experience with BioSimulations, we use cookies to store +

+ To improve your experience with {{ appName }}, we use cookies to store information about your preferences and to track your use of the site. This - information does not identify you personally or preserve and personal - information about you. However, it does allow us to improve the site and to - gather usage information for funding and future development. Because we - respect your right to privacy, you can opt-out of these sorts of information - collection by customizing your preferences below. + information does not identify you personally or preserve any personal + information about you. This allows us to improve the site and gather usage + information for funding and future development. Because we respect your right + to privacy, you can opt-out of this data

- - - Strictly necessary cookies - - - Enabled - - - - -

- {{ necessaryCookiesDescription }} -

- - - - -
- - - Performance/Analytics cookies + + + {{ cookie.displayName }} - {{ performanceCookies ? 'Enabled' : 'Disabled' }} + {{ cookie.toggleDisabled ? 'Always' : '' }} + {{ toggle.checked ? 'Enabled' : 'Disabled' }} -

{{ performanceCookiesDescription }}

+

+ {{ cookie.description }} +

- -
- - - Functional cookies - - + Learn more about our use of {{ cookie.displayName }} - {{ functionalCookies ? 'Enabled' : 'Disabled' }} - - - - -

{{ functionalCookiesDescription }}

- - - - -
- - - - Tracking cookies - - - Disabled - - - - -

{{ trackingCookiesDescription }}

- - -

- BioSimulations collects limited personal data to credit authors and improve - the platform. By using this site, you agree to our - terms of service, - privacy policy, and - use of cookies. + {{ appName }} collects limited personal data to credit authors and improve the + platform. By using this site, you agree to our + terms of service, + privacy policy, and + use of cookies.

- diff --git a/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.scss b/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.scss index 01444d535a..b6751f3199 100644 --- a/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.scss +++ b/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.scss @@ -1,7 +1,17 @@ -button { - width: 15%; - margin-left: 80%; +.submit-button { + float: right; +} +.notice { + margin-bottom: 1rem; } .terms-agreement { - margin: 1rem 1rem 1rem 1rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +::ng-deep .mat-content > mat-panel-title { + justify-content: flex-start; +} +::ng-deep .mat-content > mat-panel-description { + justify-content: flex-end; } diff --git a/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.spec.ts b/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.spec.ts index 533e38f75e..4f4b21cfb3 100644 --- a/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.spec.ts +++ b/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.spec.ts @@ -1,6 +1,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CookieConsentComponent } from './cookie-consent.component'; +import { + MatDialogModule, + MAT_DIALOG_DATA, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { ConfigService } from '@biosimulations/config/angular'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; describe('CookieConsentComponent', () => { let component: CookieConsentComponent; @@ -8,18 +17,27 @@ describe('CookieConsentComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ CookieConsentComponent ] + imports: [MatDialogModule, MatExpansionModule, MatSlideToggleModule, NoopAnimationsModule], + declarations: [ CookieConsentComponent ], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: undefined, + }, + { provide: ConfigService, useValue: { + appName: "testApp" + } }, + ], }) .compileComponents(); - }); - + }) beforeEach(() => { fixture = TestBed.createComponent(CookieConsentComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { expect(component).toBeTruthy(); }); -}); +}); \ No newline at end of file diff --git a/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.ts b/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.ts index 1592a91a95..6d4949ffc4 100644 --- a/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.ts +++ b/libs/analytics/angular-analytics/src/lib/cookie-consent/cookie-consent.component.ts @@ -1,6 +1,18 @@ -import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, +} from '@angular/core'; import { MatDialogRef } from '@angular/material/dialog'; -import { MatSlideToggleChange } from '@angular/material/slide-toggle'; +import { ConfigService } from '@biosimulations/config/angular'; +import { + Cookie, + cookieConsentType, + CookieType, + FunctionalCookie, + PerformanceCookie, + RequiredCookie, + TrackingCookie, +} from '../datamodel'; @Component({ selector: 'biosimulations-cookie-consent', @@ -9,38 +21,38 @@ import { MatSlideToggleChange } from '@angular/material/slide-toggle'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class CookieConsentComponent { - public performanceCookies = true; - public functionalCookies = true; - public necessaryCookiesDescription = 'We need these'; - public necessaryCookiesDocsURL = - 'https://www.biosimulations.org/cookies#neccesary-cookies'; - public performanceCookiesDescription = 'We want these'; - public performanceCookiesDocsURL = - 'https://www.biosimulations.org/cookies#performance-cookies'; - public functionalCookiesDescription = 'You probably want these'; - public functionalCookiesDocsURL = - 'https://www.biosimulations.org/cookies#functional-cookies'; - public trackingCookiesDescription = 'We never use these'; - public trackingCookiesDocsUrl = - 'https://www.biosimulations.org/cookies#tracking-cookies'; + public cookies: Cookie[] = [ + new RequiredCookie(), + new PerformanceCookie(), + new FunctionalCookie(), + new TrackingCookie(), + ]; - public constructor(public dialogRef: MatDialogRef) {} + public cookieConsent: cookieConsentType = { + required: true, + performance: true, + functionality: true, + tracking: false, + }; - public changePerf(event: MatSlideToggleChange): void { - this.performanceCookies = event.checked; - } - public changeFunc(event: MatSlideToggleChange): void { - this.functionalCookies = event.checked; + public appName: string; + public constructor( + public dialogRef: MatDialogRef, + private config: ConfigService, + ) { + this.appName = config.appName; } + public handleToggleClick(event: Event): void { // We do this so that toggling the button does not also open the accordion event.stopPropagation(); } + public handleToggleChange(type: CookieType, allowed: boolean): void { + this.cookieConsent[type] = allowed; + } + public submitConsent(): void { - this.dialogRef.close({ - performanceCookies: this.performanceCookies, - functionalCookies: this.functionalCookies, - }); + this.dialogRef.close(this.cookieConsent); } } diff --git a/libs/analytics/angular-analytics/src/lib/datamodel.ts b/libs/analytics/angular-analytics/src/lib/datamodel.ts index d03aa2fd26..099e54a303 100644 --- a/libs/analytics/angular-analytics/src/lib/datamodel.ts +++ b/libs/analytics/angular-analytics/src/lib/datamodel.ts @@ -1,3 +1,8 @@ +/** + * @File this is the internal datamodel for the consent/analytics service. + * In general, nothing from here should be exported from the library + */ + export interface Consent { ad_storage: 'granted' | 'denied'; analytics_storage: 'granted' | 'denied'; @@ -7,3 +12,79 @@ export interface ConsentRecord { consent: Consent; date: string; } +// https://gdpr.eu/cookies/ +export type CookieType = + | 'required' + | 'performance' + | 'functionality' + | 'tracking'; + +export enum CookieTypes { + required = 'required', + performance = 'performance', + functionality = 'functionality', + tracking = 'tracking', +} +export interface Cookie { + type: CookieType; + description: string; + externalLink: string; + displayName: string; + toggleAllowed: boolean; + toggleDisabled: boolean; +} + +export class RequiredCookie implements Cookie { + public type = CookieTypes.required; + public description = + 'These cookies are essential for the website to function correctly.\ + These cookies are first party cookies that contain no personal information\ + Examples include cookies that store your consent to the use of cookies or security cookies'; + public externalLink = + 'https://www.biosimulations.org/cookies#neccesary-cookies'; + public displayName = 'Strictly Necessary Cookies'; + public toggleAllowed = true; + public toggleDisabled = true; +} + +export class PerformanceCookie implements Cookie { + public type = CookieTypes.performance; + public description = + 'These cookies collect anonymous information about your use of the website.\ + They are first party cookies that collect information such as which pages you visit,\ + which features you use, and which links you click.\ + This information is aggregated and anonymized and cannot be traced to you individually '; + public externalLink = + 'https://www.biosimulations.org/cookies#performance-cookies'; + public displayName = 'Performance Cookies'; + public toggleAllowed = true; + public toggleDisabled = false; +} + +export class FunctionalCookie implements Cookie { + public type = CookieTypes.functionality; + public description = + 'These cookies are used to provide you with a more personalized experience.\ + They are first party cookies that contain information about your preferences.\ + Examples include cookies that remember your recent searches or keep you logged in.\ + These cookies do not contain any personal information'; + public externalLink = + 'https://www.biosimulations.org/cookies#functional-cookies'; + public displayName = 'Functional Cookies'; + public toggleAllowed = true; + public toggleDisabled = false; +} + +export class TrackingCookie implements Cookie { + public type = CookieTypes.tracking; + public description = + 'These cookies are generally third-party cookies that are used to track your online activity and behavior for marketing and advertising purposes.\ + We DO NOT USE any tracking cookies or third-party services to track your online activity and behavior.'; + public externalLink = + 'https://www.biosimulations.org/cookies#tracking-cookies'; + public displayName = 'Tracking Cookies'; + public toggleAllowed = false; + public toggleDisabled = true; +} + +export type cookieConsentType = { [key in CookieType]: boolean }; diff --git a/libs/config/angular/src/lib/config/config.service.ts b/libs/config/angular/src/lib/config/config.service.ts index 153be869c6..ea57fa6f1d 100644 --- a/libs/config/angular/src/lib/config/config.service.ts +++ b/libs/config/angular/src/lib/config/config.service.ts @@ -27,4 +27,5 @@ export class ConfigService { simulatorsNewIssueUrl!: string; simulatorsNewPullUrl!: string; appConfig!: any; + analyticsId!: string; }