diff --git a/docs/setup/administrators/cookie-consent.md b/docs/setup/administrators/cookie-consent.md new file mode 100644 index 0000000000..29ac36f5c4 --- /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 +Cookie Settings +``` + +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 0000000000..162437b5ab --- /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="

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.

", + 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="

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.

", + 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='

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 Privacy & Data Policy for more information.

', + 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 d700d4a050..3631ddcc82 100644 --- a/hypha/cookieconsent/models.py +++ b/hypha/cookieconsent/models.py @@ -7,31 +7,49 @@ @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='

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 Privacy & Data Policy for more information.

', ) + cookieconsent_essential_about = RichTextField( + 'Essential cookies information to be displayed under "Learn More"', + default="

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.

", + ) + + 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="

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.

", + ) + 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 943fe3a371..35f16a1243 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 0f490280f4..f0754f5136 100644 --- a/hypha/cookieconsent/templates/includes/banner.html +++ b/hypha/cookieconsent/templates/includes/banner.html @@ -2,14 +2,52 @@ {% if show_banner %}
-
-

{% trans title %}

-
- {{ message|richtext }} +
+
+
+

{% trans settings.cookieconsent_title %}

+
+ {{ settings.cookieconsent_message|richtext }} +
+
+ {% if settings.cookieconsent_analytics %} + + + {% else %} + + {% endif %} + +
+
-
- - +
+
+
+

{% trans "This Website uses cookies. Below you can select the categories of cookies to allow when you use our website and services." %}

+
+
+
+

{% trans "Essential Cookies" %}

+ {{ settings.cookieconsent_essential_about|richtext }} + {% trans "Always enabled." %} +
+ {% if settings.cookieconsent_analytics %} +
+

{% trans "Analytics Cookies" %}

+ {{ settings.cookieconsent_analytics_about|richtext }} +
+ {% endif %} +
+
+ {% if settings.cookieconsent_analytics %} + + + {% else %} + + {% endif %} + +
+
diff --git a/hypha/cookieconsent/templatetags/cookieconsent_tags.py b/hypha/cookieconsent/templatetags/cookieconsent_tags.py index 2c58000cc3..bd18fc35c4 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 0000000000..231d94895b --- /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='

Configure this text in Wagtail admin -> Settings -> System settings.

\n
\nCookie Settings', + 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 f3307bc7bf..530195b7ae 100644 --- a/hypha/core/models/system_settings.py +++ b/hypha/core/models/system_settings.py @@ -53,7 +53,7 @@ class Meta: footer_content = models.TextField( "Footer content", - default="

Configure this text in Wagtail admin -> Settings -> System settings.

", + default='

Configure this text in Wagtail admin -> Settings -> System settings.

\n
\nCookie Settings', 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 65255aabe5..35f4be23de 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%; + } }