feat: add opt-in product usage and feedback integration (#1110)

This commit is contained in:
Paul Nothaft
2026-09-05 12:59:06 +02:00
parent c71ffae912
commit b53e5d97b4
24 changed files with 2360 additions and 18 deletions
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { lazy, Suspense, useState } from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { useAdminAuth } from '../../contexts';
@@ -11,6 +11,7 @@ import { MigrationBanner } from './MigrationBanner';
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
const ProductUsageNotice = lazy(() => import('./ProductUsageNotice'));
export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
@@ -125,6 +126,7 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
(or remove this mount) after operators have had time to update their
docker-compose.yml. See #669. */}
<MigrationBanner />
{!mustChangePassword && <Suspense fallback={null}><ProductUsageNotice /></Suspense>}
{/* Page content - disabled when password change required.
overflow moved up to the column so the scrollbar gutter is
@@ -138,4 +140,4 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
);
};
AdminLayout.displayName = 'AdminLayout';
AdminLayout.displayName = 'AdminLayout';
@@ -0,0 +1,69 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePermissions } from '../../contexts/PermissionsContext';
import { productUsageService } from '../../services/productUsage.service';
// Loaded only inside the authenticated admin tree. Gallery routes never import
// this chunk, make usage requests, or record product usage markers.
export default function ProductUsageNotice() {
const { t } = useTranslation();
const { hasPermission } = usePermissions();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['productUsage'],
queryFn: productUsageService.status,
enabled: hasPermission('settings.edit')
});
useEffect(() => {
let running = false;
const tick = async () => {
if (document.visibilityState === 'hidden' || running) return;
running = true;
try {
await productUsageService.activity();
} catch {
/* Best-effort delivery; settings show persisted retry state. */
} finally {
running = false;
}
};
void tick();
const timer = window.setInterval(tick, 5 * 60 * 1000);
document.addEventListener('visibilitychange', tick);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', tick);
};
}, []);
if (!hasPermission('settings.edit') || !data || data.status !== 'disabled' || data.notice_dismissed) return null;
return (
<aside
className="mx-6 mt-4 rounded-lg border border-theme p-4 text-theme bg-theme-surface"
aria-label={t('productUsage.title')}
>
<p>{t('productUsage.notice')}</p>
<div className="mt-2 flex flex-wrap gap-4">
<Link className="underline" to="/admin/settings?tab=usage">
{t('productUsage.review')}
</Link>
<button
className="underline"
onClick={async () => {
try {
queryClient.setQueryData(
['productUsage'],
await productUsageService.dismiss()
);
} catch {
/* The notice remains available. */
}
}}
>
{t('productUsage.later')}
</button>
</div>
</aside>
);
}
@@ -0,0 +1,153 @@
import {
render,
screen,
fireEvent,
waitFor,
cleanup
} from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import ProductUsageTab from '../tabs/ProductUsageTab';
import {
productUsageService as service,
type UsageStatus
} from '../../../services/productUsage.service';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}));
vi.mock('../../../components/common/ConfirmDialog', () => ({
useConfirm: () => async () => true
}));
vi.mock('../../../services/productUsage.service', () => ({
productUsageService: {
status: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
retry: vi.fn(),
preview: vi.fn(),
export: vi.fn(),
preferences: vi.fn(),
feedback: vi.fn(),
portalSession: vi.fn()
}
}));
const status: UsageStatus = {
status: 'disabled',
notice_dismissed: false,
installation_id: null,
collector_url: 'https://usage.picpeak.app',
schema_version: 'usage.v1',
last_report_date: null,
last_error: null,
pending_action: null,
last_packet: null,
feedback_preferences: { name: 'Remembered private name' }
};
const mount = () =>
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<ProductUsageTab />
</QueryClientProvider>
);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(service.status).mockResolvedValue({ ...status });
HTMLDialogElement.prototype.showModal = function () {
this.setAttribute('open', '');
};
});
afterEach(cleanup);
describe('product usage controls', () => {
it('requires the disclosure and an unchecked-by-default consent before enabling', async () => {
mount();
fireEvent.click(await screen.findByText('productUsage.review'));
const enable = screen.getByRole('button', { name: 'productUsage.enable' });
expect(enable).toBeDisabled();
for (const key of [
'fields',
'excluded',
'transport',
'visibility',
'deletion',
'feedbackDisclosure'
])
expect(screen.getByText(`productUsage.${key}`)).toBeInTheDocument();
expect(service.enable).not.toHaveBeenCalled();
expect(service.preview).not.toHaveBeenCalled();
fireEvent.click(screen.getByLabelText('productUsage.consentCheck'));
fireEvent.click(enable);
await waitFor(() => expect(service.enable).toHaveBeenCalledTimes(1));
});
it('sends anonymous private feedback even when a remembered name exists', async () => {
vi.mocked(service.status).mockResolvedValue({
...status,
status: 'active',
installation_id: 'a'.repeat(64)
});
vi.mocked(service.feedback).mockResolvedValue({
delivered: true,
state: { ...status, status: 'active' }
});
mount();
await screen.findByText('productUsage.feedbackTitle');
expect(screen.getByLabelText('productUsage.includeName')).not.toBeChecked();
fireEvent.change(screen.getByLabelText('productUsage.subject'), {
target: { value: 'Feedback title' }
});
fireEvent.change(screen.getByLabelText('productUsage.message'), {
target: { value: 'Useful details' }
});
fireEvent.click(
screen.getByRole('button', { name: 'productUsage.sendFeedback' })
);
await waitFor(() =>
expect(service.feedback).toHaveBeenCalledWith({
kind: 'feedback',
title: 'Feedback title',
body: 'Useful details',
name: '',
allow_public: false,
allow_marketing: false
})
);
});
it('requires a separate marketing choice and clears it when publication permission is removed', async () => {
vi.mocked(service.status).mockResolvedValue({
...status,
status: 'active'
});
mount();
fireEvent.change(await screen.findByLabelText('productUsage.kind'), {
target: { value: 'testimonial' }
});
expect(screen.getByLabelText('productUsage.allowPublic')).not.toBeChecked();
expect(screen.getByLabelText('productUsage.allowMarketing')).toBeDisabled();
fireEvent.click(screen.getByLabelText('productUsage.allowPublic'));
fireEvent.click(screen.getByLabelText('productUsage.allowMarketing'));
expect(screen.getByLabelText('productUsage.allowMarketing')).toBeChecked();
fireEvent.click(screen.getByLabelText('productUsage.allowPublic'));
expect(
screen.getByLabelText('productUsage.allowMarketing')
).not.toBeChecked();
});
it('keeps deletion pending explicit and offers retry without rejoining or sending feedback', async () => {
vi.mocked(service.status).mockResolvedValue({
...status,
status: 'deletion_pending',
last_error: 'DELIVERY_FAILED'
});
mount();
await screen.findByText('productUsage.states.deletion_pending');
expect(screen.queryByText('productUsage.review')).not.toBeInTheDocument();
expect(
screen.queryByText('productUsage.feedbackTitle')
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'productUsage.retry' }));
await waitFor(() => expect(service.retry).toHaveBeenCalled());
});
});
@@ -0,0 +1,428 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
productUsageService as service,
type ProductFeedback
} from '../../../services/productUsage.service';
import { useConfirm } from '../../../components/common/ConfirmDialog';
import { Button } from '../../../components/common/Button';
function ConsentDialog({
close,
enable,
busy,
collector
}: {
close: () => void;
enable: () => void;
busy: boolean;
collector: string;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDialogElement>(null);
const [checked, setChecked] = useState(false);
useEffect(() => {
ref.current?.showModal();
}, []);
return (
<dialog
ref={ref}
onCancel={close}
aria-labelledby="usage-consent-title"
className="w-full max-w-2xl rounded-xl p-6 text-theme bg-theme-surface backdrop:bg-black/50"
>
<h2 id="usage-consent-title" className="text-xl font-semibold">
{t('productUsage.consentTitle')}
</h2>
<div className="my-4 max-h-[55vh] overflow-y-auto space-y-3">
{[
'purpose',
'fields',
'excluded',
'transport',
'visibility',
'deletion',
'feedbackDisclosure'
].map((key) => (
<p key={key}>{t(`productUsage.${key}`, { collector })}</p>
))}
</div>
<label className="flex items-start gap-2 mb-4">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
{t('productUsage.consentCheck')}
</label>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={close} disabled={busy}>
{t('productUsage.cancel')}
</Button>
<Button onClick={enable} disabled={!checked || busy}>
{t('productUsage.enable')}
</Button>
</div>
</dialog>
);
}
export default function ProductUsageTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const confirm = useConfirm();
const { data, isPending, isError } = useQuery({
queryKey: ['productUsage'],
queryFn: service.status,
refetchInterval: 30000
});
const [consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [preview, setPreview] = useState<unknown>(null);
const [portalUrl, setPortalUrl] = useState<string | null>(null);
const [named, setNamed] = useState(false);
const [form, setForm] = useState<ProductFeedback>({
kind: 'feedback',
title: '',
body: '',
name: '',
allow_public: false,
allow_marketing: false
});
const run = async (fn: () => Promise<void>) => {
setBusy(true);
setMessage('');
try {
await fn();
} catch {
setMessage(t('productUsage.failed'));
} finally {
setBusy(false);
await queryClient.invalidateQueries({ queryKey: ['productUsage'] });
}
};
const download = (value: unknown) => {
const url = URL.createObjectURL(
new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' })
);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'picpeak-usage-packets.json';
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};
if (isPending) return <p>{t('productUsage.loading')}</p>;
if (isError || !data) return <p role="alert">{t('productUsage.failed')}</p>;
const active = data.status === 'active';
return (
<div className="space-y-6 text-theme">
<p>{t('productUsage.purpose')}</p>
<section className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4">
<h3 className="text-lg font-semibold">
{t(`productUsage.states.${data.status}`)}
</h3>
<p>{t(`productUsage.stateDetails.${data.status}`)}</p>
{data.installation_id && (
<label className="block">
{t('productUsage.hash')}
<input
className="mt-1 w-full rounded border border-theme bg-theme-surface p-2 font-mono text-sm"
readOnly
value={data.installation_id}
/>
</label>
)}
{data.last_report_date && (
<p>{t('productUsage.lastReport', { date: data.last_report_date })}</p>
)}
{data.last_error && (
<p role="status">{t('productUsage.deliveryProblem')}</p>
)}
<div className="flex flex-wrap gap-3">
{data.status === 'disabled' ? (
<Button disabled={busy} onClick={() => setConsent(true)}>
{t('productUsage.review')}
</Button>
) : (
<>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(async () => {
await service.retry();
})
}
>
{t('productUsage.retry')}
</Button>
<Button
variant="outline"
disabled={busy || data.status === 'deletion_pending'}
onClick={async () => {
if (
await confirm({
title: t('productUsage.disable'),
message: t('productUsage.deletion'),
confirmLabel: t('productUsage.disable'),
variant: 'danger'
})
) {
await run(async () => {
await service.disable();
setPreview(null);
setPortalUrl(null);
});
}
}}
>
{t('productUsage.disable')}
</Button>
</>
)}
<a
className="underline self-center"
href={`${data.collector_url}/transparency`}
target="_blank"
rel="noreferrer"
>
{t('productUsage.transparency')}
</a>
</div>
</section>
{active && (
<>
<section className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4">
<h3 className="text-lg font-semibold">
{t('productUsage.inspect')}
</h3>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(async () => setPreview(await service.preview()))
}
>
{t('productUsage.preview')}
</Button>
<Button
variant="outline"
disabled={busy || !data.last_packet}
onClick={() => setPreview(data.last_packet)}
>
{t('productUsage.lastPacket')}
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(async () => download(await service.export()))
}
>
{t('productUsage.export')}
</Button>
<Button
variant="outline"
disabled={busy || Boolean(data.pending_action)}
onClick={() =>
run(async () => {
const result = await service.portalSession();
setPortalUrl(result.url);
if (!result.delivered) setMessage(t('productUsage.queued'));
})
}
>
{t('productUsage.connect')}
</Button>
</div>
{portalUrl && (
<a
href={portalUrl}
target="_blank"
rel="noreferrer"
className="underline"
>
{t('productUsage.openPortal')}
</a>
)}
{preview !== null && (
<pre
className="max-h-96 overflow-auto rounded border border-theme p-3 text-xs"
aria-label={t('productUsage.preview')}
>
{JSON.stringify(preview, null, 2)}
</pre>
)}
</section>
<form
className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
const result = await service.feedback({
...form,
name: named ? form.name : ''
});
setMessage(
t(
result.delivered
? 'productUsage.feedbackSent'
: result.queued
? 'productUsage.queued'
: 'productUsage.failed'
)
);
setForm({
...form,
title: '',
body: '',
allow_public: false,
allow_marketing: false
});
});
}}
>
<h3 className="text-lg font-semibold">
{t('productUsage.feedbackTitle')}
</h3>
<p>{t('productUsage.feedbackDisclosure')}</p>
<label className="block">
{t('productUsage.kind')}
<select
aria-label={t('productUsage.kind')}
className="block mt-1 rounded border border-theme bg-theme-surface p-2"
value={form.kind}
onChange={(e) =>
setForm({
...form,
kind: e.target.value as ProductFeedback['kind'],
allow_public: false,
allow_marketing: false
})
}
>
{['feedback', 'feature_request', 'testimonial'].map((kind) => (
<option key={kind} value={kind}>
{t(`productUsage.kinds.${kind}`)}
</option>
))}
</select>
</label>
<label className="block">
{t('productUsage.subject')}
<input
required
maxLength={120}
className="block mt-1 w-full rounded border border-theme bg-theme-surface p-2"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
/>
</label>
<label className="block">
{t('productUsage.message')}
<textarea
required
maxLength={4000}
rows={5}
className="block mt-1 w-full rounded border border-theme bg-theme-surface p-2"
value={form.body}
onChange={(e) => setForm({ ...form, body: e.target.value })}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={named}
onChange={(e) => {
setNamed(e.target.checked);
if (e.target.checked && !form.name)
setForm({ ...form, name: data.feedback_preferences.name });
}}
/>
{t('productUsage.includeName')}
</label>
{named && (
<div className="space-y-2">
<label className="block">
{t('productUsage.name')}
<input
required
maxLength={80}
className="block mt-1 rounded border border-theme bg-theme-surface p-2"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</label>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() =>
run(async () => {
await service.preferences(form.name);
setMessage(t('productUsage.saved'));
})
}
>
{t('productUsage.saveName')}
</Button>
</div>
)}
{form.kind !== 'feedback' && (
<label className="flex gap-2">
<input
type="checkbox"
checked={form.allow_public}
onChange={(e) =>
setForm({
...form,
allow_public: e.target.checked,
allow_marketing: false
})
}
/>
{t('productUsage.allowPublic')}
</label>
)}
{form.kind === 'testimonial' && (
<label className="flex gap-2">
<input
type="checkbox"
checked={form.allow_marketing}
disabled={!form.allow_public}
onChange={(e) =>
setForm({ ...form, allow_marketing: e.target.checked })
}
/>
{t('productUsage.allowMarketing')}
</label>
)}
<Button
type="submit"
disabled={busy || Boolean(data.pending_action)}
>
{t('productUsage.sendFeedback')}
</Button>
</form>
</>
)}
{message && <p role="status">{message}</p>}
{consent && (
<ConsentDialog
collector={data.collector_url}
busy={busy}
close={() => setConsent(false)}
enable={() =>
run(async () => {
await service.enable();
setConsent(false);
})
}
/>
)}
</div>
);
}
+63
View File
@@ -1,4 +1,67 @@
{
"productUsage": {
"title": "Produktnutzung & Feedback",
"notice": "Hilf mit, PicPeak weiterzuentwickeln. Freiwillige Nutzungsberichte zeigen, welche Funktionen der Community wichtig sind. Berichte bleiben aus, bis du ausdrücklich teilnimmst.",
"review": "Teilnahme prüfen",
"later": "Nicht jetzt",
"cancel": "Abbrechen",
"loading": "Teilnahmeeinstellungen werden geladen…",
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
"purpose": "Hilf bei der Priorisierung von PicPeak-Funktionen, Fehlerbehebungen und Wartung mit groben Informationen über teilnehmende Installationen.",
"consentTitle": "Produktnutzung freiwillig teilen",
"fields": "Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, Berichtstag, Schema- und Signaturmetadaten, Galerie-Layouts sowie Konfiguriert/Genutzt-Werte für CRM und Unterfunktionen, Buchhaltung, Workflows, Newsletter, Gesichtserkennung, eigenes CSS, OAuth, SMTP, WhatsApp, Backups, S3 und eingebundene Freigaben. „Genutzt“ bedeutet seit der Teilnahme beobachtet, nicht wie häufig.",
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
"transport": "Dein PicPeak-Backend verwahrt den Signaturschlüssel und sendet signierte Berichte an {{collector}}, einmal pro UTC-Tag bei Admin-Nutzung. Du kannst Berichte vorab ansehen und alle angenommenen Rohpakete herunterladen.",
"visibility": "Der öffentliche Datensatz zeigt Funktionskombinationen und aggregierte Ergebnisse aller berichtenden Installationen, auch Gruppen mit nur einer Installation. Der Fingerabdruck ist pseudonym, nicht anonym. Bewahre deinen Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf deine Rohpakete.",
"deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung deiner Berichte, Aggregatbeiträge, Rückmeldungen, veröffentlichten Wünsche/Empfehlungen, Stimmen und Sitzungen an. Ist der Dienst nicht erreichbar, bleiben nur die für die Löschung nötigen Zugangsdaten erhalten; die Oberfläche zeigt die ausstehende Löschung. Nach Bestätigung werden Hash und Schlüssel lokal gelöscht. Eine erneute Teilnahme erzeugt eine neue Identität. Der Dienst behält nur einen Einweg-Sperrwert, um wiederholte alte Registrierungen abzuweisen.",
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern du keinen Namen angibst, und nur für Betreuer sichtbar, sofern du die Veröffentlichung nicht ausdrücklich erlaubst. Öffentliche Beiträge werden geprüft. Die Verwendung einer Empfehlung für Marketing benötigt eine zusätzliche Erlaubnis.",
"consentCheck": "Ich habe diese Hinweise gelesen und stimme der Teilnahme ausdrücklich zu.",
"enable": "Produktnutzung aktivieren",
"disable": "Deaktivieren & Daten löschen",
"retry": "Erneut versuchen / fälligen Bericht senden",
"transparency": "Öffentliches Schema & Datenschutzhinweise",
"hash": "Dein vertraulicher Abfrage-Hash",
"lastReport": "Zuletzt angenommener Bericht: {{date}} (UTC)",
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuche es erneut oder deaktiviere die Teilnahme, um die Daten zu löschen.",
"inspect": "Genau sehen, was geteilt wird",
"preview": "Nächsten Bericht ansehen",
"lastPacket": "Zuletzt angenommenes signiertes Paket",
"export": "Alle Rohpakete herunterladen",
"connect": "Mit Wünschen & Abstimmungen verbinden",
"openPortal": "Portal öffnen (Abstimmungssitzung für 15 Minuten)",
"queued": "Der Vorgang ist zur erneuten Übertragung gespeichert. Der Empfang ist noch nicht bestätigt.",
"feedbackTitle": "Feedback & Funktionswünsche",
"kind": "Art",
"subject": "Titel",
"message": "Deine Nachricht",
"includeName": "Diesem Beitrag einen Namen hinzufügen",
"name": "Anzeigename",
"saveName": "Diesen Namen lokal merken",
"saved": "Einstellung gespeichert. Neue Beiträge sind weiterhin standardmäßig anonym.",
"allowPublic": "Ich erlaube die Veröffentlichung dieses Textes und des angegebenen Namens im Nutzungsportal nach Prüfung.",
"allowMarketing": "Ich erlaube zusätzlich die Verwendung dieser Empfehlung und des angegebenen Namens für Marketing auf der PicPeak-Homepage.",
"sendFeedback": "Feedback absenden",
"feedbackSent": "Feedback erhalten. Eine Veröffentlichung erfordert deine Erlaubnis und die Prüfung durch Betreuer.",
"states": {
"disabled": "Teilnahme ist deaktiviert",
"activation_pending": "Aktivierung ausstehend",
"active": "Du nimmst teil",
"deletion_pending": "Löschung ausstehend",
"identity_conflict": "Konflikt der Installationsidentität"
},
"stateDetails": {
"disabled": "Es werden keine Produktnutzungsdaten erfasst oder gesendet. Prüfe die Hinweise, bevor du dich entscheidest.",
"activation_pending": "Die Zustimmung ist gespeichert. Die Registrierung wird bei Admin-Nutzung oder über „Erneut versuchen“ wiederholt.",
"active": "Nur die beschriebenen Funktionssignale werden erfasst. Tagesberichte werden bei Admin-Nutzung gesendet.",
"deletion_pending": "Erfassung und Berichte sind gestoppt. Die Signaturdaten bleiben ausschließlich für die bestätigte Löschung erhalten. Versuche es erneut, sobald der Dienst erreichbar ist.",
"identity_conflict": "Möglicherweise wurde diese Installation wiederhergestellt oder kopiert, oder die Berichtsfolge stimmt nicht mehr mit dem Dienst überein. Berichte sind gestoppt. Deaktiviere und lösche die alte Teilnahme vor einem erneuten Beitritt mit neuer Identität. Dadurch werden auch die Daten einer weiteren Kopie derselben Identität gelöscht."
},
"kinds": {
"feedback": "Privates Feedback",
"feature_request": "Funktionswunsch",
"testimonial": "Empfehlung"
}
},
"userManagement": {
"title": "Benutzerverwaltung",
"subtitle": "Admin-Benutzer und Einladungen verwalten",
+63
View File
@@ -1,4 +1,67 @@
{
"productUsage": {
"title": "Product usage & feedback",
"notice": "Help shape PicPeak. Optional product usage reports show which features matter to the community. Reporting is off until you choose to participate.",
"review": "Review participation",
"later": "Not now",
"cancel": "Cancel",
"loading": "Loading participation settings…",
"failed": "The operation could not be completed. Check the status and try again.",
"purpose": "Help prioritize PicPeak features, fixes, and maintenance using coarse information about participating installations.",
"consentTitle": "Choose whether to share product usage",
"fields": "Reports contain an installation fingerprint, PicPeak version, report day, schema and signing metadata, gallery layout choices, and configured/used booleans for CRM and its subfeatures, accounting, workflows, newsletters, face recognition, custom CSS, OAuth, SMTP, WhatsApp, backups, S3, and share mounts. “Used” means observed since joining, not how often.",
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
"transport": "Your PicPeak backend keeps the signing key and sends signed reports to {{collector}} once per UTC day when an admin uses the app. You can preview reports and download every accepted raw packet.",
"visibility": "The public dataset shows feature combinations and aggregate results from all reporting installations, including groups containing just one installation. The fingerprint is pseudonymous, not anonymous. Keep your lookup hash private: it grants read-only access to your raw packets.",
"deletion": "Disabling immediately stops collection and requests deletion of your remote reports, aggregate contributions, feedback, published requests/testimonials, votes, and sessions. If the collector is unavailable, only the credentials needed to finish deletion remain and the UI shows deletion pending. After confirmation, the local hash and key are erased. Joining again creates a new identity. The collector retains only a one-way revocation digest to prevent old registrations being replayed.",
"feedbackDisclosure": "Feedback is separate from automatic reports and is sent only when you submit it. Each item is anonymous unless you include a name, and private to maintainers unless you explicitly permit publication. Public items require maintainer review. Marketing use of a testimonial requires separate permission.",
"consentCheck": "I have read this disclosure and explicitly agree to participate.",
"enable": "Enable product usage",
"disable": "Disable & delete my data",
"retry": "Retry / send if due",
"transparency": "Read the public schema & privacy details",
"hash": "Your private lookup hash",
"lastReport": "Last accepted report: {{date}} (UTC)",
"deliveryProblem": "Delivery needs attention. Collection stops during deletion or an identity conflict. Use retry, or disable participation to delete its data.",
"inspect": "See exactly what is shared",
"preview": "Preview next report",
"lastPacket": "Last accepted signed packet",
"export": "Download all raw packets",
"connect": "Connect to requests & voting",
"openPortal": "Open the portal (15-minute voting session)",
"queued": "The operation is saved for retry. It has not been confirmed as delivered.",
"feedbackTitle": "Feedback & feature requests",
"kind": "Type",
"subject": "Title",
"message": "Your message",
"includeName": "Include a name with this item",
"name": "Display name",
"saveName": "Remember this name locally",
"saved": "Preference saved. Future items still default to anonymous.",
"allowPublic": "I allow this text and included name to be published on the usage portal after review.",
"allowMarketing": "I also allow PicPeak to use this testimonial and included name for homepage marketing.",
"sendFeedback": "Submit feedback",
"feedbackSent": "Feedback received. Publication requires your permission and maintainer review.",
"states": {
"disabled": "Participation is off",
"activation_pending": "Activation pending",
"active": "You are participating",
"deletion_pending": "Deletion pending",
"identity_conflict": "Installation identity conflict"
},
"stateDetails": {
"disabled": "No product usage is collected or sent. You can review the details before deciding.",
"activation_pending": "Consent is saved. Registration will be retried when you use PicPeak or choose retry.",
"active": "Only the disclosed feature signals are collected. Daily reports run when an admin uses PicPeak.",
"deletion_pending": "Collection and reporting are stopped. The signing credentials remain only to finish authenticated deletion. Retry when the collector is reachable.",
"identity_conflict": "This may be a restored or cloned installation, or its report sequence no longer matches the collector. Reporting is stopped. Disable and delete the old participation before joining with a new identity; this also deletes data shared by another copy of the same identity."
},
"kinds": {
"feedback": "Private feedback",
"feature_request": "Feature request",
"testimonial": "Testimonial"
}
},
"userManagement": {
"title": "User Management",
"subtitle": "Manage admin users and invitations",
+7 -1
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { lazy, Suspense, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import {
@@ -25,6 +25,7 @@ import {
type LucideIcon,
} from 'lucide-react';
import { Loading } from '../../components/common';
const ProductUsageTab = lazy(() => import('../../features/settings/tabs/ProductUsageTab'));
import {
useSettingsState,
FeaturesTab,
@@ -64,6 +65,7 @@ import { Briefcase, Receipt, ScrollText, Landmark, Smartphone, MonitorPlay } fro
// Tab keys driving the inner-nav. Must include every key used in
// `navGroups` below and in the switch at the bottom of the component.
type TabType =
| 'usage'
| 'features'
| 'general'
| 'events'
@@ -107,6 +109,7 @@ interface NavGroup {
}
const ALL_TAB_KEYS: TabType[] = [
'usage',
'features', 'general', 'events', 'eventTypes',
'branding', 'categories', 'thumbnails', 'downloads', 'styling', 'cms',
'email', 'moderation',
@@ -129,6 +132,7 @@ function isValidTab(value: string | null): value is TabType {
// Settings via the broadened sidebar gate and sees only the tabs whose specific
// permission it holds. Backend routes enforce the same perms regardless of UI.
const TAB_PERMISSIONS: Record<TabType, string[]> = {
usage: ['settings.edit'],
features: ['settings.view', 'settings.features'],
general: ['settings.view', 'settings.domains'],
events: ['settings.view'],
@@ -382,6 +386,7 @@ export const SettingsPage: React.FC = () => {
items: [
{ key: 'status', label: t('settings.systemStatus.title'), icon: Activity },
{ key: 'analytics', label: t('settings.analytics.title'), icon: BarChart3 },
{ key: 'usage', label: t('productUsage.title'), icon: Shield },
{ key: 'backup', label: t('settings.backup.title', 'Backup'), icon: HardDrive },
],
},
@@ -538,6 +543,7 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
{activeTab === 'accounting' && <AccountingTab />}
{activeTab === 'whatsapp' && <WhatsAppTab />}
{activeTab === 'usage' && hasAnyPermission(['settings.edit']) && <Suspense fallback={<Loading />}><ProductUsageTab /></Suspense>}
{activeTab === 'status' && (
<StatusTab
@@ -0,0 +1,68 @@
import { api } from '../config/api';
export interface UsageStatus {
status:
| 'disabled'
| 'activation_pending'
| 'active'
| 'deletion_pending'
| 'identity_conflict';
notice_dismissed: boolean;
installation_id: string | null;
collector_url: string;
schema_version: string;
last_report_date: string | null;
last_error: string | null;
pending_action: string | null;
last_packet: unknown;
feedback_preferences: { name: string };
}
export interface ProductFeedback {
kind: 'feedback' | 'feature_request' | 'testimonial';
title: string;
body: string;
name: string;
allow_public: boolean;
allow_marketing: boolean;
}
export const productUsageService = {
async status(): Promise<UsageStatus> {
return (await api.get('/admin/usage')).data;
},
async activity(): Promise<void> {
await api.post('/admin/usage/activity');
},
async dismiss(): Promise<UsageStatus> {
return (await api.post('/admin/usage/dismiss')).data;
},
async enable(): Promise<UsageStatus> {
return (
await api.post('/admin/usage/enable', {
consent_version: 'usage-consent.v1'
})
).data;
},
async disable(): Promise<UsageStatus> {
return (await api.post('/admin/usage/disable')).data;
},
async retry(): Promise<UsageStatus> {
return (await api.post('/admin/usage/retry')).data;
},
async preview(): Promise<unknown> {
return (await api.get('/admin/usage/preview')).data;
},
async export(): Promise<unknown> {
return (await api.get('/admin/usage/export')).data;
},
async preferences(name: string): Promise<UsageStatus> {
return (await api.put('/admin/usage/feedback-preferences', { name })).data;
},
async feedback(
value: ProductFeedback
): Promise<{ delivered: boolean; queued?: boolean; state: UsageStatus }> {
return (await api.post('/admin/usage/feedback', value)).data;
},
async portalSession(): Promise<{ delivered: boolean; url: string | null }> {
return (await api.post('/admin/usage/portal-session')).data;
}
};