Switch analytics privacy controls to opt-out

This commit is contained in:
achraf
2026-06-07 16:57:48 +02:00
parent 796fa7bb4b
commit 25b9f36686
10 changed files with 102 additions and 176 deletions

View File

@@ -6,6 +6,7 @@ VITE_PRIVACY_EMAIL=privacy@example.com
VITE_PRIVACY_EFFECTIVE_DATE=2026-06-07
VITE_ANALYTICS_PROVIDER_NAME=Umami
VITE_ANALYTICS_SCRIPT_URL=https://u.achraf.app/script.js
VITE_ANALYTICS_HOST_URL=https://u.achraf.app
VITE_ANALYTICS_WEBSITE_ID=89d0f8d2-5b59-438c-b1f6-ab2655e97f6e
VITE_ANALYTICS_HOST=u.achraf.app
VITE_ANALYTICS_HOST_COUNTRY=France

View File

@@ -3,7 +3,7 @@
Plimi is a modern, modular utility suite that executes tool processing directly inside your browser. Tool inputs and outputs are not uploaded by Plimi.
## Features
- **Privacy-First Design**: Tool processing is offline-capable. Optional audience analytics load only after consent.
- **Privacy-First Design**: Tool processing is offline-capable. Audience measurement is self-hosted, narrow in scope, and can be disabled locally.
- **Plugin Architecture**: Modular, strictly-typed plugin ecosystem supporting custom UI or generated UI based on schema.
- **Web Workers & WASM**: Supports off-loading expensive operations (e.g. PDF manipulation, media processing) into separate threads.
- **High Performance**: Built with React, Vite, and Tailwind CSS v4.
@@ -57,13 +57,14 @@ Environment:
- `VITE_LEGAL_NAME`, `VITE_LEGAL_COUNTRY`: controller identity shown in the privacy notice.
- `VITE_PRIVACY_EMAIL`: contact address for privacy and data-subject requests.
- `VITE_PRIVACY_EFFECTIVE_DATE`: effective date displayed on the privacy notice.
- `VITE_ANALYTICS_SCRIPT_URL`, `VITE_ANALYTICS_WEBSITE_ID`: Umami tracker configuration.
- `VITE_ANALYTICS_SCRIPT_URL`, `VITE_ANALYTICS_HOST_URL`, `VITE_ANALYTICS_WEBSITE_ID`: Umami tracker configuration.
- `VITE_ANALYTICS_HOST`, `VITE_ANALYTICS_HOST_COUNTRY`: analytics recipient and hosting location disclosed to visitors.
- `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 consent-first. The tracker is injected only after acceptance and is
configured to exclude URL search parameters and hashes and honor Do Not Track.
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.
The privacy page is available at `/privacy`; deployers must replace all example
controller and hosting values before publishing.

View File

@@ -30,6 +30,7 @@ write_entry VITE_PRIVACY_EMAIL "privacy@example.com"
write_entry VITE_PRIVACY_EFFECTIVE_DATE "2026-06-07"
write_entry VITE_ANALYTICS_PROVIDER_NAME "Umami"
write_entry VITE_ANALYTICS_SCRIPT_URL "https://u.achraf.app/script.js"
write_entry VITE_ANALYTICS_HOST_URL "https://u.achraf.app"
write_entry VITE_ANALYTICS_WEBSITE_ID "89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"
write_entry VITE_ANALYTICS_HOST "u.achraf.app"
write_entry VITE_ANALYTICS_HOST_COUNTRY "France"

View File

@@ -2,7 +2,7 @@ import { Link } from "react-router-dom";
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
export function Footer() {
const { choice, openSettings } = useAnalyticsConsent();
const { enabled, toggleAnalytics } = useAnalyticsConsent();
return (
<footer className="border-t border-[var(--p-border)] px-6 py-6 md:px-14">
@@ -18,10 +18,11 @@ export function Footer() {
Privacy & legal
</Link>
<button
onClick={openSettings}
onClick={toggleAnalytics}
aria-pressed={!enabled}
className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
>
Analytics settings ({choice === "granted" ? "on" : "off"})
Analytics {enabled ? "on" : "off"}
</button>
</nav>
</div>

View File

@@ -7,6 +7,7 @@ export interface PlimiRuntimeConfig {
VITE_PRIVACY_EFFECTIVE_DATE?: string;
VITE_ANALYTICS_PROVIDER_NAME?: string;
VITE_ANALYTICS_SCRIPT_URL?: string;
VITE_ANALYTICS_HOST_URL?: string;
VITE_ANALYTICS_WEBSITE_ID?: string;
VITE_ANALYTICS_HOST?: string;
VITE_ANALYTICS_HOST_COUNTRY?: string;

View File

@@ -1,27 +1,23 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Link } from "react-router-dom";
import {
hasAnalyticsConfiguration,
privacyConfig,
} from "./privacy-config";
import {
AnalyticsConsentContext,
type AnalyticsConsentValue,
type ConsentChoice,
type AnalyticsPreferenceValue,
} from "./analytics-consent-context";
const CONSENT_KEY = "plimi.analytics-consent.v1";
const SCRIPT_ID = "plimi-umami-script";
const UMAMI_DISABLED_KEY = "umami.disabled";
function storedChoice(): ConsentChoice {
const value = window.localStorage.getItem(CONSENT_KEY);
return value === "granted" || value === "denied" ? value : "pending";
function analyticsEnabledInitially(): boolean {
return window.localStorage.getItem(UMAMI_DISABLED_KEY) !== "1";
}
function removeAnalyticsScript() {
@@ -36,6 +32,7 @@ function loadAnalyticsScript() {
script.defer = true;
script.src = privacyConfig.analyticsScriptUrl;
script.dataset.websiteId = privacyConfig.analyticsWebsiteId;
script.dataset.hostUrl = privacyConfig.analyticsHostUrl;
script.dataset.domains = new URL(privacyConfig.siteUrl).hostname;
script.dataset.doNotTrack = "true";
script.dataset.excludeSearch = "true";
@@ -43,145 +40,50 @@ 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 [choice, setChoice] = useState<ConsentChoice>(storedChoice);
const [isSettingsOpen, setSettingsOpen] = useState(false);
const [enabled, setEnabled] = useState(analyticsEnabledInitially);
useEffect(() => {
if (choice === "granted") {
if (enabled) {
loadAnalyticsScript();
} else {
removeAnalyticsScript();
}
}, [choice]);
}, [enabled]);
const saveChoice = useCallback(
(nextChoice: Exclude<ConsentChoice, "pending">) => {
const wasGranted = choice === "granted";
window.localStorage.setItem(CONSENT_KEY, nextChoice);
setChoice(nextChoice);
setSettingsOpen(false);
if (wasGranted && nextChoice === "denied") {
window.location.reload();
}
},
[choice]
);
const value = useMemo<AnalyticsConsentValue>(
const value = useMemo<AnalyticsPreferenceValue>(
() => ({
choice,
isSettingsOpen,
accept: () => saveChoice("granted"),
reject: () => saveChoice("denied"),
openSettings: () => setSettingsOpen(true),
closeSettings: () => setSettingsOpen(false),
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");
} else {
window.localStorage.removeItem(UMAMI_DISABLED_KEY);
}
setEnabled(!enabled);
window.location.reload();
},
}),
[choice, isSettingsOpen, saveChoice]
[enabled]
);
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,20 +1,16 @@
import { createContext, useContext } from "react";
export type ConsentChoice = "pending" | "granted" | "denied";
export interface AnalyticsConsentValue {
choice: ConsentChoice;
isSettingsOpen: boolean;
accept: () => void;
reject: () => void;
openSettings: () => void;
closeSettings: () => void;
export interface AnalyticsPreferenceValue {
enabled: boolean;
disableAnalytics: () => void;
enableAnalytics: () => void;
toggleAnalytics: () => void;
}
export const AnalyticsConsentContext =
createContext<AnalyticsConsentValue | null>(null);
createContext<AnalyticsPreferenceValue | null>(null);
export function useAnalyticsConsent(): AnalyticsConsentValue {
export function useAnalyticsConsent(): AnalyticsPreferenceValue {
const context = useContext(AnalyticsConsentContext);
if (!context) {
throw new Error(

View File

@@ -27,6 +27,10 @@ export const privacyConfig = {
"VITE_ANALYTICS_SCRIPT_URL",
"https://u.achraf.app/script.js"
),
analyticsHostUrl: runtimeConfigValue(
"VITE_ANALYTICS_HOST_URL",
"https://u.achraf.app"
),
analyticsWebsiteId: runtimeConfigValue(
"VITE_ANALYTICS_WEBSITE_ID",
"89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"

View File

@@ -94,7 +94,8 @@ export function HowItWorksPage() {
<p className="m-0 max-w-[560px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
Plimi is a collection of small local utilities. The interface loads the tool, runs the
work in the browser, then hands the result back to you without uploading your tool
input. Optional audience analytics are separate and require your consent.
input. Optional audience measurement stays separate from tool processing and can be
disabled from the privacy controls.
</p>
</div>

View File

@@ -21,7 +21,7 @@ function Section({
}
export function PrivacyPage() {
const { choice, openSettings } = useAnalyticsConsent();
const { enabled, enableAnalytics, disableAnalytics } = 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 are local. Analytics are optional.
Your tools stay local. Analytics stay narrow.
</h1>
<p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]">
Effective {privacyConfig.policyEffectiveDate}. This notice explains
@@ -72,55 +72,73 @@ export function PrivacyPage() {
</p>
</Section>
<Section title="Optional analytics">
<Section title="Audience measurement">
<p className="m-0">
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 location derived from network information.
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.
</p>
<p className="m-0">
Purpose: aggregate audience measurement and improvement of Plimi.
Legal basis: your consent under GDPR Article 6(1)(a). Analytics data
is configured for a retention period of{" "}
Plimi does not use analytics for advertising, profiling, sharing with
data brokers, or cross-site tracking. Analytics data is configured
for a retention period of{" "}
{privacyConfig.analyticsRetentionMonths} months and is hosted in{" "}
{privacyConfig.analyticsHostCountry}.
</p>
<p className="m-0">
Plimi does not send tool inputs, uploaded file contents, outputs,
names, email addresses, or account identifiers to analytics. The
tracker is configured to exclude URL search parameters and hashes and
to honor browser Do Not Track.
names, email addresses, account identifiers, custom analytics events,
or `umami.identify()` data. The tracker excludes URL search
parameters and hash fragments and honors browser Do Not Track.
</p>
<div className="flex flex-wrap items-center gap-3 pt-2">
<button
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)]"
>
Change analytics choice
</button>
{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}
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)]">
Current choice: {choice === "granted" ? "accepted" : choice === "denied" ? "rejected" : "not chosen"}
Analytics are currently {enabled ? "enabled" : "disabled"} on this
browser
</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.
</p>
</Section>
<Section title="Local storage and essential preferences">
<p className="m-0">
Plimi stores your theme preference and analytics choice in browser
local storage. These values are necessary to remember the settings
you selected. Plimi does not use advertising cookies.
Plimi stores your theme preference and analytics opt-out 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.
</p>
</Section>
<Section title="Recipients and transfers">
<p className="m-0">
When analytics are accepted, analytics metadata is received by the
When analytics are enabled, analytics metadata is received by the
operator of {privacyConfig.analyticsHost} and any infrastructure
providers used to host it. The controller must maintain appropriate
processor agreements and transfer safeguards where a provider
processes data outside the EEA.
providers used to host it. Plimi does not load other non-essential
trackers, third-party embeds, advertising pixels, or cross-site
analytics services.
</p>
</Section>
@@ -128,8 +146,8 @@ export function PrivacyPage() {
<p className="m-0">
Depending on applicable law, you may request access, correction,
deletion, restriction, or portability of your personal data. You may
withdraw analytics consent at any time through Analytics settings;
withdrawal does not affect prior lawful processing.
opt out of analytics at any time through the controls on this page or
in the footer.
</p>
<p className="m-0">
Contact {privacyConfig.privacyEmail} to exercise a right. You may also