feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)

Implements the hybrid scope agreed on in #663: two native adapters
(Umami + Rybbit) for trackers we'd keep maintained, plus a Custom
script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4,
GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible
native, deeper metrics) explicitly deferred until someone asks.

## Architecture

**Backend `services/trackers/`**:
  - `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` →
    `{ desktop, mobile, tablet } | null`. Null = route falls back to
    access_logs heuristic.
  - `umamiAdapter.js` — extracted from the `services/umamiClient.js`
    that landed in #662. Same 10 test contract preserved.
  - `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension=
    device` with Bearer auth, accepts both bare-array and `{data:[...]}`
    envelope variants, tolerates `sessions`/`visitors`/`value`/`count`
    metric keys.
  - `customScriptSanitiser.js` — sanitize-html with a tracker-tight
    allowlist (`<script>` / `<noscript>` / `<link rel=preconnect|
    dns-prefetch>` / `<meta>`). Strips event-handler attributes,
    `javascript:` and `data:` URLs.
  - `index.js` factory: `resolveAdapter()` reads
    `analytics_tracker_provider` setting → dispatches. Back-compat:
    when provider is unset, infers `umami` from the legacy
    `analytics_umami_enabled` flag so #662 installs keep working
    without an admin touching settings.

**Backend routes**:
  - `adminDashboard.js /analytics`: now goes through `resolveAdapter()`.
    Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js`
    and its test file deleted (replaced by the adapter shape).
  - `adminSettings.js PUT /analytics`: validates the new
    `analytics_tracker_provider` enum, sanitises any incoming
    `analytics_custom_head_html` on save via the sanitiser. Masks
    the new `analytics_rybbit_api_key` on every GET — same pattern as
    Umami's API key and recaptcha secret.
  - `publicSettings.js`: emits `analytics_tracker_provider`,
    `rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and
    the pre-sanitised `analytics_custom_head_html` (only when
    provider=custom). Legacy `umami_*` fields stay for back-compat.

**Frontend**:
  - `analytics.service.ts` reworked into a provider-aware shape.
    `initialize({provider, ...config})` dispatches to Umami /
    Rybbit / Custom / None. `track()` calls dispatch to
    `window.umami.track` / `window.rybbit.event` / no-op based on
    the loaded provider.
  - `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider`
    from public-settings and routes to the right `initialize` call.
    Legacy `umami_enabled`-based path preserved as fallback when the
    new field is missing.
  - `AnalyticsTab.tsx` (Settings → Analytics) reworked with a
    "Provider" dropdown switching between None / Umami / Rybbit /
    Custom panels. Each panel renders its own config fields; Custom
    panel surfaces an explicit CSP-reminder banner.
  - `useSettingsState.ts` shape extended with `tracker_provider`,
    `rybbit_url`/`rybbit_website_id`/`rybbit_api_key`,
    `custom_head_html`. Save mutation keeps `umami_enabled` in sync
    with `tracker_provider==='umami'` for back-compat with downstream
    consumers (publicSettings shape, embedded iframe).
  - `publicSettings.service.ts` type extended.

**i18n**: EN + DE for the provider heading + description + dropdown
options + Rybbit fields + Custom HTML field + CSP warning.

## Custom mode — script execution caveat

When the gallery `<head>` receives the custom HTML, simply assigning
innerHTML to a container element wouldn't execute the embedded
`<script>` tags (per the HTML spec, dynamically-inserted scripts via
innerHTML are non-running). `analytics.service.ts:120-130` re-creates
each `<script>` element manually so the browser actually evaluates
it. Non-script nodes (link, meta, noscript) move in directly.

## Tests

