From 2b86200bf734b8c70e58477000587aa9ac48ae85 Mon Sep 17 00:00:00 2001 From: Andrew Isherwood Date: Mon, 11 Jan 2021 14:50:17 +0000 Subject: [PATCH] Bug 27378: Introduce cookie consent to OPAC and staff client To avoid confusion around commit messages and the content of this enhancement, this first commit is a squashed commit of all the original code submited to this bug. Following a few years of inactivity, it has been rebased and re-submitted with some fixes and concept changes contained in the more recent commits. Signed-ff-by: Barry Cannon --- Koha/Template/Plugin/JSConsents.pm | 64 ++++ admin/preferences.pl | 17 +- .../bug_27378_add_ConsentJS_syspref.perl | 8 + ...ug_27378_add_CookieConsentBar_syspref.perl | 8 + ..._27378_add_CookieConsentPopup_syspref.perl | 8 + .../bug_27378_add_CookieConsent_syspref.perl | 8 + installer/data/mysql/mandatory/sysprefs.sql | 4 + .../intranet-tmpl/prog/css/preferences.css | 9 +- .../prog/css/src/staff-global.scss | 80 ++++ .../prog/en/includes/intranet-bottom.inc | 69 ++++ .../prog/en/modules/admin/preferences.tt | 2 + .../en/modules/admin/preferences/patrons.pref | 24 ++ .../intranet-tmpl/prog/js/cookieconsent.js | 183 +++++++++ .../prog/js/pages/preferences.js | 360 +++++++++++++++--- .../opac-tmpl/bootstrap/css/src/opac.scss | 57 +++ .../bootstrap/en/includes/masthead.inc | 65 ++++ .../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 | 199 ++++++++++ .../Koha/Template/Plugin/JSConsents.t | 25 ++ 21 files changed, 1157 insertions(+), 54 deletions(-) create mode 100644 Koha/Template/Plugin/JSConsents.pm create mode 100644 installer/data/mysql/atomicupdate/bug_27378_add_ConsentJS_syspref.perl create mode 100644 installer/data/mysql/atomicupdate/bug_27378_add_CookieConsentBar_syspref.perl create mode 100644 installer/data/mysql/atomicupdate/bug_27378_add_CookieConsentPopup_syspref.perl create mode 100644 installer/data/mysql/atomicupdate/bug_27378_add_CookieConsent_syspref.perl create mode 100644 koha-tmpl/intranet-tmpl/prog/js/cookieconsent.js 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/admin/preferences.pl b/admin/preferences.pl index 45a5891c62..73b8c442e3 100755 --- a/admin/preferences.pl +++ b/admin/preferences.pl @@ -59,12 +59,17 @@ sub _get_chunk { if( $options{'syntax'} ){ $chunk->{'syntax'} = $options{'syntax'}; } - - if( $options{'type'} && $options{'type'} eq 'modalselect' ){ - $chunk->{'source'} = $options{'source'}; - $chunk->{'exclusions'} = $options{'exclusions'} // ""; - $chunk->{'required'} = $options{'required'} // ""; - $chunk->{'type'} = 'modalselect'; + if( $options{'type'} ) { + if( $options{'type'} eq 'modalselect' ){ + $chunk->{'source'} = $options{'source'}; + $chunk->{'exclusions'} = $options{'exclusions'} // ""; + $chunk->{'required'} = $options{'required'} // ""; + $chunk->{'type'} = 'modalselect'; + } elsif( $options{'type'} eq 'modaljs' ){ + $chunk->{'type'} = 'modaljs'; + $chunk->{'initiator'} = $options{'initiator'}; + $chunk->{'processor'} = $options{'processor'}; + } } if ( $options{'class'} && $options{'class'} eq 'password' ) { diff --git a/installer/data/mysql/atomicupdate/bug_27378_add_ConsentJS_syspref.perl b/installer/data/mysql/atomicupdate/bug_27378_add_ConsentJS_syspref.perl new file mode 100644 index 0000000000..b219ead1e7 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_27378_add_ConsentJS_syspref.perl @@ -0,0 +1,8 @@ +$DBversion = 'XXX'; # will be replaced by the RM +if( CheckVersion( $DBversion ) ) { + $dbh->do( q| INSERT IGNORE INTO systempreferences (variable, value, explanation, options, type) VALUES ('ConsentJS', '', 'Specify Javascript that requires user consent to run (e.g. tracking code)', '', 'Free'); | ); + + # Always end with this (adjust the bug info) + SetVersion( $DBversion ); + print "Upgrade to $DBversion done (Bug 27378 - Add ConsentJS syspref)\n"; +} diff --git a/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsentBar_syspref.perl b/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsentBar_syspref.perl new file mode 100644 index 0000000000..c1f2f82cf6 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsentBar_syspref.perl @@ -0,0 +1,8 @@ +$DBversion = 'XXX'; # will be replaced by the RM +if( CheckVersion( $DBversion ) ) { + $dbh->do( q{ INSERT IGNORE INTO systempreferences (variable, value, explanation, options, type) VALUES ('CookieConsentBar', '', 'Show the following HTML in the cookie consent bar that is displayed at the bottom of the screen', '70|10', 'Textarea'); } ); + + # Always end with this (adjust the bug info) + SetVersion( $DBversion ); + print "Upgrade to $DBversion done (Bug 27378 - Add CookieConsentBar syspref)\n"; +} diff --git a/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsentPopup_syspref.perl b/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsentPopup_syspref.perl new file mode 100644 index 0000000000..0420876959 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsentPopup_syspref.perl @@ -0,0 +1,8 @@ +$DBversion = 'XXX'; # will be replaced by the RM +if( CheckVersion( $DBversion ) ) { + $dbh->do( q{INSERT IGNORE INTO systempreferences (variable, value, explanation, options, type) VALUES ('CookieConsentPopup', '', 'Show the following HTML in the cookie consent popup', '70|10', 'Textarea');} ); + + # Always end with this (adjust the bug info) + SetVersion( $DBversion ); + print "Upgrade to $DBversion done (Bug 27378 - Add CookieConsentPopup syspref)\n"; +} diff --git a/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsent_syspref.perl b/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsent_syspref.perl new file mode 100644 index 0000000000..3592a64248 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_27378_add_CookieConsent_syspref.perl @@ -0,0 +1,8 @@ +$DBversion = 'XXX'; # will be replaced by the RM +if( CheckVersion( $DBversion ) ) { + $dbh->do( q| INSERT IGNORE INTO systempreferences (variable, value, explanation, options, type) VALUES ('CookieConsent', '0', 'Require cookie consent to be displayed', '', 'YesNo'); | ); + + # Always end with this (adjust the bug info) + SetVersion( $DBversion ); + print "Upgrade to $DBversion done (Bug 27378 - Add CookieConsent syspref)\n"; +} diff --git a/installer/data/mysql/mandatory/sysprefs.sql b/installer/data/mysql/mandatory/sysprefs.sql index 30197591a8..b726cf069a 100644 --- a/installer/data/mysql/mandatory/sysprefs.sql +++ b/installer/data/mysql/mandatory/sysprefs.sql @@ -156,8 +156,12 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('ComponentSortField','title','call_number|pubdate|acqdate|title|author','Specify the default field used for sorting','Choice'), ('ComponentSortOrder','asc','asc|dsc|az|za','Specify the default sort order','Choice'), ('ConfirmFutureHolds','0','','Number of days for confirming future holds','Integer'), +('ConsentJS', '', NULL, 'Specify Javascript that requires user consent to run (e.g. tracking code)', 'Free'), ('ConsiderOnSiteCheckoutsAsNormalCheckouts','1',NULL,'Consider on-site checkouts as normal checkouts','YesNo'), ('ContentWarningField', '', NULL, 'MARC field to use for content warnings', 'Free'), +('CookieConsent', '0', NULL, 'Require cookie consent to be displayed', 'YesNo'), +('CookieConsentBar', '', '70|10', 'Show the following HTML in the cookie consent bar that is displayed at the bottom of the screen', 'Textarea'), +('CookieConsentPopup', '', '70|10', 'Show the following HTML in the cookie consent popup', 'Textarea'), ('CreateAVFromCataloguing', '1', '', 'Ability to create authorized values from the cataloguing module', 'YesNo'), ('CronjobLog','0',NULL,'If ON, log information from cron jobs.','YesNo'), ('CumulativeRestrictionPeriods',0,NULL,'Cumulate the restriction periods instead of keeping the highest','YesNo'), diff --git a/koha-tmpl/intranet-tmpl/prog/css/preferences.css b/koha-tmpl/intranet-tmpl/prog/css/preferences.css index ce15265389..d0f41db136 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/preferences.css +++ b/koha-tmpl/intranet-tmpl/prog/css/preferences.css @@ -3,7 +3,8 @@ .preference-multi, .preference-long, .preference-file, -.preference-modalselect { +.preference-modalselect, +.preference-modaljs { width: 20em; } @@ -15,11 +16,13 @@ width: 5em; } -input[type="text"].modalselect { +input[type="text"].modalselect, +input[type="text"].modaljs { cursor: pointer; } -input[type="text"].modalselect:hover { +input[type="text"].modalselect:hover, +input[type="text"].modaljs:hover { background-color: #FFC; } diff --git a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss index abbc4a82b7..4c05c3862c 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss @@ -4295,6 +4295,86 @@ div .suggestion_note { } } +#consentJSItems { + #consentJSWarning { + margin: 20px 0; + text-align: center; + font-weight: 800; + font-size: 120%; + } + .consentJSItem { + margin-bottom: 30px; + padding: 10px; + border: 1px solid #ccc; + } + .consentRow { + display: flex; + margin-bottom: 20px; + .consentItem { + margin-right: 40px; + } + .consentItem:last-child { + margin-right: 0; + } + } + .codeRow { + display: block; + } + .consentDelete { + display: block; + text-align: right; + } +} + +/* 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: #d7ebff; + } + #consentButtons { + margin-top: 10px; + text-align: right; + } + button:not(:last-child) { + margin-right: 10px; + } +} + +/* Cookie consent modal */ +#cookieConsentModal { + .modal-dialog { + width: 700px; + } + .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; + } +} + @import "header"; @import "toolbar"; @import "forms"; diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/intranet-bottom.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/intranet-bottom.inc index 27ba415943..3cbf7f4c1f 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/intranet-bottom.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/intranet-bottom.inc @@ -1,6 +1,10 @@ [% USE raw %] [% USE Koha %] [% USE KohaPlugins %] +[% USE Asset %] +[% IF Koha.Preference( 'CookieConsent' ) %] + [% USE JSConsents %] +[% END %] [% IF ( ( languages_loop ) && ( ! popup_window ) && ( Koha.Preference('StaffLangSelectorMode') == 'both' || Koha.Preference('StaffLangSelectorMode') == 'footer') ) %] [% UNLESS ( one_language_enabled ) %] @@ -74,5 +78,70 @@ [% END %] [% KohaPlugins.get_plugins_intranet_js | $raw %] + + [% IF Koha.Preference( 'CookieConsent' ) %] + + + + + [% END %] + + [% IF Koha.Preference( 'CookieConsent' ) && Koha.Preference( 'ConsentJS' ).length > 0 %] + [% consents = JSConsents.all() %] + [% FOREACH consent IN consents %] + + [% END %] + [% END %] + [% IF Koha.Preference( 'CookieConsent' ) %] + [% Asset.js("js/cookieconsent.js") | $raw %] + [% END %] + diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences.tt index 1bc1458141..21da1e7115 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences.tt @@ -130,6 +130,8 @@ [% ELSIF ( CHUNK.type_modalselect ) %] + [% ELSIF ( CHUNK.type_modaljs ) %] + [% ELSIF ( CHUNK.type_multiple ) %] ' : + ''; + } + + // Create a consentJS item, correctly populated + function createConsentJSItem(item, idx) { + const id = 'ConsentJS_' + idx; + const code = item.code && item.code.length > 0 ? atob(item.code) : ''; + const itemId = item.id && item.id.length > 0 ? item.id : ''; + return '
' + + '
' + + '
' + + ' ' + + ' ' + __('Required') + '' + + '
' + + '
' + + ' ' + + ' ' + __('Required') + '' + + '
' + + '
' + + ' ' + + checkBox('opacConsent_' + id, 'opacConsent', item.opacConsent) + + '
' + + '
' + + ' ' + + checkBox('staffConsent_' + id, 'staffConsent', item.staffConsent) + + '
' + + '
' + + ' ' + + ' ' + __('Required') + '' + + '
' + + '
' + + ' ' + + ' ' + __('Required') + '' + + '
' + + '
' + + ' ' + + ' ' + __('Required') + '' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + ' Delete' + + '
'; + } + + // Return the markup for all consentJS items concatenated + function populateConsentMarkup(items) { + return items.reduce(function (acc, current, idx) { + return acc + createConsentJSItem(current, idx); + }, ''); + } + + // Return the markup for a validation warning + function populateValidationWarning() { + return ''; + } + + // Return a new, empty consent item + function emptyConsentItem() { + return { + name: '', + description: '', + matchPattern: '', + cookieDomain: '', + cookiePath: '', + code: '', + consentInOpac: false, + consentInStaff: false + }; + } + + // Add the handler for a new empty consent item + function addNewHandler() { + $("#consentJSAddNew").on("click", function (e) { + e.preventDefault(); + const currentLen = $('.consentJSItem').length; + const newItem = emptyConsentItem(); + const markup = createConsentJSItem(newItem, currentLen); + $('#prefModal .modal-body #consentJSItems').append($(markup)); + addExpandHandler(); + addCollapseHandler(); + addConsentDeleteHandler(); + }); + } + + // Populate the consentJS modal, we also initialise any + // event handlers that are required. This function is added + // to the window object so we can call it if we are passed it's name + // as a data-initiator attribute + // (e.g.) + // const f = 'populateConsentJS'; + // window[f](); + window.populateConsentJS = function(el) { + let items = []; + let decoded = ''; + if (el.value && el.value.length > 0) { + try { + decoded = atob(el.value); + } catch (err) { + throw (__( + 'Unable to Base64 decode value stored in ConsentJS syspref: ' + + err.message + )); + } + try { + items = JSON.parse(decoded); + } catch (err) { + throw (__( + 'Unable to JSON parse decoded value stored in ConsentJS syspref: ' + + err.message + )); + } + } + const markup = populateConsentMarkup(items); + const validationWarning = populateValidationWarning(); + const pref_name = el.id.replace(/pref_/, ''); + $('#saveModalPrefs').data('target', el.id); + $('#prefModalLabel').text( pref_name ); + $('#prefModal .modal-body').html($('
' + validationWarning + markup + '
' + __('Add new code') + '
')); + addExpandHandler(); + addCollapseHandler(); + addNewHandler(); + addConsentDeleteHandler(); + } + + // Prepare the data in the UI for sending back as a syspref. + // We validate that everything is what we expect. This function is added + // to the window object so we can call it if we are passed it's name + // as a data-initiator attribute + // e.g. + // const f = 'prepareConsentJS'; + // window[f](); + window.prepareConsentJS = function () { + const items = $('.consentJSItem'); + const invalid = []; + const valid = []; + items.each(function () { + const id = $(this).data('id').length > 0 ? + $(this).data('id') : + '_' + Math.random().toString(36).substr(2, 9); + const name = $(this).find('.metaName').val(); + const desc = $(this).find('.metaDescription').val(); + const matchPattern = $(this).find('.metaMatchPattern').val(); + const cookieDomain = $(this).find('.metaCookieDomain').val(); + const cookiePath = $(this).find('.metaCookiePath').val(); + const opacConsent = $(this).find('.opacConsent').is(':checked') + const staffConsent = $(this).find('.staffConsent').is(':checked'); + const code = $(this).find('.preference-code').val(); + // If the name, description, match pattern code are empty, then they've + // added a new entry, but not filled it in, we can skip it + if ( + name.length === 0 && + desc.length === 0 && + matchPattern.length === 0 && + cookieDomain.length === 0 && + cookiePath.length === 0 && + code.length === 0 + ) { + return; + } + // They've filled in at least some info + if ( + (name.length === 0) || + (desc.length === 0) || + (matchPattern.length === 0) || + (cookiePath.length === 0) || + (code.length === 0) + ) { + invalid.push(this); + } else { + const obj = { + id: id, + name: name, + description: desc, + matchPattern: matchPattern, + cookieDomain: cookieDomain, + cookiePath: cookiePath, + opacConsent: opacConsent, + staffConsent: staffConsent, + code: btoa(code) + } + valid.push(obj); + } + }); + // We failed validation + if (invalid.length > 0) { + $('#consentJSWarning').show(); + return false; + } + $('#consentJSWarning').hide(); + if (valid.length === 0) { + return ''; + } + const json = JSON.stringify(valid); + const base64 = btoa(json); + return base64; + } - formfieldid.val( prefs.join("|") ) - .addClass("modified"); - mark_modified.call( formfieldid ); - KOHA.Preferences.Save( formfieldid.closest("form") ); - $("#prefModal").modal("hide"); + $("#saveModalPrefs").on("click", function(){ + var formfieldid = $("#" + $(this).data("target")); + let finalString = ""; + if ($(this).data("type") == "modalselect") { + finalString = prepareModalSelect(formfieldid); + } else { + const processor = $(".modaljs").data("processor"); + finalString = window[processor](); + } + // A processor can return false if any of the submitted + // data has failed validation + if (finalString !== false) { + formfieldid.val(finalString).addClass("modified"); + mark_modified.call( formfieldid ); + KOHA.Preferences.Save( formfieldid.closest("form") ); + $("#prefModal").modal("hide"); + } }); $("#prefModal").on("hide.bs.modal", function(){ diff --git a/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss b/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss index e48225df2e..6c42a256b1 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss +++ b/koha-tmpl/opac-tmpl/bootstrap/css/src/opac.scss @@ -2874,4 +2874,61 @@ $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; + } +} + +#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 8ea412792d..cc95fd830b 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc @@ -5,6 +5,9 @@ [% USE AuthClient %] [% 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 ) %] @@ -88,6 +91,15 @@ [% END %] + + [% IF !loggedinusername %] + + + [% END %] [% IF Koha.Preference( 'opacuserlogin' ) == 1 || Koha.Preference( 'EnableOpacSearchHistory') || Koha.Preference( 'opaclanguagesdisplay' ) %] @@ -440,3 +452,56 @@ + [% IF Koha.Preference( 'CookieConsent' ) %] + + + + + [% 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 7e4442477d..27de6f8ded 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 ) %] @@ -121,6 +124,14 @@ [% INCLUDE 'modals/checkout.inc' %] [% END %] + +[% 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 %] @@ -309,6 +320,9 @@ $(document).ready(function() { [% IF Koha.Preference( 'OpacTrustedCheckout' ) %] [% Asset.js("js/modals/checkout.js") | $raw %] [% 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 5f8b48cda8..e53c087de0 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 %] Personal details - [% IF Koha.Preference('PrivacyPolicyConsent') # remove when extending %] + [% IF Koha.Preference('PrivacyPolicyConsent') || Koha.Preference('CookieConsent') # remove when extending %] [% IF consentview %]
  • [% ELSE %]
  • [% END %] 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 d6fe1dcafe..896e9df4d2 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 @@ -70,6 +70,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..ec4372216c --- /dev/null +++ b/koha-tmpl/opac-tmpl/bootstrap/js/cookieconsent.js @@ -0,0 +1,199 @@ +(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 + if ($('#cookieConsentButton').length > 0) { + $('#cookieConsentDivider').show().attr('aria-hidden', 'false'); + $('#cookieConsentLi').show().attr('aria-hidden', 'false'); + } + } + + // 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(); + } else { + // This code doesn't have consent to run, we may need to remove + // any cookies it has previously set + const matchPattern = $(this).data('consent-match-pattern'); + const cookieDomain = $(this).data('consent-cookie-domain'); + const cookiePath = $(this).data('consent-cookie-path'); + if (matchPattern.length > 0) { + const regex = new RegExp(matchPattern); + const allCookies = document.cookie.split('; '); + allCookies.forEach(function (cookie) { + const name = cookie.split('=')[0]; + if (regex.test(name)) { + document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT; domain=' + cookieDomain +'; path=' + cookiePath; + } + }); + } + } + }); + } + + 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(); + runConsentedCode(); + 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.37.1 (Apple Git-137.1)