Add consent-first analytics privacy controls

This commit is contained in:
achraf
2026-06-07 16:11:21 +02:00
parent aa44f6c105
commit 0b2d38ee5e
15 changed files with 492 additions and 24 deletions

View File

@@ -1 +1,12 @@
VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi
VITE_SITE_URL=https://plimi.example.com
VITE_LEGAL_NAME=Achraf ACHKARI-BEGDOURI
VITE_LEGAL_COUNTRY=France
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_WEBSITE_ID=89d0f8d2-5b59-438c-b1f6-ab2655e97f6e
VITE_ANALYTICS_HOST=u.achraf.app
VITE_ANALYTICS_HOST_COUNTRY=France
VITE_ANALYTICS_RETENTION_MONTHS=13

3
.gitignore vendored
View File

@@ -11,6 +11,9 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
.env.*
!.env.example
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@@ -9,11 +9,9 @@ RUN pnpm install --frozen-lockfile
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
ARG VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi
ENV VITE_PLIMI_REPO_URL=$VITE_PLIMI_REPO_URL
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN pnpm build RUN --mount=type=secret,id=vite_env,target=/app/.env,required=true pnpm build
FROM nginx:1.27-alpine AS runtime FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,9 +1,9 @@
# Plimi: Privacy-First Browser Toolbox # Plimi: Privacy-First Browser Toolbox
Plimi is a modern, modular, zero-backend utility suite that executes all tools directly inside your browser. No data ever leaves your device. 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 ## Features
- **Privacy-First Design**: Completely offline-capable. No data is sent to external servers. - **Privacy-First Design**: Tool processing is offline-capable. Optional audience analytics load only after consent.
- **Plugin Architecture**: Modular, strictly-typed plugin ecosystem supporting custom UI or generated UI based on schema. - **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. - **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. - **High Performance**: Built with React, Vite, and Tailwind CSS v4.
@@ -36,9 +36,9 @@ npm run build
Build and run the static production app with Nginx: Build and run the static production app with Nginx:
```bash ```bash
docker build \ cp .env.example .env
--build-arg VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi \ # Edit .env with the real deployment and legal values.
-t plimi:local . docker build --secret id=vite_env,src=.env -t plimi:local .
docker run --rm -p 8080:80 --name plimi plimi:local docker run --rm -p 8080:80 --name plimi plimi:local
``` ```
@@ -46,13 +46,33 @@ docker run --rm -p 8080:80 --name plimi plimi:local
Or use Docker Compose: Or use Docker Compose:
```bash ```bash
VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi PLIMI_PORT=8080 docker compose up --build cp .env.example .env
# Edit .env, then:
docker compose up --build
``` ```
Environment: Environment:
- `VITE_PLIMI_REPO_URL`: build-time public repository URL shown on the Contribute page. - `VITE_PLIMI_REPO_URL`: build-time public repository URL shown on the Contribute page.
- `VITE_SITE_URL`: canonical public URL used to restrict analytics to the deployed hostname.
- `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_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`. - `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.
The privacy page is available at `/privacy`; deployers must replace all example
controller and hosting values before publishing.
Docker mounts `.env` as a BuildKit secret only while Vite builds the static
bundle. The file is ignored by Git and excluded from the regular Docker context.
Remember that `VITE_*` values are public build-time configuration and can be
read from the resulting browser JavaScript; do not put passwords or API secrets
in them.
--- ---
## How to Create a Tool (Plugin) ## How to Create a Tool (Plugin)

View File

@@ -2,10 +2,14 @@ services:
plimi: plimi:
build: build:
context: . context: .
args: secrets:
VITE_PLIMI_REPO_URL: ${VITE_PLIMI_REPO_URL:-https://github.com/your-org/plimi} - vite_env
image: plimi:local image: plimi:local
container_name: plimi container_name: plimi
ports: ports:
- "${PLIMI_PORT:-8080}:80" - "${PLIMI_PORT:-8080}:80"
restart: unless-stopped restart: unless-stopped
secrets:
vite_env:
file: ./.env

View File

@@ -4,10 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script defer src="https://u.achraf.app/script.js" data-website-id="89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"></script>
<title>Plimi</title> <title>Plimi</title>
</head> </head>
<body> <body>

View File

@@ -5,6 +5,7 @@ import { ToolsPage } from "../pages/ToolsPage";
import { ToolDetailPage } from "../pages/ToolDetailPage"; import { ToolDetailPage } from "../pages/ToolDetailPage";
import { HowItWorksPage } from "../pages/HowItWorksPage"; import { HowItWorksPage } from "../pages/HowItWorksPage";
import { ContributePage } from "../pages/ContributePage"; import { ContributePage } from "../pages/ContributePage";
import { PrivacyPage } from "../pages/PrivacyPage";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@@ -31,6 +32,10 @@ export const router = createBrowserRouter([
path: "contribute", path: "contribute",
element: <ContributePage />, element: <ContributePage />,
}, },
{
path: "privacy",
element: <PrivacyPage />,
},
], ],
}, },
]); ]);

View File

@@ -1,13 +1,18 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Header } from "./Header"; import { Header } from "./Header";
import { Footer } from "./Footer";
import { AnalyticsConsentProvider } from "../../core/privacy/AnalyticsConsent";
export function AppShell() { export function AppShell() {
return ( return (
<div className="relative w-full min-h-screen bg-[var(--p-bg)] text-[var(--p-text)] font-sans bg-[image:var(--p-paper-noise)] bg-[size:180px_180px]"> <AnalyticsConsentProvider>
<Header /> <div className="relative flex min-h-screen w-full flex-col bg-[var(--p-bg)] text-[var(--p-text)] font-sans bg-[image:var(--p-paper-noise)] bg-[size:180px_180px]">
<main className="px-6 py-8 md:px-14 md:py-9"> <Header />
<Outlet /> <main className="flex-1 px-6 py-8 md:px-14 md:py-9">
</main> <Outlet />
</div> </main>
<Footer />
</div>
</AnalyticsConsentProvider>
); );
} }

View File

@@ -0,0 +1,30 @@
import { Link } from "react-router-dom";
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
export function Footer() {
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>
<nav className="flex flex-wrap items-center gap-x-5 gap-y-2">
<Link
to="/privacy"
className="text-[var(--p-muted)] no-underline hover:text-[var(--p-text)]"
>
Privacy & legal
</Link>
<button
onClick={openSettings}
className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
>
Analytics settings ({choice === "granted" ? "on" : "off"})
</button>
</nav>
</div>
</footer>
);
}

View File

@@ -0,0 +1,187 @@
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,
} from "./analytics-consent-context";
const CONSENT_KEY = "plimi.analytics-consent.v1";
const SCRIPT_ID = "plimi-umami-script";
function storedChoice(): ConsentChoice {
const value = window.localStorage.getItem(CONSENT_KEY);
return value === "granted" || value === "denied" ? value : "pending";
}
function removeAnalyticsScript() {
document.getElementById(SCRIPT_ID)?.remove();
}
function loadAnalyticsScript() {
if (!hasAnalyticsConfiguration || document.getElementById(SCRIPT_ID)) return;
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.defer = true;
script.src = privacyConfig.analyticsScriptUrl;
script.dataset.websiteId = privacyConfig.analyticsWebsiteId;
script.dataset.domains = new URL(privacyConfig.siteUrl).hostname;
script.dataset.doNotTrack = "true";
script.dataset.excludeSearch = "true";
script.dataset.excludeHash = "true";
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);
useEffect(() => {
if (choice === "granted") {
loadAnalyticsScript();
} else {
removeAnalyticsScript();
}
}, [choice]);
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>(
() => ({
choice,
isSettingsOpen,
accept: () => saveChoice("granted"),
reject: () => saveChoice("denied"),
openSettings: () => setSettingsOpen(true),
closeSettings: () => setSettingsOpen(false),
}),
[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

@@ -0,0 +1,25 @@
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 const AnalyticsConsentContext =
createContext<AnalyticsConsentValue | null>(null);
export function useAnalyticsConsent(): AnalyticsConsentValue {
const context = useContext(AnalyticsConsentContext);
if (!context) {
throw new Error(
"useAnalyticsConsent must be used inside AnalyticsConsentProvider."
);
}
return context;
}

View File

@@ -0,0 +1,32 @@
function env(name: keyof ImportMetaEnv, fallback: string): string {
const value = import.meta.env[name];
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
export const privacyConfig = {
siteName: "Plimi",
siteUrl: env("VITE_SITE_URL", window.location.origin),
controllerName: env("VITE_LEGAL_NAME", "Plimi website operator"),
privacyEmail: env("VITE_PRIVACY_EMAIL", "privacy@example.com"),
controllerCountry: env("VITE_LEGAL_COUNTRY", "Not configured"),
policyEffectiveDate: env("VITE_PRIVACY_EFFECTIVE_DATE", "2026-06-07"),
analyticsProvider: env("VITE_ANALYTICS_PROVIDER_NAME", "Umami"),
analyticsScriptUrl: env(
"VITE_ANALYTICS_SCRIPT_URL",
"https://u.achraf.app/script.js"
),
analyticsWebsiteId: env(
"VITE_ANALYTICS_WEBSITE_ID",
"89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"
),
analyticsHost: env("VITE_ANALYTICS_HOST", "u.achraf.app"),
analyticsHostCountry: env(
"VITE_ANALYTICS_HOST_COUNTRY",
"Not configured"
),
analyticsRetentionMonths: env("VITE_ANALYTICS_RETENTION_MONTHS", "13"),
};
export const hasAnalyticsConfiguration = Boolean(
privacyConfig.analyticsScriptUrl && privacyConfig.analyticsWebsiteId
);

View File

@@ -93,7 +93,8 @@ export function HowItWorksPage() {
</h1> </h1>
<p className="m-0 max-w-[560px] text-base leading-relaxed text-[var(--p-muted)] text-pretty"> <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 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 a server round trip. 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.
</p> </p>
</div> </div>

151
src/pages/PrivacyPage.tsx Normal file
View File

@@ -0,0 +1,151 @@
import { useAnalyticsConsent } from "../core/privacy/analytics-consent-context";
import { privacyConfig } from "../core/privacy/privacy-config";
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-[18px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5 md:p-7">
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight text-[var(--p-text)]">
{title}
</h2>
<div className="mt-4 space-y-3 text-sm leading-relaxed text-[var(--p-muted)]">
{children}
</div>
</section>
);
}
export function PrivacyPage() {
const { choice, openSettings } = useAnalyticsConsent();
return (
<div className="mx-auto flex max-w-[920px] flex-col gap-6 pb-16 animate-plimi-slide">
<header className="flex flex-col gap-4">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
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.
</h1>
<p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]">
Effective {privacyConfig.policyEffectiveDate}. This notice explains
who operates Plimi, what data the website processes, and the choices
available to you.
</p>
</header>
<Section title="Controller and contact">
<p className="m-0">
The data controller is{" "}
<strong className="text-[var(--p-text)]">
{privacyConfig.controllerName}
</strong>
, based in {privacyConfig.controllerCountry}.
</p>
<p className="m-0">
Privacy requests:{" "}
<a
href={`mailto:${privacyConfig.privacyEmail}`}
className="text-[var(--p-text)] underline underline-offset-4"
>
{privacyConfig.privacyEmail}
</a>
. Website: {privacyConfig.siteUrl}.
</p>
</Section>
<Section title="Local tool processing">
<p className="m-0">
Text, files, images, PDFs, generated passwords, and tool outputs are
processed by code running in your browser. Plimi does not upload this
tool content to an application server or include it in analytics.
</p>
<p className="m-0">
Files you download remain under your control. Browser features such
as the clipboard or local file picker are used only when you invoke
them.
</p>
</Section>
<Section title="Optional analytics">
<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.
</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{" "}
{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.
</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>
<span className="font-mono text-[11px] uppercase text-[var(--p-muted)]">
Current choice: {choice === "granted" ? "accepted" : choice === "denied" ? "rejected" : "not chosen"}
</span>
</div>
</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.
</p>
</Section>
<Section title="Recipients and transfers">
<p className="m-0">
When analytics are accepted, 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.
</p>
</Section>
<Section title="Your rights">
<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.
</p>
<p className="m-0">
Contact {privacyConfig.privacyEmail} to exercise a right. You may also
lodge a complaint with the data-protection authority in your country
of residence, work, or the place of an alleged infringement.
</p>
</Section>
<Section title="Security and changes">
<p className="m-0">
Plimi uses HTTPS and access controls appropriate to the services it
operates. No internet service can guarantee absolute security. This
notice may be updated when processing or providers change; the
effective date above identifies the current version.
</p>
</Section>
</div>
);
}

View File

@@ -79,7 +79,7 @@ export function ToolsPage() {
<span className="text-[var(--p-muted)]">Big trust.</span> <span className="text-[var(--p-muted)]">Big trust.</span>
</h1> </h1>
<p className="m-0 max-w-[480px] text-base leading-relaxed text-[var(--p-muted)] text-pretty"> <p className="m-0 max-w-[480px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
{pluginRegistry.length} utilities for files, text and code running entirely in your browser. No upload. No account. No server. {pluginRegistry.length} utilities for files, text and code running entirely in your browser. No tool-data upload. No account.
</p> </p>
</div> </div>