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.
- `PLIMI_PORT`: host port used by Docker Compose, default `8080`.
Analytics is self-hosted and enabled by default for audience measurement. The
tracker excludes URL search parameters and hashes, honors Do Not Track, and can
be disabled locally through the privacy controls.
Analytics is self-hosted and disabled until the visitor explicitly consents.
The tracker excludes URL search parameters and hashes, honors Do Not Track, and
can be disabled locally through the privacy controls.
The privacy page is available at `/privacy`; deployers must replace all example
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
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

View File

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

View File

@@ -2,14 +2,12 @@ import { Link } from "react-router-dom";
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
export function Footer() {
const { enabled, toggleAnalytics } = useAnalyticsConsent();
const { choice, openSettings } = useAnalyticsConsent();
return (
<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">
<span>
Plimi processes tool inputs locally in your browser.
</span>
<span>Plimi processes tool inputs locally in your browser.</span>
<nav className="flex flex-wrap items-center gap-x-5 gap-y-2">
<Link
to="/privacy"
@@ -18,11 +16,10 @@ export function Footer() {
Privacy & legal
</Link>
<button
onClick={toggleAnalytics}
aria-pressed={!enabled}
onClick={openSettings}
className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
>
Analytics {enabled ? "on" : "off"}
Analytics settings ({choice === "granted" ? "on" : "off"})
</button>
</nav>
</div>

View File

@@ -1,23 +1,38 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Link } from "react-router-dom";
import {
hasAnalyticsConfiguration,
privacyConfig,
} from "./privacy-config";
import {
AnalyticsConsentContext,
type AnalyticsPreferenceValue,
type AnalyticsConsentValue,
type ConsentChoice,
} from "./analytics-consent-context";
const CONSENT_KEY = "plimi.analytics-consent.v1";
const LEGACY_DISABLED_KEY = "umami.disabled";
const SCRIPT_ID = "plimi-umami-script";
const UMAMI_DISABLED_KEY = "umami.disabled";
function analyticsEnabledInitially(): boolean {
return window.localStorage.getItem(UMAMI_DISABLED_KEY) !== "1";
function storedChoice(): ConsentChoice {
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() {
@@ -40,50 +55,156 @@ function loadAnalyticsScript() {
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({
children,
}: {
children: ReactNode;
}) {
const [enabled, setEnabled] = useState(analyticsEnabledInitially);
const [choice, setChoice] = useState<ConsentChoice>(storedChoice);
const [isSettingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
if (enabled) {
if (choice === "granted") {
loadAnalyticsScript();
} else {
removeAnalyticsScript();
}
}, [enabled]);
}, [choice]);
const value = useMemo<AnalyticsPreferenceValue>(
() => ({
enabled,
disableAnalytics: () => {
window.localStorage.setItem(UMAMI_DISABLED_KEY, "1");
setEnabled(false);
window.location.reload();
},
enableAnalytics: () => {
window.localStorage.removeItem(UMAMI_DISABLED_KEY);
setEnabled(true);
window.location.reload();
},
toggleAnalytics: () => {
if (enabled) {
window.localStorage.setItem(UMAMI_DISABLED_KEY, "1");
const saveChoice = useCallback(
(nextChoice: Exclude<ConsentChoice, "pending">) => {
const wasGranted = choice === "granted";
try {
window.localStorage.setItem(CONSENT_KEY, nextChoice);
if (nextChoice === "denied") {
window.localStorage.setItem(LEGACY_DISABLED_KEY, "1");
} 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();
}
},
[choice]
);
const value = useMemo<AnalyticsConsentValue>(
() => ({
choice,
isSettingsOpen,
accept: () => saveChoice("granted"),
reject: () => saveChoice("denied"),
openSettings: () => setSettingsOpen(true),
closeSettings: () => setSettingsOpen(false),
}),
[enabled]
[choice, isSettingsOpen, saveChoice]
);
return (
<AnalyticsConsentContext.Provider value={value}>
{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>
);
}

View File

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

View File

@@ -21,7 +21,7 @@ function Section({
}
export function PrivacyPage() {
const { enabled, enableAnalytics, disableAnalytics } = useAnalyticsConsent();
const { choice, openSettings } = useAnalyticsConsent();
return (
<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
</span>
<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>
<p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]">
Effective {privacyConfig.policyEffectiveDate}. This notice explains
@@ -72,14 +72,15 @@ export function PrivacyPage() {
</p>
</Section>
<Section title="Audience measurement">
<Section title="Optional analytics">
<p className="m-0">
Plimi uses a self-hosted {privacyConfig.analyticsProvider} audience
measurement script from {privacyConfig.analyticsHost}. It measures
page visits using cookieless analytics and may process the visited
route, timestamp, browser, operating system, device type, screen
size, language, referrer, and an approximate country derived from
network information.
Analytics are disabled until you consent. If accepted, the site loads{" "}
{privacyConfig.analyticsProvider} from {privacyConfig.analyticsHost}.
It may process the visited route, referrer, timestamp, browser,
operating system, device type, screen size, language, and an
approximate country derived from the IP address. Umami states that it
does not store the IP address, but uses request and device information
to generate a session identifier.
</p>
<p className="m-0">
Purpose: aggregate audience measurement and improvement of Plimi.
@@ -95,37 +96,40 @@ export function PrivacyPage() {
or `umami.identify()` data. The tracker excludes URL search
parameters and hash fragments and honors browser Do Not Track.
</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">
{enabled ? (
<button
onClick={disableAnalytics}
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)]"
>
Disable analytics
</button>
) : (
<button
onClick={enableAnalytics}
onClick={openSettings}
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
Change analytics choice
</button>
)}
<span className="font-mono text-[11px] uppercase text-[var(--p-muted)]">
Analytics are currently {enabled ? "enabled" : "disabled"} on this
browser
Current choice:{" "}
{choice === "granted"
? "accepted"
: choice === "denied"
? "rejected"
: "not chosen"}
</span>
</div>
<p className="m-0">
Opt out locally at any time. Plimi sets{" "}
<code className="text-[var(--p-text)]">localStorage["umami.disabled"] = "1"</code>{" "}
when you disable analytics and removes that key when you re-enable it.
Your choice is stored locally as{" "}
<code className="text-[var(--p-text)]">
localStorage[&quot;plimi.analytics-consent.v1&quot;]
</code>
. You can change or withdraw it at any time using these controls.
</p>
</Section>
<Section title="Local storage and essential preferences">
<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
settings. Plimi does not use analytics cookies, advertising cookies,
or marketing pixels.