From 588639a47a6c2fe2a77da424995b72eef004d7ed Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 28 Jan 2026 10:52:40 -0600 Subject: [PATCH] gdpr cookie consent --- assets/js/app.ts | 6 ++ assets/js/cookie_consent.js | 49 +++++++++++++ lib/towerops_web/components/cookie_consent.ex | 72 +++++++++++++++++++ lib/towerops_web/components/layouts.ex | 5 ++ lib/towerops_web/plugs/detect_eu_user.ex | 49 +++++++++++++ lib/towerops_web/router.ex | 1 + priv/static/changelog.txt | 1 + 7 files changed, 183 insertions(+) create mode 100644 assets/js/cookie_consent.js create mode 100644 lib/towerops_web/components/cookie_consent.ex create mode 100644 lib/towerops_web/plugs/detect_eu_user.ex diff --git a/assets/js/app.ts b/assets/js/app.ts index dba421e5..a09971de 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -33,6 +33,7 @@ import "./passkey_login" import "./passkey_discoverable" import type { SensorChartHook } from "./types/liveview" import { DeviceListReorder } from "./device_list_reorder" +import { initCookieConsent } from "./cookie_consent" // Helper function to convert range string to milliseconds function getTimeRangeMs(range: string): number { @@ -694,6 +695,11 @@ window.addEventListener("phx:copy", (event: any) => { // connect if there are any LiveViews on the page liveSocket.connect() +// Initialize cookie consent handler +document.addEventListener('DOMContentLoaded', () => { + initCookieConsent() +}) + // expose liveSocket on window for web console debug logs and latency simulation: // >> liveSocket.enableDebug() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session diff --git a/assets/js/cookie_consent.js b/assets/js/cookie_consent.js new file mode 100644 index 00000000..80f52504 --- /dev/null +++ b/assets/js/cookie_consent.js @@ -0,0 +1,49 @@ +/** + * Cookie Consent Handler + * + * Handles cookie consent acceptance for GDPR compliance. + * Sets a cookie that expires in 1 year when the user accepts. + */ + +export function initCookieConsent() { + // Check if consent cookie already exists on page load + if (hasConsentCookie()) { + hideBanner(); + } + + // Listen for click events on the accept button + document.addEventListener('click', (event) => { + if (event.target.matches('[data-cookie-consent="accept"]') || + event.target.closest('[data-cookie-consent="accept"]')) { + acceptCookies(); + } + }); +} + +function hasConsentCookie() { + return document.cookie.split('; ').some(cookie => cookie.startsWith('cookie_consent=accepted')); +} + +function hideBanner() { + const banner = document.getElementById('cookie-consent-banner'); + if (banner) { + banner.style.display = 'none'; + } +} + +function acceptCookies() { + // Set cookie that expires in 1 year + const expiryDate = new Date(); + expiryDate.setFullYear(expiryDate.getFullYear() + 1); + + document.cookie = `cookie_consent=accepted; expires=${expiryDate.toUTCString()}; path=/; SameSite=Lax; Secure`; + + // Hide the banner with animation + const banner = document.getElementById('cookie-consent-banner'); + if (banner) { + banner.classList.add('opacity-0', 'translate-y-4'); + setTimeout(() => { + banner.remove(); + }, 200); + } +} diff --git a/lib/towerops_web/components/cookie_consent.ex b/lib/towerops_web/components/cookie_consent.ex new file mode 100644 index 00000000..eb845824 --- /dev/null +++ b/lib/towerops_web/components/cookie_consent.ex @@ -0,0 +1,72 @@ +defmodule ToweropsWeb.Components.CookieConsent do + @moduledoc """ + Cookie consent banner component for GDPR compliance. + + Only displays to users from EU/EEA countries and for users who haven't + yet accepted the cookie policy. + + Since we only use essential cookies (session, CSRF), the banner is + informational and provides an "Acknowledge" button. + """ + use Phoenix.Component + + import ToweropsWeb.CoreComponents, only: [icon: 1] + + @doc """ + Renders a cookie consent banner at the bottom of the page. + + ## Attributes + * `requires_consent` - boolean, whether to show the banner (EU/EEA users) + + Note: The banner is always rendered if requires_consent is true. + JavaScript will hide it immediately if the consent cookie exists. + """ + attr :requires_consent, :boolean, required: true + + def banner(assigns) do + ~H""" + <%= if @requires_consent do %> + + <% end %> + """ + end +end diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index f8f7509e..92abef3e 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -5,6 +5,8 @@ defmodule ToweropsWeb.Layouts do """ use ToweropsWeb, :html + alias ToweropsWeb.Components.CookieConsent + # Embed all files in layouts/* within this module. # The default root.html.heex file contains the HTML # skeleton of your application, namely HTML headers @@ -74,6 +76,7 @@ defmodule ToweropsWeb.Layouts do <.flash_group flash={@flash} /> + """ end @@ -392,6 +395,7 @@ defmodule ToweropsWeb.Layouts do <.flash_group flash={@flash} /> + """ end @@ -653,6 +657,7 @@ defmodule ToweropsWeb.Layouts do <.flash_group flash={@flash} /> + """ end diff --git a/lib/towerops_web/plugs/detect_eu_user.ex b/lib/towerops_web/plugs/detect_eu_user.ex new file mode 100644 index 00000000..0aa82270 --- /dev/null +++ b/lib/towerops_web/plugs/detect_eu_user.ex @@ -0,0 +1,49 @@ +defmodule ToweropsWeb.Plugs.DetectEUUser do + @moduledoc """ + Detects if a user is from an EU/EEA country that requires GDPR cookie consent. + + This plug checks the CloudFlare CF-IPCountry header (if available) or other + geolocation headers to determine if the user is from an EU/EEA country. + + The result is stored in conn.assigns.requires_cookie_consent + """ + import Plug.Conn + + # EU/EEA country codes that require GDPR cookie consent + # EU member states + EEA countries (Norway, Iceland, Liechtenstein) + @eu_eea_countries ~w( + AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE + IS NO LI + ) + + @doc false + def init(opts), do: opts + + @doc false + def call(conn, _opts) do + requires_consent = detect_eu_user(conn) + assign(conn, :requires_cookie_consent, requires_consent) + end + + defp detect_eu_user(conn) do + # Try CloudFlare's CF-IPCountry header first (most reliable if behind CF) + case get_req_header(conn, "cf-ipcountry") do + [country_code] when country_code != "" -> + country_code = String.upcase(country_code) + country_code in @eu_eea_countries + + _ -> + # Try X-Country header (some proxies set this) + case get_req_header(conn, "x-country") do + [country_code] when country_code != "" -> + country_code = String.upcase(country_code) + country_code in @eu_eea_countries + + _ -> + # No reliable country detection available + # Default to showing consent banner (conservative approach for GDPR compliance) + true + end + end + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index e723e7d1..14d907e0 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -19,6 +19,7 @@ defmodule ToweropsWeb.Router do plug :put_secure_browser_headers plug :fetch_current_scope_for_user plug :store_return_to_for_liveview + plug ToweropsWeb.Plugs.DetectEUUser end pipeline :api do diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 4fa3e3fa..c4d22aaa 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -4,6 +4,7 @@ Devices Working 2026-01-28 * Major SNMP Discovery overhaul and improvements +* GDPR: Cookie consent banner 2025-01-27 * Infrastructure improvements