fix(usage): close the QA findings on opt-in product usage
A QA exploration of this branch against an isolated rig — own stub collector, SQLite and PostgreSQL — turned up one dead end and a set of signals and controls that did not hold up. This closes all of them. Rotating JWT_SECRET, the documented response to a suspected compromise, made the signing key unreadable. That was already named and documented, but it left no way out: the delete packet can never be signed, so the row stays deletion_pending forever, and enable() refuses because it is not `disabled`. An operator who rotated precisely because the secret was compromised cannot restore it, so the feature was bricked with no control left. POST /usage/abandon is offered only in that state; it drops the local identity and records the receipt as `collector-unconfirmed` rather than claiming a deletion that did not happen. Every failed delivery was retried on the next admin request, and /activity is open to any authenticated admin while the settings ticker fires it every five minutes per open tab — 30 activity calls against a rejecting collector produced 30 outbound requests. Migration 206 adds attempts/next_attempt_at and the unattended sender honours the gate; Retry and opt-out still send immediately, and the tab names the time of the next automatic attempt. Feedback, votes and portal sessions now share an installation-wide budget of 30/hour. They are the only endpoints whose effect is outbound traffic carrying operator-written free text, and the general limiter skips authenticated requests by design. Reading status and withdrawing stay unthrottled. gallery_image_protection was true on a bare install with no galleries: PicPeak ships default_protection_level='standard' and enable_devtools_protection=true, so it reported fleet-wide 100% and could never separate a decision from an untouched default. It now reads only what deviates from the shipped defaults, and the devtools flag is not read at all — being on by default, its only informative state is off, which is the opposite of what the key claims. Also: - the export receipt counted every packet and called the total "usage reports"; reports and participant operations are now counted and named separately - GET /usage/preview no longer persists the custom_css marker, so the transparency view stops changing what will be sent - the feedback route requires every field the packet schema requires, so an API caller gets the missing field named instead of a bare INVALID_PACKET from inside signing - the German strings for this feature use "Sie" throughout, matching the rest of the admin UI; the ignore hint says what ignoring will do rather than stating it as already true - the consent dialog returns focus to the control that opened it - the long buttons wrap instead of running off a 390px viewport - a deletion receipt is labelled as belonging to an earlier participation while a new one is active Regression tests cover each of these, including the delete packet's reuse of the last accepted sequence, which was an unwritten assumption about the collector rather than a defect.
This commit is contained in:
@@ -31,6 +31,7 @@ vi.mock('../../../services/productUsage.service', () => ({
|
||||
upgradeConsent: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
abandon: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
export: vi.fn(),
|
||||
preferences: vi.fn(),
|
||||
@@ -216,3 +217,90 @@ describe('product usage controls', () => {
|
||||
await waitFor(() => expect(service.retry).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('a withdrawal that can never be signed', () => {
|
||||
const stuck: UsageStatus = {
|
||||
...status,
|
||||
status: 'deletion_pending',
|
||||
installation_id: 'a'.repeat(64),
|
||||
schema_version: 'usage.v2',
|
||||
last_error: 'SIGNING_KEY_UNREADABLE',
|
||||
can_abandon: true
|
||||
};
|
||||
|
||||
it('explains the dead end and offers the only remaining exit', async () => {
|
||||
vi.mocked(service.status).mockResolvedValue(stuck);
|
||||
vi.mocked(service.abandon).mockResolvedValue({ ...status });
|
||||
mount();
|
||||
|
||||
// The operator is told what happened before being offered the exit.
|
||||
await screen.findByText('productUsage.signingKeyUnreadable');
|
||||
await screen.findByText('productUsage.abandonExplanation');
|
||||
fireEvent.click(await screen.findByText('productUsage.abandon'));
|
||||
await waitFor(() => expect(service.abandon).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('does not offer it for a withdrawal that is merely undelivered', async () => {
|
||||
vi.mocked(service.status).mockResolvedValue({
|
||||
...stuck,
|
||||
last_error: 'DELIVERY_FAILED',
|
||||
can_abandon: false
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('productUsage.deliveryProblem');
|
||||
expect(screen.queryByText('productUsage.abandon')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('says the sender is waiting rather than leaving a bare error on screen', async () => {
|
||||
vi.mocked(service.status).mockResolvedValue({
|
||||
...status,
|
||||
status: 'active',
|
||||
schema_version: 'usage.v2',
|
||||
installation_id: 'a'.repeat(64),
|
||||
last_error: 'DELIVERY_FAILED',
|
||||
retry_after: Date.now() + 600000
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('productUsage.retryScheduled');
|
||||
});
|
||||
|
||||
it('marks a deletion receipt as belonging to an earlier participation', async () => {
|
||||
const receipts = { last_deletion: { kind: 'deletion' } };
|
||||
vi.mocked(service.status).mockResolvedValue({
|
||||
...status,
|
||||
status: 'active',
|
||||
schema_version: 'usage.v2',
|
||||
installation_id: 'a'.repeat(64),
|
||||
privacy_receipts: receipts
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('productUsage.auditPreviousParticipation');
|
||||
|
||||
cleanup();
|
||||
// Withdrawn: the same receipt now describes the participation just ended,
|
||||
// so the qualifier would be wrong.
|
||||
vi.mocked(service.status).mockResolvedValue({ ...status, privacy_receipts: receipts });
|
||||
mount();
|
||||
await screen.findByText('productUsage.auditTitle');
|
||||
expect(screen.queryByText('productUsage.auditPreviousParticipation')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns focus to the control that opened the consent dialog', async () => {
|
||||
mount();
|
||||
const trigger = await screen.findByText('productUsage.review');
|
||||
trigger.focus();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
await screen.findByText('productUsage.consentTitle');
|
||||
fireEvent.click(screen.getByText('productUsage.cancel'));
|
||||
|
||||
// Without the restore this lands on <body>, dropping a keyboard user back
|
||||
// to the top of the page (WCAG 2.4.3).
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByText('productUsage.review')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,13 @@ const DISCLOSURE: {
|
||||
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }
|
||||
];
|
||||
|
||||
// `.btn` is whitespace-nowrap and `.btn-md` a fixed 2.5rem tall — right for
|
||||
// short labels, wrong for the sentence-length ones in this tab, which ran off
|
||||
// the card at 390px and then, once allowed to wrap, out of the fixed height.
|
||||
// h-auto lets the second line have somewhere to go; min-h keeps a one-line
|
||||
// button the same size as every other button beside it.
|
||||
const WRAPPING_BUTTON = 'max-w-full whitespace-normal text-left h-auto min-h-[2.5rem]';
|
||||
|
||||
function ConsentDialog({
|
||||
close,
|
||||
enable,
|
||||
@@ -53,6 +60,12 @@ function ConsentDialog({
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [checked, setChecked] = useState(false);
|
||||
useEffect(() => {
|
||||
// React unmounts this <dialog> on close rather than only closing it, so
|
||||
// the focus restoration showModal() normally performs has nothing left to
|
||||
// return to and focus drops to <body> — a keyboard user is thrown back to
|
||||
// the top of the page every time they cancel (WCAG 2.4.3). Remember the
|
||||
// opener and put focus back by hand.
|
||||
const opener = document.activeElement as HTMLElement | null;
|
||||
ref.current?.showModal();
|
||||
// showModal() focuses the first focusable descendant, which is the scroll
|
||||
// region below — so its focus ring was drawn for everyone the moment the
|
||||
@@ -61,6 +74,9 @@ function ConsentDialog({
|
||||
// Focusing the dialog puts the ring back where it belongs: only when
|
||||
// someone deliberately tabs to the region.
|
||||
ref.current?.focus();
|
||||
return () => {
|
||||
if (opener?.isConnected) opener.focus();
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<dialog
|
||||
@@ -261,6 +277,46 @@ export default function ProductUsageTab() {
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{data.retry_after && (
|
||||
// A paced install is waiting, not broken. Without this the tab shows
|
||||
// a delivery error and an idle Retry button, and nothing says the
|
||||
// sender is going to try again on its own.
|
||||
<p role="status" className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('productUsage.retryScheduled', {
|
||||
time: new Date(data.retry_after).toLocaleTimeString()
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data.can_abandon && (
|
||||
// The one dead end the operator cannot retry out of. Offered only
|
||||
// here, and worded so nobody mistakes it for a confirmed deletion.
|
||||
<div className="rounded border border-amber-300 dark:border-amber-700 p-3 space-y-2">
|
||||
<p>{t('productUsage.abandonExplanation')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t('productUsage.abandon'),
|
||||
message: t('productUsage.abandonConfirm'),
|
||||
confirmLabel: t('productUsage.abandon'),
|
||||
variant: 'danger'
|
||||
})
|
||||
) {
|
||||
await run(async () => {
|
||||
await service.abandon();
|
||||
setPreview(null);
|
||||
setPortalUrl(null);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('productUsage.abandon')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.status === 'disabled' ? (
|
||||
<Button disabled={busy} onClick={() => setConsent(true)}>
|
||||
@@ -323,6 +379,19 @@ export default function ProductUsageTab() {
|
||||
{t('productUsage.auditTitle')}
|
||||
</h3>
|
||||
<p>{t('productUsage.auditDescription')}</p>
|
||||
{/* The receipts outlive the participation they describe: rejoining
|
||||
does not clear them, so an active install would otherwise show
|
||||
a bare "deletion confirmed" next to its own live participation
|
||||
and read as a contradiction. */}
|
||||
{active &&
|
||||
Boolean(
|
||||
data.privacy_receipts.last_deletion ||
|
||||
data.privacy_receipts.last_abandonment
|
||||
) && (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('productUsage.auditPreviousParticipation')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
@@ -342,9 +411,14 @@ export default function ProductUsageTab() {
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('productUsage.inspect')}
|
||||
</h3>
|
||||
{/* `.btn` sets whitespace-nowrap, and these labels are long
|
||||
sentences in both locales — at 390px two of them ran past the
|
||||
card and their text was simply cut off. Allowed to wrap and
|
||||
capped at the container width instead. */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(async () => setPreview(await service.preview()))
|
||||
@@ -354,6 +428,7 @@ export default function ProductUsageTab() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy || !data.last_packet}
|
||||
onClick={() => setPreview(data.last_packet)}
|
||||
>
|
||||
@@ -361,6 +436,7 @@ export default function ProductUsageTab() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(async () => download(await service.export()))
|
||||
@@ -370,6 +446,7 @@ export default function ProductUsageTab() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy || Boolean(data.pending_action)}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
|
||||
@@ -1252,8 +1252,8 @@
|
||||
"de": "Bildschutz aktiviert"
|
||||
},
|
||||
"configured": {
|
||||
"en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.",
|
||||
"de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
"en": "Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts.",
|
||||
"de": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"used": null
|
||||
},
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"currentSchema": "Aktuelles Berichtsschema: {{schema}}",
|
||||
"reviewUpgrade": "Erweiterten Umfang von usage.v2 prüfen",
|
||||
"upgrade": "usage.v2 ausdrücklich zustimmen",
|
||||
"upgradeExplanation": "Deine bestehende usage.v1-Teilnahme bleibt unverändert. Prüfe den vollständigen erweiterten Katalog, bevor du über das Upgrade entscheidest. Eine Ablehnung beendet deine bisherige Teilnahme nicht.",
|
||||
"upgradePending": "Die signierte Erweiterung der Zustimmung wartet auf Bestätigung. Es wird nur der bisherige v1-Umfang erfasst. Versuche es erneut, sobald der Collector erreichbar ist, oder deaktiviere die Teilnahme zum Stoppen und Löschen.",
|
||||
"upgradeExplanation": "Ihre bestehende usage.v1-Teilnahme bleibt unverändert. Prüfen Sie den vollständigen erweiterten Katalog, bevor Sie über das Upgrade entscheiden. Eine Ablehnung beendet Ihre bisherige Teilnahme nicht.",
|
||||
"upgradePending": "Die signierte Erweiterung der Zustimmung wartet auf Bestätigung. Es wird nur der bisherige v1-Umfang erfasst. Versuchen Sie es erneut, sobald der Collector erreichbar ist, oder deaktivieren Sie die Teilnahme zum Stoppen und Löschen.",
|
||||
"catalog": {
|
||||
"crm": {
|
||||
"name": "Kundenverwaltung",
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"gallery_image_protection": {
|
||||
"name": "Bildschutz aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
"configured": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_reveal": {
|
||||
"name": "Galerie-Enthüllung aktiviert",
|
||||
@@ -365,17 +365,17 @@
|
||||
}
|
||||
},
|
||||
"auditTitle": "Export- und Löschquittungen",
|
||||
"auditDescription": "Lade deine privaten Nachweise herunter. PicPeak speichert nur die letzte Exportquittung während der Teilnahme und die letzte Löschbestätigung. Sie enthalten keinen Installationshash, Schlüssel oder Bericht-/Feedbackinhalt. Opt-out entfernt die lokale Exportquittung; die Löschbestätigung ohne Identitätsbezug bleibt erhalten. Der Collector führt keinen Export- oder Zugriffsverlauf.",
|
||||
"auditDescription": "Laden Sie Ihre privaten Nachweise herunter. PicPeak speichert nur die letzte Exportquittung während der Teilnahme und die letzte Löschbestätigung. Sie enthalten keinen Installationshash, Schlüssel oder Bericht-/Feedbackinhalt. Opt-out entfernt die lokale Exportquittung; die Löschbestätigung ohne Identitätsbezug bleibt erhalten. Der Collector führt keinen Export- oder Zugriffsverlauf.",
|
||||
"auditDownload": "Datenschutzquittungen herunterladen",
|
||||
"title": "Produktnutzung & Feedback",
|
||||
"noticeTitle": "Gestalten Sie PicPeak mit",
|
||||
"notice": "Optionale Nutzungsberichte zeigen, welche Funktionen für die Community wichtig sind. Die Übermittlung ist aus, bis Sie sich aktiv dafür entscheiden.",
|
||||
"ignore": "Ignorieren",
|
||||
"ignoreHint": "Dieser Hinweis erscheint nicht erneut — Sie können weiterhin unter Einstellungen → Produktnutzung teilnehmen.",
|
||||
"ignoreHint": "Wenn Sie ignorieren, erscheint dieser Hinweis nicht erneut — Sie können weiterhin unter Einstellungen → Produktnutzung teilnehmen.",
|
||||
"review": "Teilnahme prüfen",
|
||||
"cancel": "Abbrechen",
|
||||
"loading": "Teilnahmeeinstellungen werden geladen…",
|
||||
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
|
||||
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfen Sie den Status und versuchen Sie es erneut.",
|
||||
"purpose": "Hilf bei der Priorisierung von PicPeak-Funktionen, Fehlerbehebungen und Wartung mit groben Informationen über teilnehmende Installationen.",
|
||||
"consentTitle": "Produktnutzung freiwillig teilen",
|
||||
"sectionFields": "Was ein Bericht enthält",
|
||||
@@ -385,19 +385,19 @@
|
||||
"sectionDeletion": "Beenden und löschen",
|
||||
"sectionFeedback": "Feedback ist getrennt",
|
||||
"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 einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Du kannst Berichte vorab ansehen und jeden eindeutig angenommenen Bericht genau wie beim ersten Empfang herunterladen; Übertragungswiederholungen werden zusammengeführt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Berichtsexport.",
|
||||
"visibility": "Nur teilnehmende Installationen können den Funktionsdatensatz und aggregierte Ergebnisse einsehen, auch Gruppen mit nur einer Installation. Schema und Quellcode sind öffentlich; geprüfte Funktionswünsche und Empfehlungen werden nur mit Erlaubnis ihrer Verfasser veröffentlicht. Dein Fingerabdruck ist pseudonym, nicht anonym. Bewahre deinen Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf deine eigenen Berichte und den Teilnehmerdatensatz.",
|
||||
"transport": "Ihr PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Sie können Berichte vorab ansehen und jeden eindeutig angenommenen Bericht genau wie beim ersten Empfang herunterladen; Übertragungswiederholungen werden zusammengeführt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Berichtsexport.",
|
||||
"visibility": "Nur teilnehmende Installationen können den Funktionsdatensatz und aggregierte Ergebnisse einsehen, auch Gruppen mit nur einer Installation. Schema und Quellcode sind öffentlich; geprüfte Funktionswünsche und Empfehlungen werden nur mit Erlaubnis ihrer Verfasser veröffentlicht. Ihr Fingerabdruck ist pseudonym, nicht anonym. Bewahren Sie Ihren Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf Ihre eigenen Berichte und den Teilnehmerdatensatz.",
|
||||
"deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung der Berichte, Aggregatbeiträge, Rückmeldungen, Veröffentlichungen, Stimmen und Sitzungen an. Bei einem Ausfall bleiben nur die zur 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 Collector behält einen Einweg-Sperrwert und kurzlebige Missbrauchszähler ohne Installationsbezug. PicPeak speichert eine herunterladbare lokale Löschquittung ohne den alten Hash, Schlüssel oder Inhalte.",
|
||||
"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.",
|
||||
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern Sie keinen Namen angeben, und nur für Betreuer sichtbar, sofern Sie die Veröffentlichung nicht ausdrücklich erlauben. Ö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",
|
||||
"linkCollector": "Wohin Berichte gesendet werden",
|
||||
"hash": "Dein vertraulicher Abfrage-Hash",
|
||||
"hash": "Ihr 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.",
|
||||
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuchen Sie es erneut oder deaktivieren Sie die Teilnahme, um die Daten zu löschen.",
|
||||
"invalidCollectorUrl": "Die konfigurierte Collector-URL ist ungültig, daher kann die Teilnahme weder gestartet noch übermittelt werden. Setzen Sie USAGE_COLLECTOR_URL auf einen https-Origin ohne Pfad, Query oder Zugangsdaten (oder lassen Sie sie leer, um den Standard zu verwenden).",
|
||||
"signingKeyUnreadable": "Der Signaturschlüssel für die Nutzungsdaten kann nicht gelesen werden. Meist wurde USAGE_ENCRYPTION_KEY — oder das als Rückfallwert genutzte JWT_SECRET — geändert. Berichte können nicht gesendet und auch die Löschanfrage kann nicht signiert werden. Stellen Sie das ursprüngliche Schlüsselmaterial wieder her, um die Löschung abzuschließen; erneutes Senden oder Deaktivieren allein behebt dies nicht.",
|
||||
"inspect": "Genau sehen, was geteilt wird",
|
||||
@@ -410,7 +410,7 @@
|
||||
"feedbackTitle": "Feedback & Funktionswünsche",
|
||||
"kind": "Art",
|
||||
"subject": "Titel",
|
||||
"message": "Deine Nachricht",
|
||||
"message": "Ihre Nachricht",
|
||||
"includeName": "Diesem Beitrag einen Namen hinzufügen",
|
||||
"name": "Anzeigename",
|
||||
"saveName": "Diesen Namen lokal merken",
|
||||
@@ -418,26 +418,31 @@
|
||||
"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.",
|
||||
"feedbackSent": "Feedback erhalten. Eine Veröffentlichung erfordert Ihre Erlaubnis und die Prüfung durch Betreuer.",
|
||||
"states": {
|
||||
"disabled": "Teilnahme ist deaktiviert",
|
||||
"activation_pending": "Aktivierung ausstehend",
|
||||
"active": "Du nimmst teil",
|
||||
"active": "Sie nehmen 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.",
|
||||
"disabled": "Es werden keine Produktnutzungsdaten erfasst oder gesendet. Prüfen Sie die Hinweise, bevor Sie sich entscheiden.",
|
||||
"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.",
|
||||
"deletion_pending": "Erfassung und Berichte sind gestoppt. Die Signaturdaten bleiben ausschließlich für die bestätigte Löschung erhalten. Versuchen Sie 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"
|
||||
}
|
||||
},
|
||||
"retryScheduled": "Der nächste automatische Versuch erfolgt um {{time}}. „Erneut versuchen“ sendet sofort.",
|
||||
"abandon": "Lokale Identität verwerfen",
|
||||
"abandonExplanation": "Die Löschanfrage kann ohne das ursprüngliche Schlüsselmaterial nicht signiert werden. Wenn Sie es nicht wiederherstellen können, lässt sich die lokale Identität verwerfen: Erfassung und Schlüssel werden hier entfernt, der Collector bestätigt die Löschung dabei aber nicht.",
|
||||
"abandonConfirm": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector wird nicht benachrichtigt und behält die bisher gesendeten Berichte — die Quittung hält das als unbestätigt fest. Danach ist eine neue Teilnahme wieder möglich.",
|
||||
"auditPreviousParticipation": "Löschbestätigungen beziehen sich auf eine frühere Teilnahme, nicht auf die aktuelle."
|
||||
},
|
||||
"userManagement": {
|
||||
"title": "Benutzerverwaltung",
|
||||
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"gallery_image_protection": {
|
||||
"name": "Image protection enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
"configured": "Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_reveal": {
|
||||
"name": "Gallery reveal enabled",
|
||||
@@ -371,7 +371,7 @@
|
||||
"noticeTitle": "Help shape PicPeak",
|
||||
"notice": "Optional product usage reports show which features matter to the community. Reporting is off until you choose to participate.",
|
||||
"ignore": "Ignore",
|
||||
"ignoreHint": "This notice won't appear again — you can still join from Settings → Product usage.",
|
||||
"ignoreHint": "If you ignore this, the notice won't appear again — you can still join from Settings → Product usage.",
|
||||
"review": "Review participation",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading participation settings…",
|
||||
@@ -437,7 +437,12 @@
|
||||
"feedback": "Private feedback",
|
||||
"feature_request": "Feature request",
|
||||
"testimonial": "Testimonial"
|
||||
}
|
||||
},
|
||||
"retryScheduled": "The next automatic attempt is at {{time}}. \"Retry\" sends immediately.",
|
||||
"abandon": "Discard local identity",
|
||||
"abandonExplanation": "The deletion request cannot be signed without the original encryption material. If you cannot restore it, you can discard the local identity: collection and keys are removed here, but the collector does not confirm the deletion.",
|
||||
"abandonConfirm": "This deletes the installation identity, the key material and every local marker. The collector is not notified and keeps the reports already sent — the receipt records that as unconfirmed. You can join again afterwards.",
|
||||
"auditPreviousParticipation": "Deletion confirmations refer to an earlier participation, not the current one."
|
||||
},
|
||||
"userManagement": {
|
||||
"title": "User Management",
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface UsageStatus {
|
||||
consent_update_available?: boolean;
|
||||
last_report_date: string | null;
|
||||
last_error: string | null;
|
||||
/** Epoch ms the paced sender is waiting for, or null when nothing is paced. */
|
||||
retry_after?: number | null;
|
||||
/** True only for a withdrawal whose delete packet can never be signed. */
|
||||
can_abandon?: boolean;
|
||||
pending_action: string | null;
|
||||
last_packet: unknown;
|
||||
privacy_receipts?: Record<string, unknown>;
|
||||
@@ -56,6 +60,9 @@ export const productUsageService = {
|
||||
async retry(): Promise<UsageStatus> {
|
||||
return (await api.post('/admin/usage/retry')).data;
|
||||
},
|
||||
async abandon(): Promise<UsageStatus> {
|
||||
return (await api.post('/admin/usage/abandon')).data;
|
||||
},
|
||||
async preview(): Promise<unknown> {
|
||||
return (await api.get('/admin/usage/preview')).data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user