From 1826888ea5a18fb55eac4a8ede2a7859b25b3cce Mon Sep 17 00:00:00 2001 From: Andrew Isherwood Date: Mon, 18 Jan 2021 12:13:12 +0000 Subject: [PATCH] Bug 27378: Add cookie consent display to OPAC This commit adds the display of the cookie consent bar and modal to the OPAC. - Adds a new JSConsents template plugin that enables a template to be populated with the contents of the ConsentJS syspref in a prepared, usable form - Adds a new cookieconsent.js script that drives the display and functionality of the cookie consent bar and modal - Adds styles for the cookie consent bar and modal - Adds the creation and population of the cookie consent bar and modal, if appropriate - Adds the ability for both authenticated and unauthenticated users to view and modify their consents either via "your consents" or a new unobtrusive "Your cookies" button in the bottom right of the screen. - Adds unit test for JSConsents.pm Signed-ff-by: Barry Cannon --- Koha/Template/Plugin/JSConsents.pm | 64 ++++++ .../opac-tmpl/bootstrap/css/src/opac.scss | 68 +++++++ .../bootstrap/en/includes/masthead.inc | 62 ++++++ .../bootstrap/en/includes/opac-bottom.inc | 14 ++ .../bootstrap/en/includes/usermenu.inc | 2 +- .../en/modules/opac-patron-consent.tt | 5 + .../opac-tmpl/bootstrap/js/cookieconsent.js | 183 ++++++++++++++++++ .../Koha/Template/Plugin/JSConsents.t | 25 +++ 8 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 Koha/Template/Plugin/JSConsents.pm create mode 100644 koha-tmpl/opac-tmpl/bootstrap/js/cookieconsent.js create mode 100755 t/db_dependent/Koha/Template/Plugin/JSConsents.t diff --git a/Koha/Template/Plugin/JSConsents.pm b/Koha/Template/Plugin/JSConsents.pm new file mode 100644 index 0000000000..a21ae178c3 --- /dev/null +++ b/Koha/Template/Plugin/JSConsents.pm @@ -0,0 +1,64 @@ +package Koha::Template::Plugin::JSConsents; + +# Copyright 2021 PTFS Europe +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Template::Plugin; +use base qw( Template::Plugin ); +use MIME::Base64 qw{ decode_base64 }; +use JSON qw{ decode_json }; + +use C4::Context; + +sub all { + my ( $self ) = @_; + my $consents = C4::Context->preference( 'ConsentJS' ); + if (length $consents > 0) { + my $decoded = decode_base64($consents); + return decode_json $decoded; + } else { + return []; + } +} + +1; + +=head1 NAME + +Koha::Template::Plugin::JSConsents - TT Plugin for Javascript consents +Provided by ConsentJS syspref + +=head1 SYNOPSIS + +[% USE JSConsents %] + +[% JSConsents.all() %] + +=head1 ROUTINES + +=head2 all + +In a template, you can get the all Javascript snippets +that require consent using the following TT: +[% JSConsents.all() %] +The consents are returned in an array of hashes + +=head1 AUTHOR + +Andrew Isherwood + +=cut diff --git a/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss b/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss index 747251450f..9c2d0ec4c9 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss +++ b/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss @@ -2872,4 +2872,72 @@ $star-selected: #EDB867; } } +/* Cookie consent bar */ +#cookieConsentBar { + display: none; + padding: 20px; + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: rgba(0,0,0,0.7); + color: rgba(255,255,255,0.9); + z-index: 1; + a:link, a:visited { + color: #c5ecff; + } + #consentButtons { + margin-top: 10px; + text-align: right; + } + button:not(:last-child) { + margin-right: 10px; + } +} + +/* Cookie consent modal */ +#cookieConsentModal { + .modal-dialog { + max-width: 1000px; + } + .consentModalItem { + display: flex; + align-items: center; + border: 1px solid #ccc; + padding: 10px 20px; + border-radius: 5px; + } + .consentModalItem:not(:last-child) { + margin-bottom: 20px; + } + .consentItemCheckbox { + padding-right: 20px; + } + .consentItemName { + font-weight: bold; + } + #cookieConsentPopupText { + margin: 20px 0; + } +} + +#cookieConsentButton { + position: fixed; + border: 0; + bottom: 0; + right: 0; + padding: 10px; + background: rgba(0,0,0,0.75); + color: rgba(255,255,255,0.8); + z-index: 1; +} + +#viewCookieConsents { + margin-top: 10px; +} + +#patronconsents h5 { + margin-top: 30px; +} + @import "responsive"; diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc index 407e87d4b7..159fbe463e 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc @@ -4,6 +4,9 @@ [% USE Categories %] [% USE AdditionalContents %] [% PROCESS 'html_helpers.inc' %] +[% IF Koha.Preference( 'CookieConsent' ) %] + [% USE JSConsents %] +[% END %] [% SET OpacLangSelectorMode = Koha.Preference('OpacLangSelectorMode') %] [% SET OpacHeader = AdditionalContents.get( location => "opacheader", lang => lang, library => logged_in_user.branchcode || default_branch, blocktitle => 0 ) %] [% SET OpacCustomSearch = AdditionalContents.get( location => "OpacCustomSearch", lang => lang, library => logged_in_user.branchcode || default_branch, blocktitle => 0 ) %] @@ -412,3 +415,62 @@ + [% IF Koha.Preference( 'CookieConsent' ) %] + + + + [% IF !loggedinusername %] + + [% END %] + + + [% END %] diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc index 2d61b53590..cb4a9f5385 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc @@ -5,6 +5,9 @@ [% USE Asset %] [% SET opaccredits = AdditionalContents.get( location => "opaccredits", lang => lang, library => logged_in_user.branchcode || default_branch ) %] [% PROCESS 'html_helpers.inc' %] +[% IF Koha.Preference( 'CookieConsent' ) %] + [% USE JSConsents %] +[% END %] [% UNLESS ( is_popup ) %] [% SET OpacLangSelectorMode = Koha.Preference('OpacLangSelectorMode') %] [% IF ( opaccredits ) %] @@ -117,6 +120,14 @@ [% END # /IF OpacLangSelectorMode == 'both' || OpacLangSelectorMode == 'footer' %] [% END # / UNLESS is_popup %] + +[% IF Koha.Preference( 'CookieConsent' ) && Koha.Preference( 'ConsentJS' ).length > 0 %] + [% consents = JSConsents.all() %] + [% FOREACH consent IN consents %] + + [% END %] +[% END %] + [% Asset.js("lib/jquery/jquery-3.6.0.min.js") | $raw %] [% Asset.js("lib/jquery/jquery-migrate-3.3.2.min.js") | $raw %] @@ -304,6 +315,9 @@ $(document).ready(function() { [% END %] [% END %] +[% IF Koha.Preference( 'CookieConsent' ) %] + [% Asset.js("js/cookieconsent.js") | $raw %] +[% END %] [% KohaPlugins.get_plugins_opac_js | $raw %] diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc index 433b80db82..1065e29c53 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc @@ -24,7 +24,7 @@ [% END %] your personal details - [% IF Koha.Preference('GDPR_Policy') # remove when extending %] + [% IF Koha.Preference('GDPR_Policy') || Koha.Preference('CookieConsent') # remove when extending %] [% IF consentview %]
  • [% ELSE %]
  • [% END %] your consents
  • diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-patron-consent.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-patron-consent.tt index 68c3e459d6..bfa09d5437 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-patron-consent.tt +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-patron-consent.tt @@ -69,6 +69,11 @@ + [% IF Koha.Preference('CookieConsent') %] +
    Cookie consents
    + + [% END %] + diff --git a/koha-tmpl/opac-tmpl/bootstrap/js/cookieconsent.js b/koha-tmpl/opac-tmpl/bootstrap/js/cookieconsent.js new file mode 100644 index 0000000000..ae10eeac86 --- /dev/null +++ b/koha-tmpl/opac-tmpl/bootstrap/js/cookieconsent.js @@ -0,0 +1,183 @@ +(function () { + // Has the user previously consented, establish our + // initial state + let selected = []; + let existingConsent = ''; + // The presence of a 'cookieConsent' local storage item indicates + // that previous consent has been set. If the value is empty, then + // no consent to non-essential cookies was given + const hasStoredConsent = localStorage.getItem('cookieConsent'); + getExistingConsent(); + + // The consent bar may not be in the DOM if it is not being used + const consentBar = $('#cookieConsentBar'); + if (!consentBar) { + return; + } + if (hasStoredConsent === null) { + showConsentBar(); + } else { + showYourCookies(); + } + addButtonHandlers(); + + // When the modal is opened, populate our state based on currently + // selected values + $('#cookieConsentModal').on('shown.bs.modal', function () { + initialiseSelected(); + }); + + // Initialise existing consent based on local storage + function getExistingConsent() { + existingConsent = localStorage.getItem('cookieConsent') ? + localStorage.getItem('cookieConsent') : + []; + } + + function showConsentBar() { + const consentBar = $('#cookieConsentBar'); + consentBar.attr('aria-hidden', 'false'); + consentBar.show(); + } + + function hideConsentBar() { + const consentBar = $('#cookieConsentBar'); + consentBar.attr('aria-hidden', 'true'); + consentBar.hide(); + } + + // Hides the appropriate consent container, depending on what + // is currently visible + function hideContainer() { + if ($('#cookieConsentModal').hasClass('show')) { + $('#cookieConsentModal').modal('hide'); + } else { + hideConsentBar(); + } + } + + // Show the unauthenticated user's "Your cookies" button + function showYourCookies() { + // If the user is unauthenticated, there will be a + // cookieConsentsButton element in the DOM. The user + // has previously consented, so we need to display this + // button + const consentButton = $('#cookieConsentButton'); + if (consentButton.length > 0) { + consentButton.attr('aria-hidden', 'false'); + consentButton.show(); + } + } + + // Initialise our state of selected item and enable/disable + // the "Accept selected" button appropriately + function initialiseSelected() { + selected = []; + $('.consentCheckbox').each(function () { + const val = $(this).val(); + if (existingConsent.indexOf(val) > -1 ) { + $(this).prop('checked', true); + selected.push(val); + } else { + $(this).prop('checked', false); + } + }); + enableDisableSelectedButton(); + } + + // Maintain our state of selected items and enable/disable + // the "Accept selected" button appropriately + function maintainSelected() { + selected = []; + $('.consentCheckbox:checked').each(function () { + selected.push($(this).val()); + }); + enableDisableSelectedButton(); + } + + // Set the enabled / disabled state of the + // "Accept selected button based on the checkbox + // states + function enableDisableSelectedButton() { + $('#consentAcceptSelected').prop( + 'disabled', + selected.length === 0 + ); + } + + function runConsentedCode() { + getExistingConsent(); + $('.consentCode').each(function () { + // The user has explicitly consented to this code, or the + // code doesn't require consent in the OPAC + if ( + existingConsent.indexOf($(this).data('consent-id')) > -1 || + !$(this).data('requires-consent') + ) { + const code = atob($(this).data('consent-code')); + const func = Function(code); + func(); + } + }); + } + + function addButtonHandlers() { + // "Accept all" handler + $('.consentAcceptAll').on('click', function(e) { + e.preventDefault(); + let toSave = []; + $('.consentCheckbox').each(function () { + const val = $(this).val(); + toSave.push(val); + }); + localStorage.setItem('cookieConsent', toSave); + hideContainer(); + runConsentedCode(); + showYourCookies(); + }); + + // "Accept essential" handler + $('.consentAcceptEssential').on('click', function(e) { + e.preventDefault(); + localStorage.setItem('cookieConsent', []); + hideContainer(); + showYourCookies(); + }); + + // "Accept selected" handler + $('#consentAcceptSelected').on('click', function(e) { + e.preventDefault(); + const toSave = selected.length > 0 ? selected : []; + localStorage.setItem('cookieConsent', toSave); + hideContainer(); + runConsentedCode(); + showYourCookies(); + }); + + // "More information" handler + $('#consentMoreInfo, #cookieConsentButton, #viewCookieConsents').on( + 'click', + function (e) { + e.preventDefault(); + hideConsentBar(); + // Ensure we're up to date with the existing consent + getExistingConsent(); + // Prevent the modal from being closed with anything + // but the buttons + $('#cookieConsentModal') + .modal({ + backdrop: 'static', + keyboard: false, + focus: true, + show: true + }); + } + ); + $('.consentCheckbox').on('click', function () { + maintainSelected(); + }); + } + + // On page load, run any code that has been given consent + runConsentedCode(); +})(); diff --git a/t/db_dependent/Koha/Template/Plugin/JSConsents.t b/t/db_dependent/Koha/Template/Plugin/JSConsents.t new file mode 100755 index 0000000000..4210f5f4b1 --- /dev/null +++ b/t/db_dependent/Koha/Template/Plugin/JSConsents.t @@ -0,0 +1,25 @@ +#!/usr/bin/perl + +use Modern::Perl; + +use C4::Context; + +use Test::MockModule; +use Test::More tests => 3; +use t::lib::Mocks; + +BEGIN { + use_ok('Koha::Template::Plugin::JSConsents', "Can use Koha::Template::Plugin::JSConsents"); +} + +ok( my $consents = Koha::Template::Plugin::JSConsents->new(), 'Able to instantiate template plugin' ); + +subtest "all" => sub { + plan tests => 1; + + t::lib::Mocks::mock_preference( 'ConsentJS', 'eyAidGVzdCI6ICJvbmUiIH0=' ); + + is_deeply( $consents->all(), { test => 'one' }, 'Returns a Base64 decoded JSON object converted into a data structure'); +}; + +1; -- 2.20.1