diff --git a/docs/setup/administrators/cookie-consent.md b/docs/setup/administrators/cookie-consent.md new file mode 100644 index 0000000000000000000000000000000000000000..29ac36f5c4a8e194ad0ff8cc26270a484db68236 --- /dev/null +++ b/docs/setup/administrators/cookie-consent.md @@ -0,0 +1,44 @@ +Hypha comes stock with a cookie banner indicating that only essential cookies are used. You can configure this banner to display information about analytics cookies in Wagtail Admin under `Settings` -> `Cookie banner settings`. + +It's possible to configure settings such as: + +* Edit General cookie consent message +* Edit Essential cookies informational statement +* Enable & edit the analytics cookies informational statement + +### Retrieving preferences + +Cookie preferences are stored in the browser's local storage using the key `cookieconsent`, and can be globally retrieved via: + +```js +localStorage.get('cookieconsent') +``` + +There are three valid values `cookieconsent` in local storage: + +* `decline` - analytics cookies have been declined, only essential cookies should be used +* `accept` - all cookies are consented to +* `ack` - there are no analytics cookies and user accepts that only essential cookies are in use +* **null** - no selection has been made by the user in the cookie banner + +On page load the cookie banner JavaScript snippet will check if cookie policies have changed (ie. the site originally only used essential cookies, user ack'd, then analytics cookies were enabled) and automatically reprompt the user with the new cookie options. + +### Allowing the user to change cookie preferences + +The functions to open and close the cookie consent prompts are globally exposed in the JavaScript and can be utilized via: + +```js +// Open consent prompt +window.openConsentPrompt() + +// Close consent prompt +window.closeConsentPrompt() +``` + +By default, there is a button in the footer that allows the user to re-open the cookie consent prompt: + +```html +<a href="#" onclick="openConsentPrompt()">Cookie Settings</a> +``` + +This can be further configured in Wagtail Admin under `Settings` -> `System settings` -> `Footer content`. \ No newline at end of file diff --git a/hypha/cookieconsent/migrations/0003_alter_cookieconsentsettings_options_and_more.py b/hypha/cookieconsent/migrations/0003_alter_cookieconsentsettings_options_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..162437b5ab69aa5a9840afd5b32a7433a2f4f7dd --- /dev/null +++ b/hypha/cookieconsent/migrations/0003_alter_cookieconsentsettings_options_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.11 on 2024-06-04 19:41 + +from django.db import migrations, models +import wagtail.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("cookieconsent", "0002_remove_cookieconsentsettings_site_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="cookieconsentsettings", + options={"verbose_name": "Cookie banner settings"}, + ), + migrations.AddField( + model_name="cookieconsentsettings", + name="cookieconsent_analytics", + field=models.BooleanField( + default=False, + verbose_name="Include consent option for analytics cookies", + ), + ), + migrations.AddField( + model_name="cookieconsentsettings", + name="cookieconsent_analytics_about", + field=wagtail.fields.RichTextField( + default="<p>With these cookies we count visits and traffic sources to help improve the performance of our services through metrics. These cookies show us which pages on our services are the most and the least popular, and how users navigate our services. The information collected is aggregated and contains no personally identifiable information. If you block these cookies, then we will not know when you have used our services.</p>", + verbose_name='Analytics cookies information to be displayed under "Learn More"', + ), + ), + migrations.AddField( + model_name="cookieconsentsettings", + name="cookieconsent_essential_about", + field=wagtail.fields.RichTextField( + default="<p>Strictly necessary for the operation of a website because they enable you to navigate around the site and use features. These cookies cannot be switched off in our systems and do not store any personally identifiable information.</p>", + verbose_name='Essential cookies information to be displayed under "Learn More"', + ), + ), + migrations.AlterField( + model_name="cookieconsentsettings", + name="cookieconsent_active", + field=models.BooleanField( + default=False, verbose_name="Activate cookie pop-up banner" + ), + ), + migrations.AlterField( + model_name="cookieconsentsettings", + name="cookieconsent_message", + field=wagtail.fields.RichTextField( + default='<p>This website deploys cookies for basic functionality and to keep it secure. These cookies are strictly necessary. Optional analysis cookies which provide us with statistical information about the use of the website may also be deployed, but only with your consent. Please review our <a href="/data-privacy-policy/">Privacy & Data Policy</a> for more information.</p>', + verbose_name="Cookie consent message", + ), + ), + migrations.AlterField( + model_name="cookieconsentsettings", + name="cookieconsent_title", + field=models.CharField( + default="Your cookie settings", + max_length=255, + verbose_name="Cookie banner title", + ), + ), + ] diff --git a/hypha/cookieconsent/models.py b/hypha/cookieconsent/models.py index d700d4a050c320624d2c8436e00156c702a28bc2..3631ddcc82f2ebf175095eedaf8404fc6b839de5 100644 --- a/hypha/cookieconsent/models.py +++ b/hypha/cookieconsent/models.py @@ -7,31 +7,49 @@ from wagtail.fields import RichTextField @register_setting class CookieConsentSettings(BaseGenericSetting): class Meta: - verbose_name = "Cookie consent settings" + verbose_name = "Cookie banner settings" cookieconsent_active = models.BooleanField( - "Activate cookie consent feature", + "Activate cookie pop-up banner", default=False, ) cookieconsent_title = models.CharField( - "cookie consent title", + "Cookie banner title", max_length=255, default="Your cookie settings", ) cookieconsent_message = RichTextField( - "cookie consent message", + "Cookie consent message", default='<p>This website deploys cookies for basic functionality and to keep it secure. These cookies are strictly necessary. Optional analysis cookies which provide us with statistical information about the use of the website may also be deployed, but only with your consent. Please review our <a href="/data-privacy-policy/">Privacy & Data Policy</a> for more information.</p>', ) + cookieconsent_essential_about = RichTextField( + 'Essential cookies information to be displayed under "Learn More"', + default="<p>Strictly necessary for the operation of a website because they enable you to navigate around the site and use features. These cookies cannot be switched off in our systems and do not store any personally identifiable information.</p>", + ) + + cookieconsent_analytics = models.BooleanField( + "Include consent option for analytics cookies", + default=False, + ) + + cookieconsent_analytics_about = RichTextField( + 'Analytics cookies information to be displayed under "Learn More"', + default="<p>With these cookies we count visits and traffic sources to help improve the performance of our services through metrics. These cookies show us which pages on our services are the most and the least popular, and how users navigate our services. The information collected is aggregated and contains no personally identifiable information. If you block these cookies, then we will not know when you have used our services.</p>", + ) + panels = [ MultiFieldPanel( [ FieldPanel("cookieconsent_active"), FieldPanel("cookieconsent_title"), FieldPanel("cookieconsent_message"), + FieldPanel("cookieconsent_essential_about"), + FieldPanel("cookieconsent_analytics"), + FieldPanel("cookieconsent_analytics_about"), ], - "cookie banner", + "Cookie banner", ), ] diff --git a/hypha/cookieconsent/static/js/cookieconsent.js b/hypha/cookieconsent/static/js/cookieconsent.js index 943fe3a371eaf84c6621e427833052d65faffa47..35f16a12430efc50620a938f13d51835f0d4ff18 100644 --- a/hypha/cookieconsent/static/js/cookieconsent.js +++ b/hypha/cookieconsent/static/js/cookieconsent.js @@ -1,38 +1,98 @@ (function () { "use strict"; - if (typeof Cookies !== "undefined") { - const cookieconsent = document.querySelector(".cookieconsent"); - - if ( - typeof Cookies.get("cookieconsent") === "undefined" && - cookieconsent - ) { - cookieconsent.classList.add("js-cookieconsent-open"); + // Used when an analytics cookie notice is enabled + const ACCEPT = "accept"; + const DECLINE = "decline"; + const ACK = "ack"; // Only for essential cookies + + // Constant key used for localstorage + const COOKIECONSENT_KEY = "cookieconsent"; + + // Class constants + const CLASS_COOKIECONSENT = "cookieconsent"; + const CLASS_LEARNMORE = "cookieconsent__learnmore"; + const CLASS_COOKIEBRIEF = "cookieconsent__brief"; + const CLASS_COOKIECONTENT = "cookieconsent__content"; + const CLASS_JS_CONSENT_OPEN = "js-cookieconsent-open"; + const CLASS_JS_LEARNMORE = "js-cookieconsent-show-learnmore"; + const CLASS_JS_LEARNMORE_EXPAND = `${CLASS_JS_LEARNMORE}-expand`; + + const cookieconsent = document.querySelector(`.${CLASS_COOKIECONSENT}`); + if (!cookieconsent) return; + + const cookieButtons = cookieconsent.querySelectorAll( + "button[data-consent]" + ); + const learnMoreToggles = cookieconsent.querySelectorAll( + ".button--learn-more" + ); + + function getConsentValue() { + return localStorage.getItem(COOKIECONSENT_KEY); + } + + function setConsentValue(value) { + if ([ACCEPT, DECLINE, ACK].includes(value)) { + localStorage.setItem(COOKIECONSENT_KEY, value); + } else { + // If for whatever reason the value is not in the predefined values, assume decline + localStorage.setItem(COOKIECONSENT_KEY, DECLINE); } + } - const cookie_buttons = Array.prototype.slice.call( - document.querySelectorAll("button[data-consent]") - ); - const sitedomain = window.location.hostname.split(".").slice(-2); - const cookiedomain = sitedomain.join("."); - let cookie_options = []; - cookie_options["domain"] = cookiedomain; - cookie_options["sameSite"] = "strict"; - cookie_options["expires"] = 365; - if (window.location.protocol === "https:") { - cookie_options["secure"] = true; + function openConsentPrompt() { + cookieconsent.classList.add(CLASS_JS_CONSENT_OPEN); + } + + function closeConsentPrompt() { + cookieconsent.classList.remove(CLASS_JS_CONSENT_OPEN); + } + + // Expose consent prompt opening/closing globally (ie. to use in a footer) + window.openConsentPrompt = openConsentPrompt; + window.closeConsentPrompt = closeConsentPrompt; + + function toggleLearnMore(open) { + const content = cookieconsent.querySelector(`.${CLASS_COOKIECONTENT}`); + if (open) { + content.classList.add(CLASS_JS_LEARNMORE); + cookieconsent.classList.add(CLASS_JS_LEARNMORE_EXPAND); + } else { + content.classList.remove(CLASS_JS_LEARNMORE); + cookieconsent.classList.remove(CLASS_JS_LEARNMORE_EXPAND); } + setInputTabIndex(`.${CLASS_LEARNMORE}`, open ? 0 : -1); + setInputTabIndex(`.${CLASS_COOKIEBRIEF}`, open ? -1 : 0); + } - cookie_buttons.forEach(function (button) { - button.addEventListener("click", function () { - if (button.getAttribute("data-consent") == "true") { - Cookies.set("cookieconsent", "accept", cookie_options); - } else { - Cookies.set("cookieconsent", "decline", cookie_options); - } - cookieconsent.classList.remove("js-cookieconsent-open"); - }); - }); + // Adds "tabability" to menu buttons/toggles + function setInputTabIndex(wrapperClassSelector, tabValue) { + const wrapper = cookieconsent.querySelector(wrapperClassSelector); + const tabables = wrapper.querySelectorAll("button, input"); + tabables.forEach((element) => (element.tabIndex = tabValue)); + } + + // Open the prompt if consent value is undefined OR if analytics has been added since the user ack'd essential cookies + if ( + getConsentValue() == undefined || + (getConsentValue() === ACK && cookieButtons.length > 1) + ) { + openConsentPrompt(); } + + cookieButtons.forEach(function (button) { + button.addEventListener("click", function () { + const buttonValue = button.getAttribute("data-consent"); + setConsentValue(buttonValue); + closeConsentPrompt(); + }); + }); + + learnMoreToggles.forEach(function (button) { + button.addEventListener("click", function () { + const buttonValue = button.getAttribute("show-learn-more"); + toggleLearnMore(buttonValue === "true"); + }); + }); })(); diff --git a/hypha/cookieconsent/templates/includes/banner.html b/hypha/cookieconsent/templates/includes/banner.html index 0f490280f4587165d4d65aee019f7d41547e2280..f0754f5136e2c85bae7f7280f3c924b0c8d20c44 100644 --- a/hypha/cookieconsent/templates/includes/banner.html +++ b/hypha/cookieconsent/templates/includes/banner.html @@ -2,14 +2,52 @@ {% if show_banner %} <div class="wrapper wrapper--cookieconsent cookieconsent"> - <div class="wrapper wrapper--small"> - <h2 class="cookieconsent__title">{% trans title %}</h2> - <div class="cookieconsent__message rich-text"> - {{ message|richtext }} + <div class="cookieconsent__content"> + <div class="cookieconsent__brief"> + <div> + <h2 class="cookieconsent__title">{% trans settings.cookieconsent_title %}</h2> + <div class="cookieconsent__message rich-text"> + {{ settings.cookieconsent_message|richtext }} + </div> + <div class="cookieconsent__actions"> + {% if settings.cookieconsent_analytics %} + <button class="button button--cookieconsent button--decline" title="{% trans 'Decline tracking cookies.' %}" type="button" data-consent="decline">{% trans 'Essential only' %}</button> + <button class="button button--cookieconsent button--accept lg:ms-[20px]" title="{% trans 'Accept tracking cookies.' %}" type="button" data-consent="accept">{% trans 'Accept all' %}</button> + {% else %} + <button class="button button--cookieconsent button--accept" title="{% trans 'Acknowledge use of essential cookies.' %}" data-consent="ack">{% trans 'Ok' %}</button> + {% endif %} + <button class="button button--cookieconsent button--learn-more lg:ms-[20px]" title="{% trans 'Learn more about specific cookies used.' %}" show-learn-more="true">{% trans 'Learn More' %}</button> + </div> + </div> </div> - <div class="cookieconsent__actions"> - <button class="button button--cookieconsent button--decline" title="{% trans 'Decline tracking cookies.' %}" type="button" data-consent="false">{% trans 'Decline' %}</button> - <button class="button button--cookieconsent button--accept button--left-space" title="{% trans 'Accept tracking cookies.' %}" type="button" data-consent="true">{% trans 'Accept' %}</button> + <div class="cookieconsent__learnmore"> + <div> + <div class="cookieconsent__statement"> + <p>{% trans "This Website uses cookies. Below you can select the categories of cookies to allow when you use our website and services." %}</p> + </div> + <div class="cookieconsent__info-wrapper"> + <div class="cookieconsent__info"> + <p class="font-semibold">{% trans "Essential Cookies" %}</p> + {{ settings.cookieconsent_essential_about|richtext }} + <span class="font-bold">{% trans "Always enabled." %}</span> + </div> + {% if settings.cookieconsent_analytics %} + <div class="cookieconsent__info lg:pl-10"> + <p class="font-semibold">{% trans "Analytics Cookies" %}</p> + {{ settings.cookieconsent_analytics_about|richtext }} + </div> + {% endif %} + </div> + <div class="cookieconsent__actions"> + {% if settings.cookieconsent_analytics %} + <button class="button button--cookieconsent button--decline" title="{% trans 'Decline tracking cookies.' %}" type="button" data-consent="decline">{% trans 'Essential only' %}</button> + <button class="button button--cookieconsent button--accept lg:ms-[20px]" title="{% trans 'Accept tracking cookies.' %}" type="button" data-consent="accept">{% trans 'Accept all' %}</button> + {% else %} + <button class="button button--cookieconsent button--accept" title="{% trans 'Acknowledge use of essential cookies.' %}" data-consent="ack">{% trans 'Ok' %}</button> + {% endif %} + <button class="button button--cookieconsent button--learn-more lg:ms-[20px]" title="{% trans 'Return to main cookie menu.' %}" show-learn-more="false" tabindex="-1">{% trans 'Back' %}</button> + </div> + </div> </div> </div> </div> diff --git a/hypha/cookieconsent/templatetags/cookieconsent_tags.py b/hypha/cookieconsent/templatetags/cookieconsent_tags.py index 2c58000cc346ed2ac998389e85c9cea4dc19baf6..bd18fc35c4c7056bc46da39c3524e62d4483311a 100644 --- a/hypha/cookieconsent/templatetags/cookieconsent_tags.py +++ b/hypha/cookieconsent/templatetags/cookieconsent_tags.py @@ -13,8 +13,4 @@ def cookie_banner(context): "cookieconsent" ) - return { - "show_banner": show_banner, - "title": settings.cookieconsent_title, - "message": settings.cookieconsent_message, - } + return {"show_banner": show_banner, "settings": settings} diff --git a/hypha/core/migrations/0005_alter_systemsettings_footer_content.py b/hypha/core/migrations/0005_alter_systemsettings_footer_content.py new file mode 100644 index 0000000000000000000000000000000000000000..231d94895baabe018d8437cd449382843237ae6b --- /dev/null +++ b/hypha/core/migrations/0005_alter_systemsettings_footer_content.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.11 on 2024-06-06 21:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0004_move_system_settings_data"), + ] + + operations = [ + migrations.AlterField( + model_name="systemsettings", + name="footer_content", + field=models.TextField( + blank=True, + default='<p>Configure this text in Wagtail admin -> Settings -> System settings.</p>\n<br>\n<a href="#" onclick="openConsentPrompt()">Cookie Settings</a>', + help_text="This will be added to the footer, html tags is allowed.", + verbose_name="Footer content", + ), + ), + ] diff --git a/hypha/core/models/system_settings.py b/hypha/core/models/system_settings.py index f3307bc7bf0320e48d0e7ee612b910cf949d7b94..530195b7ae1a65b0c268659bc3a2bf02fadeab14 100644 --- a/hypha/core/models/system_settings.py +++ b/hypha/core/models/system_settings.py @@ -53,7 +53,7 @@ class SystemSettings(BaseGenericSetting): footer_content = models.TextField( "Footer content", - default="<p>Configure this text in Wagtail admin -> Settings -> System settings.</p>", + default='<p>Configure this text in Wagtail admin -> Settings -> System settings.</p>\n<br>\n<a href="#" onclick="openConsentPrompt()">Cookie Settings</a>', help_text=_("This will be added to the footer, html tags is allowed."), blank=True, ) diff --git a/hypha/static_src/sass/components/_cookieconsent.scss b/hypha/static_src/sass/components/_cookieconsent.scss index 65255aabe5a8af45fee19260464262bdf8f49e6e..35f4be23de32bc221cb2b061686c32b5eab420ea 100644 --- a/hypha/static_src/sass/components/_cookieconsent.scss +++ b/hypha/static_src/sass/components/_cookieconsent.scss @@ -6,12 +6,101 @@ border-block-start: 4px solid $color--light-blue; transform: translateY(100vh); transition: all $transition; + overflow: hidden; + max-height: 37.5rem; + + @include media-query(lg) { + max-height: 19rem; + } a { color: inherit; + text-decoration-line: underline; + } + + &__content { + width: 200%; + margin-left: 0; + transition: all $transition; + } + + &__content > div { + width: 50%; + height: 100%; + float: left; + display: flex; + justify-content: center; + + & > div { + width: 70%; + } + } + + &__actions { + display: flex; + flex-direction: column; + align-items: center; + + & > button { + margin-top: 0.5rem; + max-width: 22rem; + min-width: 10rem; + width: 100%; + } + + @include media-query(lg) { + width: fit-content; + display: block; + + & > button { + width: auto; + } + } + } + + &__statement { + display: none; + + @include media-query(lg) { + display: block; + } + } + + &__info-wrapper { + display: flex; + flex-direction: column; + + @include media-query(lg) { + flex-direction: row; + margin-block-end: 1rem; + } + } + + &__info { + display: flex; + flex-direction: column; + justify-content: left; + min-width: 250px; + margin-bottom: 0.5rem; + + & > * { + font-size: 0.875rem; + line-height: 1.25rem; + margin-block: 0.25rem; + } } } -.js-cookieconsent-open { - transform: translateY(0); +.js-cookieconsent { + &-open { + transform: translateY(0); + } + + &-show-learnmore-expand { + max-height: 700px; + } + + &-show-learnmore { + margin-left: -100%; + } }