gdpr cookie consent

This commit is contained in:
Graham McIntire 2026-01-28 10:52:40 -06:00
parent f350a9ade4
commit 588639a47a
7 changed files with 183 additions and 0 deletions

View file

@ -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

View file

@ -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);
}
}

View file

@ -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 %>
<div
id="cookie-consent-banner"
class="fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 shadow-lg"
role="dialog"
aria-live="polite"
aria-label="Cookie consent"
>
<div class="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div class="flex-1 flex items-start gap-3">
<.icon
name="hero-information-circle"
class="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
/>
<div class="text-sm text-gray-700 dark:text-gray-300">
<p class="font-medium mb-1">We respect your privacy</p>
<p>
We use essential cookies to maintain your session and ensure the security of our service. We do not use tracking or advertising cookies.
<a
href="/privacy"
class="underline hover:text-gray-900 dark:hover:text-gray-100 font-medium"
>
Learn more
</a>
</p>
</div>
</div>
<div class="flex gap-2 w-full sm:w-auto">
<button
type="button"
data-cookie-consent="accept"
class="flex-1 sm:flex-none px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900"
>
Accept
</button>
</div>
</div>
</div>
</div>
<% end %>
"""
end
end

View file

@ -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
</div>
<.flash_group flash={@flash} />
<CookieConsent.banner requires_consent={Map.get(assigns, :requires_cookie_consent, false)} />
"""
end
@ -392,6 +395,7 @@ defmodule ToweropsWeb.Layouts do
</div>
<.flash_group flash={@flash} />
<CookieConsent.banner requires_consent={Map.get(assigns, :requires_cookie_consent, false)} />
"""
end
@ -653,6 +657,7 @@ defmodule ToweropsWeb.Layouts do
</div>
<.flash_group flash={@flash} />
<CookieConsent.banner requires_consent={Map.get(assigns, :requires_cookie_consent, false)} />
"""
end

View file

@ -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

View file

@ -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

View file

@ -4,6 +4,7 @@ Devices Working
2026-01-28
* Major SNMP Discovery overhaul and improvements
* GDPR: Cookie consent banner
2025-01-27
* Infrastructure improvements