add GDPR Banner

This commit is contained in:
achraf
2026-06-07 17:43:45 +02:00
parent 25b9f36686
commit 365b98c7dd
6 changed files with 206 additions and 74 deletions

View File

@@ -62,12 +62,17 @@ Environment:
- `VITE_ANALYTICS_RETENTION_MONTHS`: disclosed analytics retention period. Configure Umami/database deletion to match it. - `VITE_ANALYTICS_RETENTION_MONTHS`: disclosed analytics retention period. Configure Umami/database deletion to match it.
- `PLIMI_PORT`: host port used by Docker Compose, default `8080`. - `PLIMI_PORT`: host port used by Docker Compose, default `8080`.
Analytics is self-hosted and enabled by default for audience measurement. The Analytics is self-hosted and disabled until the visitor explicitly consents.
tracker excludes URL search parameters and hashes, honors Do Not Track, and can The tracker excludes URL search parameters and hashes, honors Do Not Track, and
be disabled locally through the privacy controls. can be disabled locally through the privacy controls.
The privacy page is available at `/privacy`; deployers must replace all example The privacy page is available at `/privacy`; deployers must replace all example
controller and hosting values before publishing. controller and hosting values before publishing.
The bundled Nginx configuration disables routine access logging. If a reverse
proxy, CDN, hosting platform, or firewall logs visitor IP addresses or request
metadata, document that processing and enforce an appropriate retention period
in that service.
The Docker image is environment-independent. At container startup, its entrypoint The Docker image is environment-independent. At container startup, its entrypoint
generates `/config.js` from `.env`, so the same image can be promoted between generates `/config.js` from `.env`, so the same image can be promoted between
environments without rebuilding. The file is ignored by Git and excluded from environments without rebuilding. The file is ignored by Git and excluded from

View File

