Add consent-first analytics privacy controls
This commit is contained in:
11
.env.example
11
.env.example
@@ -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
3
.gitignore
vendored
@@ -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/*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<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]">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="px-6 py-8 md:px-14 md:py-9">
|
<main className="flex-1 px-6 py-8 md:px-14 md:py-9">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
</AnalyticsConsentProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/components/layout/Footer.tsx
Normal file
30
src/components/layout/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/core/privacy/AnalyticsConsent.tsx
Normal file
187
src/core/privacy/AnalyticsConsent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/core/privacy/analytics-consent-context.ts
Normal file
25
src/core/privacy/analytics-consent-context.ts
Normal 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;
|
||||||
|
}
|
||||||
32
src/core/privacy/privacy-config.ts
Normal file
32
src/core/privacy/privacy-config.ts
Normal 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
|
||||||
|
);
|
||||||
@@ -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
151
src/pages/PrivacyPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user