From 1826888ea5a18fb55eac4a8ede2a7859b25b3cce Mon Sep 17 00:00:00 2001
From: Andrew Isherwood <andrew.isherwood@ptfs-europe.com>
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 <bc@interleaf.ie>
---
 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 <http://www.gnu.org/licenses>.
+
+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 <andrew.isherwood@ptfs-europe.com>
+
+=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 @@
             </div> <!-- /.modal-content -->
         </div> <!-- /.modal-dialog -->
     </div>  <!-- /#modalAuth  -->
+    [% IF Koha.Preference( 'CookieConsent' ) %]
+        <!-- Cookie consent bar -->
+        <div id="cookieConsentBar" aria-hidden="true">
+            [% Koha.Preference( 'CookieConsentBar' ) | $raw %]
+            <div id="consentButtons">
+                <button type="button" class="btn btn-primary consentAcceptAll">Accept all cookies</button>
+                [% IF ( Koha.Preference( 'ConsentJS' ).length > 0 ) %]
+                    <button type="button" class="btn btn-primary consentAcceptEssential">Accept only essential cookies</button>
+                    <button type="button" class="btn btn-info" id="consentMoreInfo">More information</button>
+                [% END %]
+            </div>
+        </div> <!-- /#cookieConsentBar -->
+        <!-- Cookie consent button for non logged-in users -->
+        [% IF !loggedinusername %]
+            <button id="cookieConsentButton" style="display:none" aria-hidden="true">
+                Your cookies
+            </button>
+        [% END %]
+        <!-- Cookie consent modal -->
+        <div id="cookieConsentModal" class="modal" tabindex="-1" role="dialog" aria-labelledby="cookieConsentModalLabel" aria-hidden="true">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h2 class="modal-title" id="cookieConsentModalLabel">Cookie consent</h2>
+                    </div>
+                    <div class="modal-body">
+                        [% IF Koha.Preference( 'CookieConsentPopup' ) %]
+                            <div id="cookieConsentPopupText">
+                                [% Koha.Preference( 'CookieConsentPopup' ) | $raw %]
+                            </div>
+                        [% END %]
+                        <div id="consentCookieList">
+                            [% consents = JSConsents.all() %]
+                            [% FOREACH consent IN consents %]
+                                <div class="consentModalItem">
+                                    <div class="consentItemCheckbox">
+                                        <input class="consentCheckbox" type="checkbox" name="consentCheckbox" value="[% consent.id %]">
+                                    </div>
+                                    <div class="consentItemMeta">
+                                        <div class="consentItemName">[% consent.name | html %]</div>
+                                        <div class="consentItemDescription">[% consent.description | html %]</div>
+                                    </div>
+                                </div>
+                            [% END %]
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <div id="consentButtons">
+                            <button type="button" class="btn btn-primary consentAcceptAll">Accept all cookies</button>
+                            [% IF ( Koha.Preference( 'ConsentJS' ).length > 0 ) %]
+                                <button type="button" class="btn btn-primary consentAcceptEssential">Accept only essential cookies</button>
+                                <button type="button" class="btn btn-warning" id="consentAcceptSelected">Accept selected non-essential cookies</button>
+                            [% END %]
+                        </div>
+                    </div>
+                </div> <!-- /.modal-content -->
+            </div> <!-- /.modal-dialog -->
+        </div>  <!-- /#cookieConsentModal  -->
+    [% 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 %]
 
+<!-- ConsentJS code that may run -->
+[% IF Koha.Preference( 'CookieConsent' ) && Koha.Preference( 'ConsentJS' ).length > 0 %]
+    [% consents = JSConsents.all() %]
+    [% FOREACH consent IN consents %]
+        <div class="consentCode" style="display:none" aria-hidden="true" data-consent-id="[% consent.id %]" data-consent-code="[% consent.code %]" data-requires-consent="[% consent.opacConsent ? 'true' : 'false' %]"></div>
+    [% END %]
+[% END %]
+
 <!-- JavaScript includes -->
 [% 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() {
         </script>
     [% END %]
 [% END %]
+[% IF Koha.Preference( 'CookieConsent' ) %]
+    [% Asset.js("js/cookieconsent.js") | $raw %]
+[% END %]
 [% KohaPlugins.get_plugins_opac_js | $raw %]
 </body>
 </html>
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 %]
                 <a href="/cgi-bin/koha/opac-memberentry.pl">your personal details</a></li>
 
-            [% IF Koha.Preference('GDPR_Policy') # remove when extending %]
+            [% IF Koha.Preference('GDPR_Policy') || Koha.Preference('CookieConsent') # remove when extending %]
                 [% IF consentview %]<li class="active">[% ELSE %]<li>[% END %]
                     <a href="/cgi-bin/koha/opac-patron-consent.pl">your consents</a>
                 </li>
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 @@
 
                     </form>
 
+                    [% IF Koha.Preference('CookieConsent') %]
+                        <h5>Cookie consents</h5>
+                        <button id="viewCookieConsents" type="button" class="btn btn-success">View your cookie consents</button>
+                    [% END %]
+
                 </div> <!-- / #userpasswd -->
             </div> <!-- / .col-lg-10 -->
         </div> <!-- / .row -->
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