@@ -6,6 +6,7 @@ server {
index index.html; index index.html;
server_tokens off; server_tokens off;
access_log off;
gzip on; gzip on;
gzip_comp_level 6; gzip_comp_level 6;

View File

@@ -2,14 +2,12 @@ import { Link } from "react-router-dom";
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context"; import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
export function Footer() { export function Footer() {
const { enabled, toggleAnalytics } = useAnalyticsConsent(); const { choice, openSettings } = useAnalyticsConsent();
return ( return (
<footer className="border-t border-[var(--p-border)] px-6 py-6 md:px-14"> <footer className="border-t border-[var(--p-border)] px-6 py-6 md:px-14">
<div className="mx-auto flex max-w-[1200px] flex-col gap-4 text-sm text-[var(--p-muted)] sm:flex-row sm:items-center sm:justify-between"> <div className="mx-auto flex max-w-[1200px] flex-col gap-4 text-sm text-[var(--p-muted)] sm:flex-row sm:items-center sm:justify-between">
<span> <span>Plimi processes tool inputs locally in your browser.</span>
Plimi processes tool inputs locally in your browser.
</span>
<nav className="flex flex-wrap items-center gap-x-5 gap-y-2"> <nav className="flex flex-wrap items-center gap-x-5 gap-y-2">
<Link <Link
to="/privacy" to="/privacy"
@@ -18,11 +16,10 @@ export function Footer() {
Privacy & legal Privacy & legal
</Link> </Link>
<button <button
onClick={toggleAnalytics} onClick={openSettings}
aria-pressed={!enabled}
className="text-[var(--p-muted)] hover:text-[var(--p-text)]" className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
> >
Analytics {enabled ? "on" : "off"} Analytics settings ({choice === "granted" ? "on" : "off"})
</button> </button>
</nav> </nav>
</div> </div>

View File

@@ -1,23 +1,38 @@
import { import {
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Link } from "react-router-dom";
import { import {
hasAnalyticsConfiguration, hasAnalyticsConfiguration,
privacyConfig, privacyConfig,
} from "./privacy-config"; } from "./privacy-config";
import { import {
AnalyticsConsentContext, AnalyticsConsentContext,
type AnalyticsPreferenceValue, type AnalyticsConsentValue,
type ConsentChoice,
} from "./analytics-consent-context"; } from "./analytics-consent-context";
const CONSENT_KEY = "plimi.analytics-consent.v1";
const LEGACY_DISABLED_KEY = "umami.disabled";
const SCRIPT_ID = "plimi-umami-script"; const SCRIPT_ID = "plimi-umami-script";
const UMAMI_DISABLED_KEY = "umami.disabled";
function analyticsEnabledInitially(): boolean { function storedChoice(): ConsentChoice {
return window.localStorage.getItem(UMAMI_DISABLED_KEY) !== "1"; try {
const value = window.localStorage.getItem(CONSENT_KEY);
if (value === "granted" || value === "denied") return value;
if (window.localStorage.getItem(LEGACY_DISABLED_KEY) === "1") {
return "denied";
}
} catch {
// Treat unavailable storage as no consent.
}
return "pending";
} }
function removeAnalyticsScript() { function removeAnalyticsScript() {
@@ -40,50 +55,156 @@ function loadAnalyticsScript() {
document.head.appendChild(script); document.head.appendChild(script);
} }
function ConsentPanel({
settings,
onAccept,
onReject,
onClose,
}: {
settings: boolean;
onAccept: () => void;
onReject: () => void;
onClose?: () => void;
}) {
return (
<div
className={`fixed inset-x-3 z-[80] mx-auto max-w-[680px] rounded-[18px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5 md:inset-x-6 md:p-6 ${
settings ? "top-1/2 -translate-y-1/2" : "bottom-3 md:bottom-6"
}`}
style={{ boxShadow: "0 24px 70px -28px var(--p-shadow-soft)" }}
role={settings ? "dialog" : "region"}
aria-modal={settings ? true : undefined}
aria-label="Analytics preferences"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
privacy choice
</div>
<h2 className="mb-0 mt-2 font-sans text-xl font-semibold tracking-tight text-[var(--p-text)]">
Optional, cookieless analytics
</h2>
</div>
{settings && onClose && (
<button
onClick={onClose}
aria-label="Close analytics preferences"
className="rounded-lg bg-[var(--p-chip)] px-3 py-2 text-sm text-[var(--p-muted)]"
>
Close
</button>
)}
</div>
<p className="mb-0 mt-3 text-sm leading-relaxed text-[var(--p-muted)]">
With your permission, Plimi loads {privacyConfig.analyticsProvider} from{" "}
{privacyConfig.analyticsHost} to measure page visits and device/browser
categories. Tool inputs, files, and outputs are never included.
</p>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
<Link
to="/privacy"
onClick={onClose}
className="mr-auto px-1 py-2 text-center text-sm text-[var(--p-muted)] underline decoration-[var(--p-border)] underline-offset-4"
>
Read the privacy notice
</Link>
<button
onClick={onReject}
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-text)]"
>
Reject analytics
</button>
<button
onClick={onAccept}
className="rounded-[10px] bg-[var(--p-accent)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-accent-ink)]"
>
Accept analytics
</button>
</div>
</div>
);
}
export function AnalyticsConsentProvider({ export function AnalyticsConsentProvider({
children, children,
}: { }: {
children: ReactNode; children: ReactNode;
}) { }) {
const [enabled, setEnabled] = useState(analyticsEnabledInitially); const [choice, setChoice] = useState<ConsentChoice>(storedChoice);
const [isSettingsOpen, setSettingsOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (enabled) { if (choice === "granted") {
loadAnalyticsScript(); loadAnalyticsScript();
} else { } else {
removeAnalyticsScript(); removeAnalyticsScript();
} }
}, [enabled]); }, [choice]);
const value = useMemo<AnalyticsPreferenceValue>( const saveChoice = useCallback(
() => ({ (nextChoice: Exclude<ConsentChoice, "pending">) => {
enabled, const wasGranted = choice === "granted";
disableAnalytics: () => {
window.localStorage.setItem(UMAMI_DISABLED_KEY, "1"); try {
setEnabled(false); window.localStorage.setItem(CONSENT_KEY, nextChoice);
window.location.reload(); if (nextChoice === "denied") {
}, window.localStorage.setItem(LEGACY_DISABLED_KEY, "1");
enableAnalytics: () => {
window.localStorage.removeItem(UMAMI_DISABLED_KEY);
setEnabled(true);
window.location.reload();
},
toggleAnalytics: () => {
if (enabled) {
window.localStorage.setItem(UMAMI_DISABLED_KEY, "1");
} else { } else {
window.localStorage.removeItem(UMAMI_DISABLED_KEY); window.localStorage.removeItem(LEGACY_DISABLED_KEY);
} }
setEnabled(!enabled); } catch {
// The in-memory choice still applies for the current page.
}
setChoice(nextChoice);
setSettingsOpen(false);
if (wasGranted && nextChoice === "denied") {
window.location.reload(); window.location.reload();
}, }
},
[choice]
);
const value = useMemo<AnalyticsConsentValue>(
() => ({
choice,
isSettingsOpen,
accept: () => saveChoice("granted"),
reject: () => saveChoice("denied"),
openSettings: () => setSettingsOpen(true),
closeSettings: () => setSettingsOpen(false),
}), }),
[enabled] [choice, isSettingsOpen, saveChoice]
); );
return ( return (
<AnalyticsConsentContext.Provider value={value}> <AnalyticsConsentContext.Provider value={value}>
{children} {children}
{choice === "pending" && (
<ConsentPanel
settings={false}
onAccept={value.accept}
onReject={value.reject}
/>
)}
{isSettingsOpen && (
<>
<button
aria-label="Close analytics preferences"
onClick={value.closeSettings}
className="fixed inset-0 z-[70] bg-[color-mix(in_oklab,var(--p-bg)_70%,transparent)] backdrop-blur-sm"
/>
<ConsentPanel
settings
onAccept={value.accept}
onReject={value.reject}
onClose={value.closeSettings}
/>
</>
)}
</AnalyticsConsentContext.Provider> </AnalyticsConsentContext.Provider>
); );
} }

View File

@@ -1,16 +1,20 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
export interface AnalyticsPreferenceValue { export type ConsentChoice = "pending" | "granted" | "denied";
enabled: boolean;
disableAnalytics: () => void; export interface AnalyticsConsentValue {
enableAnalytics: () => void; choice: ConsentChoice;
toggleAnalytics: () => void; isSettingsOpen: boolean;
accept: () => void;
reject: () => void;
openSettings: () => void;
closeSettings: () => void;
} }
export const AnalyticsConsentContext = export const AnalyticsConsentContext =
createContext<AnalyticsPreferenceValue | null>(null); createContext<AnalyticsConsentValue | null>(null);
export function useAnalyticsConsent(): AnalyticsPreferenceValue { export function useAnalyticsConsent(): AnalyticsConsentValue {
const context = useContext(AnalyticsConsentContext); const context = useContext(AnalyticsConsentContext);
if (!context) { if (!context) {
throw new Error( throw new Error(

View File

@@ -21,7 +21,7 @@ function Section({
} }
export function PrivacyPage() { export function PrivacyPage() {
const { enabled, enableAnalytics, disableAnalytics } = useAnalyticsConsent(); const { choice, openSettings } = useAnalyticsConsent();
return ( return (
<div className="mx-auto flex max-w-[920px] flex-col gap-6 pb-16 animate-plimi-slide"> <div className="mx-auto flex max-w-[920px] flex-col gap-6 pb-16 animate-plimi-slide">
@@ -30,7 +30,7 @@ export function PrivacyPage() {
privacy notice & legal information privacy notice & legal information
</span> </span>
<h1 className="m-0 font-sans text-4xl font-bold leading-[1.03] tracking-tight text-[var(--p-text)] md:text-[52px]"> <h1 className="m-0 font-sans text-4xl font-bold leading-[1.03] tracking-tight text-[var(--p-text)] md:text-[52px]">
Your tools stay local. Analytics stay narrow. Your tools are local. Analytics are optional.
</h1> </h1>
<p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]"> <p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]">
Effective {privacyConfig.policyEffectiveDate}. This notice explains Effective {privacyConfig.policyEffectiveDate}. This notice explains
@@ -72,14 +72,15 @@ export function PrivacyPage() {
</p> </p>
</Section> </Section>
<Section title="Audience measurement"> <Section title="Optional analytics">
<p className="m-0"> <p className="m-0">
Plimi uses a self-hosted {privacyConfig.analyticsProvider} audience Analytics are disabled until you consent. If accepted, the site loads{" "}
measurement script from {privacyConfig.analyticsHost}. It measures {privacyConfig.analyticsProvider} from {privacyConfig.analyticsHost}.
page visits using cookieless analytics and may process the visited It may process the visited route, referrer, timestamp, browser,
route, timestamp, browser, operating system, device type, screen operating system, device type, screen size, language, and an
size, language, referrer, and an approximate country derived from approximate country derived from the IP address. Umami states that it
network information. does not store the IP address, but uses request and device information
to generate a session identifier.
</p> </p>
<p className="m-0"> <p className="m-0">
Purpose: aggregate audience measurement and improvement of Plimi. Purpose: aggregate audience measurement and improvement of Plimi.
@@ -95,37 +96,40 @@ export function PrivacyPage() {
or `umami.identify()` data. The tracker excludes URL search or `umami.identify()` data. The tracker excludes URL search
parameters and hash fragments and honors browser Do Not Track. parameters and hash fragments and honors browser Do Not Track.
</p> </p>
<p className="m-0">
The legal basis is your consent under Article 6(1)(a) GDPR. Umami is
not loaded and no analytics request is sent unless you select
&quot;Accept analytics&quot;. You may withdraw consent at any time,
without affecting processing that occurred before withdrawal.
</p>
<div className="flex flex-wrap items-center gap-3 pt-2"> <div className="flex flex-wrap items-center gap-3 pt-2">
{enabled ? ( <button
<button onClick={openSettings}
onClick={disableAnalytics} className="rounded-[10px] bg-[var(--p-accent)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-accent-ink)]"
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-text)]" >
> Change analytics choice
Disable analytics </button>
</button>
) : (
<button
onClick={enableAnalytics}
className="rounded-[10px] bg-[var(--p-accent)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-accent-ink)]"
>
Enable analytics
</button>
)}
<span className="font-mono text-[11px] uppercase text-[var(--p-muted)]"> <span className="font-mono text-[11px] uppercase text-[var(--p-muted)]">
Analytics are currently {enabled ? "enabled" : "disabled"} on this Current choice:{" "}
browser {choice === "granted"
? "accepted"
: choice === "denied"
? "rejected"
: "not chosen"}
</span> </span>
</div> </div>
<p className="m-0"> <p className="m-0">
Opt out locally at any time. Plimi sets{" "} Your choice is stored locally as{" "}
<code className="text-[var(--p-text)]">localStorage["umami.disabled"] = "1"</code>{" "} <code className="text-[var(--p-text)]">
when you disable analytics and removes that key when you re-enable it. localStorage[&quot;plimi.analytics-consent.v1&quot;]
</code>
. You can change or withdraw it at any time using these controls.
</p> </p>
</Section> </Section>
<Section title="Local storage and essential preferences"> <Section title="Local storage and essential preferences">
<p className="m-0"> <p className="m-0">
Plimi stores your theme preference and analytics opt-out preference in Plimi stores your theme preference and analytics consent preference in
browser local storage. These values are used only to remember your browser local storage. These values are used only to remember your
settings. Plimi does not use analytics cookies, advertising cookies, settings. Plimi does not use analytics cookies, advertising cookies,
or marketing pixels. or marketing pixels.