Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cookie consent#45 #47

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 31 additions & 28 deletions app/components/app.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// https://github.com/EnCiv/civil-server/issues/45

'use strict'

import React from 'react'
import React, { useEffect, useState, useRef } from 'react'
import { hot } from 'react-hot-loader'
import WebComponents from '../web-components'
import Footer from './footer'
import { ErrorBoundary } from 'civil-client'
import { Helmet } from 'react-helmet'
import EncivCookies from './enciv-cookies'

const DynamicFontSizeHelmet =
typeof window === 'undefined'
Expand All @@ -21,33 +24,33 @@ const DynamicFontSizeHelmet =
)
: () => null

class App extends React.Component {
render() {
if (this.props.iota) {
var { iota, ...newProps } = this.props
Object.assign(newProps, this.props.iota)
return (
<ErrorBoundary>
<div style={{ position: 'relative' }}>
<Helmet>
<title>{iota?.subject || 'Candiate Conversations'}</title>
</Helmet>
<DynamicFontSizeHelmet />
<WebComponents key="web-component" webComponent={this.props.iota.webComponent} {...newProps} />
<Footer key="footer" />
</div>
</ErrorBoundary>
)
} else
return (
<ErrorBoundary>
<div style={{ position: 'relative' }}>
<div>Nothing Here</div>
<Footer />
</div>
</ErrorBoundary>
)
}
function App(props) {
var { iota, ...newProps } = props

if (iota) {
Object.assign(newProps, iota)
return (
<ErrorBoundary>
<div style={{ position: 'relative' }}>
<Helmet>
<title>{iota?.subject || 'Candiate Conversations'}</title>
</Helmet>
<DynamicFontSizeHelmet />
<EncivCookies user={newProps.user} />
<WebComponents key="web-component" webComponent={iota.webComponent} {...newProps} />
<Footer key="footer" />
</div>
</ErrorBoundary>
)
} else
return (
<ErrorBoundary>
<div style={{ position: 'relative' }}>
<div>Nothing Here</div>
<Footer />
</div>
</ErrorBoundary>
)
}

export default hot(module)(App)
173 changes: 173 additions & 0 deletions app/components/enciv-cookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import React, { useEffect, useState, useRef } from 'react'
import Helmet from 'react-helmet'
import * as CookieConsent from 'vanilla-cookieconsent'

const CConsentStyleHelmet = () => (
<Helmet>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orestbida/[email protected]/dist/cookieconsent.css" />
</Helmet>
)

function EncivCookies(props) {
const { user } = props
const [cookie, setCookie] = useState()
const hasMounted = useRef(false)

useEffect(() => {
// Prevent this running on the initial render
if (!hasMounted.current) {
hasMounted.current = true
return
}

const userId = user?.id || user?.tempId
const synuser = { synuser: { id: userId } }

const consent = CookieConsent.getCookie()

// Retrieve information from lookups and format
let formattedConsentData = []
for (const category of Object.keys(modalSections)) {
formattedConsentData.push({
category: category,
isGranted: consent.categories.includes(category),
terms: modalSections[category].description,
services: consent.services[category],
})
}

// Call the server to save consent to database
window.socket.emit('save-consent', synuser, formattedConsentData, () => {
console.log('Consent data successfully saved.')
})
}, [cookie])

useEffect(() => {
CookieConsent.run({
onFirstConsent: cookie => {
setCookie(cookie)
},
onChange: cookie => {
setCookie(cookie)
},
categories: consentCategories,
language: {
default: 'en',
translations: {
en: {
consentModal: {
title: 'We use cookies',
description: 'Cookie modal description',
acceptAllBtn: 'Accept all',
acceptNecessaryBtn: 'Reject all',
showPreferencesBtn: 'Manage Individual preferences',
},
preferencesModal: {
title: 'Manage cookie preferences',
acceptAllBtn: 'Accept all',
acceptNecessaryBtn: 'Reject all',
savePreferencesBtn: 'Accept current selection',
closeIconLabel: 'Close modal',
sections: [
...Object.values(modalSections),
{
title: 'More information',
description:
'For any queries in relation to my policy on cookies and your choices, please <a href="#contact-page">contact us</a>',
},
],
},
},
},
},
})
}, [])

// The sections that show in the consent modal
const modalSections = {
necessary: {
title: 'Strictly Necessary cookies',
description: 'These cookies are essential for the proper functioning of the website and cannot be disabled.',

//this field will generate a toggle linked to the 'necessary' category
linkedCategory: 'necessary',
},
analytics: {
title: 'Performance and Analytics',
description:
'These cookies collect information about how you use our website. All of the data is anonymized and cannot be used to identify you.',
linkedCategory: 'analytics',
},
}

// We can extend this by storing in the database
const services = {
necessary: [],
analytics: [
{
label: 'Google Analytics',
onAccept: () => {
if (window.process.env.GOOGLE_ANALYTICS) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this from process.env to window.process.env because it was getting a different value. I added a an issue to civil-client to fix the code in main to use process.env rather then global.env - but this fixes the immediate problem.

// using window.process becase there's a process.env that's different
window.dataLayer = window.dataLayer || []
window.gtag = function () {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('config', `${window.process.env.GOOGLE_ANALYTICS}`)
const script = document.createElement('script') // create a script DOM node
script.src = `https://www.googletagmanager.com/gtag/js?id=${window.process.env.GOOGLE_ANALYTICS}`
script.id = 'googletagmanager' // so we can find it and delete it if needed
document.head.appendChild(script)
}
},
onReject: () => {
delete window.dataLayer
delete window.gtag
const gtmElement = document.getElementById('googletagmanager')
if (gtmElement) gtmElement.remove()
},
},
],
}

/*
Format the services data lists for each category.

Was a bit hard to find documentation,
but this is the object structure for displaying individual services.
{
service1: {
label: 'service1',
onAccept: Func(),
onReject: Func(),
},
service2: {...}
...
}
*/

const consentCategories = {}
// Init the services lists
for (const key of Object.keys(services)) {
consentCategories[key] = {
services: services[key].reduce((result, service) => {
result[service.label] = { ...service }
return result
}, {}),
}

if (key === 'necessary') {
consentCategories[key].readOnly = true
consentCategories[key].enabled = true
}
}

return (
<div>
<CConsentStyleHelmet />
</div>
)
}

export default EncivCookies
131 changes: 131 additions & 0 deletions app/models/__tests__/consent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
const { Mongo } = require('@enciv/mongo-collections')
import { MongoMemoryServer } from 'mongodb-memory-server'
const Consent = require('../consent')

const USER_ID = '6667d5a33da5d19ddc304a6b'

// dummy out logger for tests
if (!global.logger) {
global.logger = console
}

let MemoryServer
beforeAll(async () => {
MemoryServer = await MongoMemoryServer.create()
const uri = MemoryServer.getUri()
await Mongo.connect(uri)
})

afterAll(async () => {
Mongo.disconnect()
MemoryServer.stop()
})

test('Test the database is empty on startup.', async () => {
const count = await Consent.count({})
expect(count).toBe(0)
})

test('Testing adding a consent obj.', async () => {
const aConsent = { who: { userId: USER_ID } }
const consent = await Consent.create(aConsent)
expect(consent).toMatchObject(aConsent)
})

test('Test consents exist in the DB.', async () => {
const consents = await Consent.find({}).toArray()
expect(consents.length).toBeGreaterThan(0)
// this will fail if there are other test running and putting things in the database
})

test('Test adding consent data.', async () => {
const aConsent = { who: { userId: USER_ID }, what: {} }
await Consent.create(aConsent)

const updatedDoc = await Consent.updateConsent({ userId: USER_ID }, [
{ category: 'ConsentOption1', isGranted: true, terms: 'By consenting, you agree to consent to this agreement.' },
])

expect(updatedDoc).toMatchObject({
_id: /./,
who: { userId: '6667d5a33da5d19ddc304a6b' },
what: {
ConsentOption1: {
isGranted: true,
consentDate: expect.any(Date),
terms: 'By consenting, you agree to consent to this agreement.',
history: [],
},
},
})
})

test('Test historical consent data is pushed.', async () => {
const aConsent = { who: { userId: USER_ID }, what: {} }
await Consent.create(aConsent)

const userIdQuery = { userId: USER_ID }

// Update the same consent option twice
await Consent.updateConsent(userIdQuery, [
{
category: 'ConsentOption1',
isGranted: true,
terms: 'By consenting a second time, you agree to consent to this agreement being pushed to the history.',
},
])

// Test adding multiple at once
await Consent.updateConsent(userIdQuery, [
{
category: 'ConsentOption2',
isGranted: true,
terms: "By consenting to another option, you agree there's two options now.",
},
{
category: 'ConsentOption3',
isGranted: false,
terms: "By consenting to a third option, you agree there's three options now.",
},
])

const updatedDoc = await Consent.updateConsent(userIdQuery, [
{ category: 'ConsentOption1', isGranted: false, terms: "By revoking your consent, you don't agree to consent." },
])

expect(updatedDoc).toMatchObject({
_id: /./,
who: { userId: '6667d5a33da5d19ddc304a6b' },
what: {
ConsentOption1: {
isGranted: false,
consentDate: expect.any(Date),
terms: "By revoking your consent, you don't agree to consent.",
history: [
{
isGranted: true,
consentDate: expect.any(Date),
terms: 'By consenting, you agree to consent to this agreement.',
},
{
isGranted: true,
consentDate: expect.any(Date),
terms: 'By consenting a second time, you agree to consent to this agreement being pushed to the history.',
},
],
},
ConsentOption2: {
isGranted: true,
consentDate: expect.any(Date),
terms: "By consenting to another option, you agree there's two options now.",
history: [],
},
ConsentOption3: {
isGranted: false,
consentDate: expect.any(Date),
terms: "By consenting to a third option, you agree there's three options now.",
history: [],
},
},
})
})
Loading