**Backend** (42 cases, all pass locally):
  - `umamiAdapter.test.js` (10) — pinned from the original
    `umamiClient.test.js`: missing-config / URL shape / encoding /
    payload normalisation / `laptop`→`desktop` / unknown buckets /
    empty / non-2xx / invalid JSON / network error.
  - `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit:
    bare-array + envelope payload, `sessions`/`visitors`/`dimension`
    key tolerance, encoding, failure modes.
  - `trackerFactory.test.js` (6) — resolves null for `none`/`custom`,
    correct adapter for `umami`/`rybbit`, back-compat path via
    legacy `analytics_umami_enabled`, garbage-provider defensive null.
  - `customScriptSanitiser.test.js` (12) — Plausible-style passthrough,
    Umami-style passthrough, inline body passthrough, `<noscript>`
    allowed, `<link rel="preconnect|dns-prefetch">` allowed,
    `<link rel="stylesheet">` stripped, disallowed tags stripped,
    `javascript:`/`data:` URLs stripped, `on*` event handlers
    stripped, defensive on malformed input.
  - `analyticsDateMerge.test.js` (5) — preserved from #662.

**Frontend**: full 84-case vitest suite green; tsc + eslint clean
on changed files. Adapter changes are narrow refactors of code
covered by backend tests; no new analytics-page unit test added.

## End-to-end smoke (dockerised backend + my changes mounted)

```
test 1 (back-compat: no provider, umami_enabled=true)
  → factory returns umami adapter, /analytics returns
    devicesSource:access_logs (umami fetch to fake host fails
    gracefully). ✓

test 2 (invalid provider value)
  → 400 "analytics_tracker_provider must be one of: none, umami,
    rybbit, custom" ✓

test 3 (save custom HTML with XSS payload)
  → stored sanitised:
    `<script>alert(1)</script>evil<script async defer
     data-domain="x.com" src="https://plausible.io/js/script.js"></script>`
    (<div> stripped; script tags survive but CSP `script-src 'self'`
    still blocks inline + non-allowlisted external at runtime) ✓

test 4 (public-settings exposes the provider switch)
  → `analytics_tracker_provider: 'custom'`,
    `analytics_custom_head_html: '<sanitised>'` ✓
```

## Out of scope (next discussions)

- **Plausible native** — covered via Custom mode for now; native is
  Phase 2 if someone explicitly asks.
- **CSP "trusted domains" admin input** — Phase 1.5. For now operators
  add their tracker domain to nginx/proxy CSP manually; the new
  CSP-reminder banner in the Custom panel makes that clear.
- **Refactor `(window as any).umami.track(...)` direct calls** in
  PhotoLightbox/PhotoGrid to go through `analyticsService.track()`
  so events fire on the right tracker. Currently a no-op when Umami
  isn't loaded; functional but not optimal.

Closes #663 Phase 1.
This commit is contained in:
Paul Nothaft
2026-06-23 18:11:58 +02:00
parent 349f566e87
commit ab501459a4
20 changed files with 1344 additions and 427 deletions
@@ -45,7 +45,13 @@ export interface SecuritySettings {
recaptcha_secret_key: string;
}
export type TrackerProvider = 'none' | 'umami' | 'rybbit' | 'custom';
export interface AnalyticsSettings {
// Tracker-provider switch (#663 Phase 1). Drives which provider's
// settings panel renders + which tracker script gets injected into the
// public gallery. 'none' = no tracker; 'custom' = paste-your-own HTML.
tracker_provider: TrackerProvider;
umami_enabled: boolean;
umami_url: string;
umami_website_id: string;
@@ -56,6 +62,14 @@ export interface AnalyticsSettings {
// on GET when a value is stored — submit the masked sentinel unchanged
// to keep the stored value, or a real key to replace it.
umami_api_key: string;
// Rybbit native provider (#663 Phase 1). Same shape as Umami.
rybbit_url: string;
rybbit_website_id: string;
rybbit_api_key: string;
// Custom-mode HTML snippet (#663). Sanitised server-side on save via
// sanitize-html with a tracker-script allowlist. Rendered into the
// public gallery <head> as-is on every request.
custom_head_html: string;
}
export interface EventSettings {
@@ -132,11 +146,16 @@ export function useSettingsState() {
// Analytics settings state
const [analyticsSettings, setAnalyticsSettings] = useState<AnalyticsSettings>({
tracker_provider: 'none',
umami_enabled: false,
umami_url: '',
umami_website_id: '',
umami_share_url: '',
umami_api_key: ''
umami_api_key: '',
rybbit_url: '',
rybbit_website_id: '',
rybbit_api_key: '',
custom_head_html: ''
});
// Event creation settings state
@@ -222,12 +241,27 @@ export function useSettingsState() {
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
});
// Tracker provider: prefer explicit setting; fall back to legacy
// umami_enabled flag for installs that haven't picked yet (#663).
const explicitProvider = settings.analytics_tracker_provider;
const provider: TrackerProvider = (
explicitProvider === 'none' || explicitProvider === 'umami'
|| explicitProvider === 'rybbit' || explicitProvider === 'custom'
)
? explicitProvider
: (toBoolean(settings.analytics_umami_enabled, false) ? 'umami' : 'none');
setAnalyticsSettings({
tracker_provider: provider,
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || '',
umami_api_key: settings.analytics_umami_api_key || ''
umami_api_key: settings.analytics_umami_api_key || '',
rybbit_url: settings.analytics_rybbit_url || '',
rybbit_website_id: settings.analytics_rybbit_website_id || '',
rybbit_api_key: settings.analytics_rybbit_api_key || '',
custom_head_html: settings.analytics_custom_head_html || ''
});
setEventSettings({
@@ -323,12 +357,16 @@ export function useSettingsState() {
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(analyticsSettings).forEach(([key, value]) => {
// The Umami API key (#661 Bug C) is returned masked as `••••••••`
// on GET so it doesn't leak in the response body. Don't re-save
// that sentinel — silently preserve whatever's already stored.
if (key === 'umami_api_key' && value === '••••••••') return;
// API keys (Umami / Rybbit) are returned masked as `••••••••` on
// GET so they don't leak in the response body. Don't re-save the
// sentinel — silently preserve whatever's already stored.
if ((key === 'umami_api_key' || key === 'rybbit_api_key') && value === '••••••••') return;
settingsData[`analytics_${key}`] = value;
});
// Keep the legacy `analytics_umami_enabled` flag in sync with the
// new `tracker_provider` switch so back-compat consumers (publicSettings
// surface, embedded Umami iframe) keep working when provider !== 'umami'.
settingsData.analytics_umami_enabled = analyticsSettings.tracker_provider === 'umami';
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
@@ -1,8 +1,8 @@
import React from 'react';
import { Save, Globe, Key, Activity, AlertCircle } from 'lucide-react';
import { Save, Globe, Key, Activity, AlertCircle, Code } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { AnalyticsSettings } from '../hooks/useSettingsState';
import type { AnalyticsSettings, TrackerProvider } from '../hooks/useSettingsState';
interface AnalyticsTabProps {
analyticsSettings: AnalyticsSettings;
@@ -13,6 +13,8 @@ interface AnalyticsTabProps {
};
}
const PROVIDER_OPTIONS: TrackerProvider[] = ['none', 'umami', 'rybbit', 'custom'];
export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
analyticsSettings,
setAnalyticsSettings,
@@ -20,110 +22,238 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
}) => {
const { t } = useTranslation();
const provider = analyticsSettings.tracker_provider;
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={analyticsSettings.umami_enabled}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.analytics.enableUmami')}</span>
</label>
{analyticsSettings.umami_enabled && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
type="text"
value={analyticsSettings.umami_website_id}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_share_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
{/* Umami API key — only used by the dashboard's device
breakdown today (#661 Bug C). Optional; the rest of the
integration works without it. Masked as •••••••• on GET. */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiApiKey', 'API key')}
</label>
<Input
type="password"
value={analyticsSettings.umami_api_key}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_api_key: e.target.value }))}
placeholder="api_xxx…"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
autoComplete="off"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.umamiApiKeyHelp',
'Optional. Required only for the device-breakdown chart on the Analytics dashboard. Generate in Umami → Settings → Profile → API Keys. Stored masked as •••••••• once saved — leave the masked value to keep the existing key.',
)}
</p>
</div>
</>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('settings.analytics.providerHeading', 'Analytics provider')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t(
'settings.analytics.providerDescription',
'Pick which tracker to use for the public gallery, or paste your own script. The admin dashboard\'s device-breakdown chart only enriches when you pick Umami or Rybbit — those expose a metrics API. Other providers (Plausible, Matomo, GA4, …) work via the Custom mode below.',
)}
</p>
{/* Provider dropdown — single source of truth for which panel renders. */}
<div className="mb-4">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.providerLabel', 'Provider')}
</label>
<select
value={provider}
onChange={(e) => setAnalyticsSettings((prev) => ({
...prev,
tracker_provider: e.target.value as TrackerProvider,
}))}
className="w-full sm:w-72 px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
>
{PROVIDER_OPTIONS.map((p) => (
<option key={p} value={p}>
{t(`settings.analytics.provider.${p}`, p)}
</option>
))}
</select>
</div>
{/* Umami panel */}
{provider === 'umami' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_url}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
type="text"
value={analyticsSettings.umami_website_id}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_share_url}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_share_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiApiKey', 'API key')}
</label>
<Input
type="password"
value={analyticsSettings.umami_api_key}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_api_key: e.target.value }))}
placeholder="api_xxx…"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
autoComplete="off"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.umamiApiKeyHelp',
'Optional. Required only for the device-breakdown chart on the Analytics dashboard. Generate in Umami → Settings → Profile → API Keys. Stored masked as •••••••• once saved — leave the masked value to keep the existing key.',
)}
</p>
</div>
</div>
)}
{/* Rybbit panel */}
{provider === 'rybbit' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.rybbitUrl', 'Rybbit URL')}
</label>
<Input
type="url"
value={analyticsSettings.rybbit_url}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_url: e.target.value }))}
placeholder="https://app.rybbit.io"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.rybbitUrlHelp',
'Your Rybbit instance URL — `https://app.rybbit.io` for the SaaS, or `https://rybbit.yourdomain.com` for self-hosted.',
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.rybbitWebsiteId', 'Site ID')}
</label>
<Input
type="text"
value={analyticsSettings.rybbit_website_id}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.rybbitWebsiteIdHelp',
'Found in Rybbit → Sites → your site → Tracking script.',
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.rybbitApiKey', 'API key')}
</label>
<Input
type="password"
value={analyticsSettings.rybbit_api_key}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_api_key: e.target.value }))}
placeholder="rybbit_xxx…"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
autoComplete="off"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.rybbitApiKeyHelp',
'Optional. Required only for the device-breakdown chart. Generate in Rybbit → Account → Settings → API Keys. Stored masked as •••••••• once saved.',
)}
</p>
</div>
</div>
)}
{/* Custom panel */}
{provider === 'custom' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.customHeadHtml', 'Custom <head> HTML')}
</label>
<div className="relative">
<span className="absolute left-3 top-3 pointer-events-none">
<Code className="w-5 h-5 text-neutral-400" />
</span>
<textarea
value={analyticsSettings.custom_head_html}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, custom_head_html: e.target.value }))}
placeholder={'<script async defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>'}
rows={6}
spellCheck={false}
className="w-full pl-10 pr-3 py-2 font-mono text-xs border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
/>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.customHeadHtmlHelp',
'Paste your tracker\'s `<head>` snippet (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics, …). Sanitised on save: only `<script>` / `<noscript>` / `<link rel="preconnect|dns-prefetch">` / `<meta>` tags survive, and event-handler attributes are stripped. The admin dashboard\'s device-breakdown chart falls back to a server-side user-agent heuristic in this mode — pick Umami or Rybbit if you want the tracker-side numbers.',
)}
</p>
</div>
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium mb-1">
{t('settings.analytics.customCspWarning', 'Content-Security-Policy reminder')}
</p>
<p>
{t(
'settings.analytics.customCspWarningText',
'PicPeak ships with a strict CSP (`script-src \'self\'`). If your tracker loads from another domain, add that domain to your reverse-proxy or nginx CSP config — otherwise the browser silently blocks the script.',
)}
</p>
</div>
</div>
</div>
</div>
)}
{provider === 'none' && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
<p>{t('settings.analytics.umamiInfoText')}</p>
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
{t('settings.analytics.learnMore')}
</a>
{t(
'settings.analytics.providerNoneInfo',
'No external tracker injected. The admin dashboard still shows summary cards + the daily chart from PicPeak\'s own access_logs; the device-breakdown chart uses a coarse user-agent heuristic.',
)}
</div>
</div>
</div>
</div>
)}
<div className="mt-6">
<Button