From f114f3e876f81a06b9ea28598f326bcbbc5901af Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 7 Sep 2026 08:24:43 +0200 Subject: [PATCH 1/2] feat(usage): open the portal signed in, with the credential never in a served URL The "open usage portal" button was a plain link, so an operator who wanted to see their own data had to copy the lookup hash out of the settings page and paste it into the portal. Next to it sat a second control, "connect to requests & voting", which minted a collector session and then showed a third thing, a link to open it. One button now. Before participation it stays the plain link: the portal is public and someone deciding whether to join should be able to look at it first. While participating, a click asks the backend for a collector session (a signed `session` command, so the collector knows which installation this is) and opens the portal with that token in the URL fragment. Fragments are never sent over the wire; the portal drops it from the address bar on load and keeps the session in memory only. The lookup hash itself never leaves the settings page, and no URL a server or an access log sees ever carries a credential. The tab is opened synchronously in the click handler and navigated once the session exists, because opening it after the await trips popup blockers. If the collector cannot be reached the session command is queued for retry and the tab falls back to the public portal, so the click still lands somewhere; a failed request closes the tab again. The separate connect button and its session link are gone, and so are their strings. --- .../__tests__/ProductUsageTab.test.tsx | 62 +++++++- .../settings/tabs/ProductUsageTab.tsx | 132 ++++++++++++------ frontend/src/i18n/locales/en.json | 3 +- 3 files changed, 153 insertions(+), 44 deletions(-) diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx index 1a306b3c..3699e6cb 100644 --- a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -329,8 +329,6 @@ describe('the plain usage portal link', () => { expect(link).toHaveAttribute('target', '_blank'); expect(link).toHaveAttribute('rel', 'noopener noreferrer'); expect(service.portalSession).not.toHaveBeenCalled(); - // The session-bound link is a different thing and stays behind `connect`. - expect(screen.queryByText('productUsage.openPortal')).toBeNull(); }); it('is not offered when the collector URL is unusable', async () => { @@ -344,3 +342,63 @@ describe('the plain usage portal link', () => { expect(screen.queryByRole('link', { name: 'productUsage.openUsagePortal' })).toBeNull(); }); }); + +// While participating, the same button signs the operator in. The credential +// must never sit in a URL a server sees: the backend mints a short-lived +// collector session and the portal receives it in the fragment only. +describe('the signed-in usage portal button', () => { + const tab = { document: { open: vi.fn(), write: vi.fn(), close: vi.fn() }, opener: {} as unknown, close: vi.fn() }; + const written = () => tab.document.write.mock.calls.map((c) => String(c[0])).join(''); + beforeEach(() => { + tab.opener = {}; tab.close.mockClear(); tab.document.write.mockClear(); tab.document.open.mockClear(); tab.document.close.mockClear(); + vi.stubGlobal('open', vi.fn(() => tab)); + vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active' }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('opens a tab synchronously, mints a session and navigates it without a referrer', async () => { + vi.mocked(service.portalSession).mockResolvedValue({ + delivered: true, url: 'https://usage.picpeak.app/#connect=session-token' + }); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'productUsage.openUsagePortal' })); + expect(window.open).toHaveBeenCalledWith('about:blank', '_blank'); + await waitFor(() => expect(written()).toContain('url=https://usage.picpeak.app/#connect=session-token')); + // The written page, not the admin page, initiates the navigation — and + // it says no-referrer, so the collector never sees this origin. + expect(written()).toContain(''); + expect(tab.opener).toBeNull(); + expect(screen.queryByRole('link', { name: 'productUsage.openUsagePortal' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'productUsage.portalReady' })).toBeNull(); + }); + + it('offers the session URL as a link when the browser refused the tab', async () => { + vi.mocked(window.open).mockReturnValue(null); + vi.mocked(service.portalSession).mockResolvedValue({ + delivered: true, url: 'https://usage.picpeak.app/#connect=session-token' + }); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'productUsage.openUsagePortal' })); + const link = await screen.findByRole('link', { name: 'productUsage.portalReady' }); + expect(link).toHaveAttribute('href', 'https://usage.picpeak.app/#connect=session-token'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(window.open).toHaveBeenCalledTimes(1); + }); + + it('falls back to the public portal and says so when the session is only queued', async () => { + vi.mocked(service.portalSession).mockResolvedValue({ delivered: false, url: null }); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'productUsage.openUsagePortal' })); + await waitFor(() => expect(written()).toContain('url=https://usage.picpeak.app"')); + await screen.findByText('productUsage.queued'); + }); + + it('closes the tab again when the request fails', async () => { + vi.mocked(service.portalSession).mockRejectedValue(new Error('down')); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'productUsage.openUsagePortal' })); + await screen.findByText('productUsage.failed'); + expect(tab.close).toHaveBeenCalled(); + expect(tab.document.write).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index fb571e73..54647ab3 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -196,8 +196,10 @@ export default function ProductUsageTab() { const [busy, setBusy] = useState(false); const [message, setMessage] = useState(''); const [preview, setPreview] = useState(null); - const [portalUrl, setPortalUrl] = useState(null); const [named, setNamed] = useState(false); + // Set only when the browser refused the tab (popup blocked): the session + // URL is then offered as a plain link the operator can click instead. + const [portalUrl, setPortalUrl] = useState(null); const [form, setForm] = useState({ kind: 'feedback', title: '', @@ -231,6 +233,58 @@ export default function ProductUsageTab() { if (isPending) return

{t('productUsage.loading')}

; if (isError || !data) return

{t('productUsage.failed')}

; const active = data.status === 'active'; + // Signed-in portal access without the credential ever touching a URL that + // a server sees. The backend asks the collector for a short-lived session + // (a signed `session` command, so the collector knows which installation + // this is), and the portal is opened with that token in the URL *fragment*: + // fragments are never sent over the wire, and the portal drops it from the + // address bar on load and keeps the session in memory only. The lookup + // hash itself never leaves the settings page. + // + // The tab is opened synchronously in the click handler and navigated once + // the session exists — opening it after the await trips popup blockers. + // Navigation goes through a document written into the blank tab rather + // than `tab.location`: a script-initiated navigation carries the admin + // page as referrer, and the collector must not learn this installation's + // origin. The written page declares no-referrer and refreshes itself. + // + // If the browser refused the tab, the URL is kept as a plain link instead + // of a second window.open after the await, which would be refused too. If + // the collector cannot be reached the session command is queued for retry + // and the tab falls back to the public portal, so the click still lands + // somewhere. + const openPortal = () => { + if (!data.collector_url) return; + setPortalUrl(null); + const tab = window.open('about:blank', '_blank'); + if (tab) tab.opener = null; + const go = (url: string) => { + if (!tab) { + setPortalUrl(url); + return; + } + const escaped = url.replace(/"/g, '"'); + tab.document.open(); + tab.document.write( + `` + ); + tab.document.close(); + }; + return run(async () => { + try { + const result = await service.portalSession(); + if (result.url) { + go(result.url); + } else { + go(data.collector_url as string); + if (!result.delivered) setMessage(t('productUsage.queued')); + } + } catch (error) { + tab?.close(); + throw error; + } + }); + }; return (

{t('productUsage.purpose')}

@@ -326,7 +380,6 @@ export default function ProductUsageTab() { await run(async () => { await service.abandon(); setPreview(null); - setPortalUrl(null); }); } }} @@ -368,7 +421,6 @@ export default function ProductUsageTab() { await run(async () => { await service.disable(); setPreview(null); - setPortalUrl(null); }); } }} @@ -379,19 +431,43 @@ export default function ProductUsageTab() { )} {data.collector_url && ( <> - {/* The public portal itself, not the session-bound link below: - it needs neither participation nor a voting session, so an - operator can look at the portal before deciding to join. - Styled as a button so it reads as an action, not a footnote. */} - - {t('productUsage.openUsagePortal')} - + {/* One button, two behaviours. Before participation it is a plain + link: the portal is public and an operator deciding whether + to join should be able to look at it first. While + participating it opens the portal signed in — see openPortal + above — so nobody has to copy the lookup hash around. */} + {active ? ( + <> + + {portalUrl && ( + + {t('productUsage.portalReady')} + + )} + + ) : ( + + {t('productUsage.openUsagePortal')} + + )} {t('productUsage.export')} -
- {portalUrl && ( - - {t('productUsage.openPortal')} - - )} {preview !== null && (
Date: Mon, 7 Sep 2026 08:24:44 +0200
Subject: [PATCH 2/2] fix(i18n): rewrite the German product-usage copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The block mixed Sie and du (four strings duzed, the rest siezte), used
two words for the same credential (Lookup-Hash, Abfrage-Hash) and two
for the same people (Maintainer, Betreuer), and carried a handful of
strings that read like a translation rather than German: "Die
Übertragung benötigt Aufmerksamkeit", "rundheraus abgelehnt",
"Integriert bedeutet verfügbar, nicht genutzt", "feste Galerie-Layouts"
for controlled layouts, "Erneut versuchen / fälligen Bericht senden" as
a button label.

Now du throughout (the form the maintainer chose for this area),
Abfrage-Hash and Betreuer everywhere, Collector left as the product
name it is, and the clunky strings reworded. No key added or removed;
meaning unchanged, so nothing here touches consent.
---
 frontend/src/i18n/locales/de.json | 95 +++++++++++++++----------------
 1 file changed, 47 insertions(+), 48 deletions(-)

diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 1dfc1e1a..e58a7c10 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1,19 +1,19 @@
 {
   "productUsage": {
-    "fields": "usage.v4-Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, UTC-Berichtsdatum und Erstellungszeit, Schema-/Signaturmetadaten, feste Galerie-Layouts, 86 Funktionssignale (63 Konfiguriert/Genutzt-Paare und 23 reine Konfigurationswerte) sowie zwei Gesamtzahlen der Installation: gespeicherte Galerien und Fotoeinträge ohne Videos. Entwürfe, archivierte Galerien und deren erhaltene Fotoeinträge zählen mit. Der Katalog unten erklärt jedes Feld. Keine Aktionsanzahlen oder Besucherbeobachtung.",
+    "fields": "usage.v4-Berichte enthalten einen Installationsfingerabdruck, die PicPeak-Version, UTC-Berichtsdatum und Erstellungszeit, Schema- und Signaturmetadaten, die Galerie-Layouts aus einer festen Liste, 86 Funktionssignale (63 Paare aus Konfiguriert und Genutzt, 23 reine Konfigurationswerte) sowie zwei Gesamtzahlen der Installation: gespeicherte Galerien und Fotoeinträge ohne Videos. Entwürfe, archivierte Galerien und deren erhaltene Fotoeinträge zählen mit. Der Katalog unten erklärt jedes Feld. Keine Aktionszähler, keine Beobachtung von Besuchern.",
     "catalogTitle": "Vollständiger Katalog: 86 Funktionssignale und 2 Bestandszahlen (usage.v4)",
-    "catalogExplanation": "Konfiguriert beschreibt die aktuelle technische Verfügbarkeit oder Einrichtung. Integriert bedeutet verfügbar, nicht genutzt. Genutzt ist ein installationsweites Ja/Nein seit Zustimmung zum Berichtsschema; angenommene Aufträge gelten als gestartet, nicht zwingend abgeschlossen. Reine Konfigurationssignale enthalten kein Genutzt-Feld. Der Bestand enthält nur aktuelle Gesamtzahlen der Galerie-/Fotoeinträge, ohne Aufschlüsselung nach Galerien. Nutzungsmarker speichern keine Personen, Objektkennungen, Aktionszeiten oder Häufigkeiten.",
+    "catalogExplanation": "„Konfiguriert“ beschreibt, ob eine Funktion technisch verfügbar oder eingerichtet ist. „Integriert“ heißt: immer verfügbar, ohne Nutzungsnachweis. „Genutzt“ ist ein einzelnes Ja/Nein für die ganze Installation seit der Zustimmung zu diesem Schema; ein angenommener Auftrag zählt als gestartet, nicht als abgeschlossen. Reine Konfigurationssignale haben kein Genutzt-Feld. Der Bestand enthält nur die aktuellen Gesamtzahlen der Galerie- und Fotoeinträge, ohne Aufschlüsselung nach Galerien. Nutzungsmarker speichern keine Personen, Objektkennungen, Zeitpunkte oder Häufigkeiten.",
     "catalogSearch": "Funktionsname oder Schlüssel suchen",
     "catalogEmpty": "Keine passenden Funktionen.",
     "configuredLabel": "Konfiguriert",
     "usedLabel": "Genutzt",
-    "configurationOnly": "Nur Konfiguration — tatsächliche Nutzung wird nicht erfasst.",
-    "versionDisclosure": "Diese Zustimmung gilt für usage.v4 / usage-consent.v4. Sie ersetzt die Frage „Erlaubt mindestens eine Galerie Downloads?“ durch „Hat mindestens eine Galerie Downloads abgeschaltet?“. Bestehende v1/v2/v3-Teilnahmen behalten ihren bisherigen Umfang bis zur ausdrücklichen Erweiterung. Identität und Rohhistorie bleiben erhalten; wartende Pakete werden vor dem Upgrade unverändert zugestellt. Lokale Nutzungsmarker beginnen erst nach Bestätigung neu. Der erste v4-Bericht kann am nächsten aktiven UTC-Tag folgen. Das neue Signal wird vor Bestätigung nicht erfasst.",
+    "configurationOnly": "Nur Konfiguration, die tatsächliche Nutzung wird nicht erfasst.",
+    "versionDisclosure": "Diese Zustimmung gilt für usage.v4 / usage-consent.v4. Sie ersetzt die Frage „Erlaubt mindestens eine Galerie Downloads?“ durch „Hat mindestens eine Galerie Downloads abgeschaltet?“. Bestehende v1-, v2- und v3-Teilnahmen behalten ihren bisherigen Umfang, bis du sie ausdrücklich erweiterst. Identität und Rohhistorie bleiben erhalten, wartende Pakete werden vor dem Upgrade unverändert zugestellt. Lokale Nutzungsmarker beginnen erst nach der Bestätigung durch den Collector neu. Der erste v4-Bericht kann am nächsten aktiven UTC-Tag folgen. Das neue Signal wird vor der Bestätigung nicht erfasst.",
     "currentSchema": "Aktuelles Berichtsschema: {{schema}}",
-    "reviewUpgrade": "Erweiterten Umfang von usage.v4 prüfen",
+    "reviewUpgrade": "Erweiterten Umfang von usage.v4 ansehen",
     "upgrade": "usage.v4 ausdrücklich zustimmen",
-    "upgradeExplanation": "Deine bestehende Teilnahme behält ihren bisherigen Umfang. Prüfe den erweiterten Katalog und die beiden Bestandszahlen, bevor du dich entscheidest. Ablehnen beendet die Teilnahme nicht.",
-    "upgradePending": "Die signierte Erweiterung wartet auf Bestätigung. Es wird nur der bisher bestätigte Umfang erfasst. Wiederhole den Versuch, wenn der Collector erreichbar ist, oder deaktiviere die Teilnahme zum Stoppen und Löschen.",
+    "upgradeExplanation": "Deine bestehende Teilnahme behält ihren bisherigen Umfang. Sieh dir den erweiterten Katalog und die beiden Bestandszahlen an, bevor du dich entscheidest. Ablehnen beendet die Teilnahme nicht.",
+    "upgradePending": "Die signierte Erweiterung wartet auf die Bestätigung des Collectors. Bis dahin wird nur der bisher bestätigte Umfang erfasst. Versuche es erneut, wenn der Collector erreichbar ist, oder deaktiviere die Teilnahme, um zu stoppen und zu löschen.",
     "catalog": {
       "crm": {
         "name": "Kundenverwaltung",
@@ -428,18 +428,18 @@
       }
     },
     "auditTitle": "Export- und Löschquittungen",
-    "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.",
+    "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, keinen Schlüssel und keine Bericht- oder Feedbackinhalte. Ein 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.",
+    "noticeTitle": "Gestalte PicPeak mit",
+    "notice": "Freiwillige Nutzungsberichte zeigen, welche Funktionen für die Community wichtig sind. Es wird nichts übermittelt, bis du dich aktiv dafür entscheidest.",
     "ignore": "Ignorieren",
-    "ignoreHint": "Wenn Sie ignorieren, erscheint dieser Hinweis nicht erneut — Sie können weiterhin unter Einstellungen → Produktnutzung teilnehmen.",
+    "ignoreHint": "Wenn du ignorierst, erscheint dieser Hinweis nicht erneut. Du kannst jederzeit unter Einstellungen → Produktnutzung teilnehmen.",
     "review": "Details ansehen",
     "cancel": "Abbrechen",
-    "loading": "Teilnahmeeinstellungen werden geladen…",
-    "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.",
+    "loading": "Teilnahmeeinstellungen werden geladen …",
+    "failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
+    "purpose": "Hilf mit, PicPeak-Funktionen, Fehlerbehebungen und Wartung zu priorisieren, mit groben Informationen über teilnehmende Installationen.",
     "consentTitle": "Produktnutzung freiwillig teilen",
     "sectionFields": "Was ein Bericht enthält",
     "sectionExcluded": "Was niemals enthalten ist",
@@ -447,73 +447,72 @@
     "sectionVisibility": "Wo er sichtbar ist",
     "sectionDeletion": "Beenden und löschen",
     "sectionFeedback": "Feedback ist getrennt",
-    "excluded": "Automatische Berichte enthalten keine Besucheraktivität, Klickverläufe, Aufschlüsselungen nach Galerien oder Fotos, Namen, E-Mails, Domains, Dateinamen, Bildinhalte, biometrischen Ergebnisse, Finanzwerte oder Konfigurationsgeheimnisse.",
-    "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.",
-    "sectionOneWay": "Nur senden — kein Rückkanal",
-    "oneWay": "PicPeak sendet ausschließlich. Es ruft beim Collector nichts ab, holt sich keine Anweisungen und stellt ihm keinen Endpunkt bereit, den er aufrufen könnte — auf diesem Weg gibt es weder einen geplanten Job noch eine eingehende Route. Aus einer Antwort wird nur die Bestätigung für das eben gesendete Paket gelesen, und jedes ihrer Felder wird gegen dieses Paket geprüft, bevor sie angenommen wird; alles andere wird verworfen. Ein von Ihnen angeforderter Datenexport wird Ihnen als Datei übergeben und niemals ausgewertet oder ausgeführt. Über diesen Kanal können also weder Code noch Konfiguration oder Inhalte in Ihre Installation gelangen — auch nicht von einem übernommenen Collector.",
-    "visibility": "Teilnehmende Installationen können den gemeinsamen Datensatz der neuesten Funktionskombinationen und Bestandszahlen einschließlich Einzelgruppen sowie aggregierte Zeitverläufe einsehen. Maintainer können alle aufbewahrten Originalberichte und privates Feedback einsehen und exportieren. Dies erlaubt keine Veröffentlichung privaten Feedbacks. Schema und Quellcode sind öffentlich. Dein Fingerabdruck und die Daten sind pseudonym, nicht anonym. Halte deinen Lookup-Hash geheim: Er gewährt Lesezugriff auf deine 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 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.",
+    "excluded": "Automatische Berichte enthalten keine Besucheraktivität, keine Klickverläufe, keine Aufschlüsselung nach Galerien oder Fotos, keine Namen, E-Mail-Adressen, Domains, Dateinamen oder Bildinhalte, keine biometrischen Ergebnisse, keine Finanzwerte und keine Konfigurationsgeheimnisse.",
+    "transport": "Dein PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag, während ein Admin angemeldet ist, einen signierten Nutzungsbericht an {{collector}}. Du kannst Berichte vorab ansehen und jeden angenommenen Bericht genau so herunterladen, wie er beim Collector angekommen ist; wiederholte Übertragungen zählen nicht doppelt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Export.",
+    "sectionOneWay": "Nur senden, kein Rückkanal",
+    "oneWay": "PicPeak sendet ausschließlich. Es ruft beim Collector nichts ab, holt keine Anweisungen und bietet ihm keinen Endpunkt, den er aufrufen könnte: auf diesem Weg gibt es weder einen geplanten Job noch eine eingehende Route. Aus einer Antwort wird nur die Bestätigung für das gerade gesendete Paket gelesen, und jedes ihrer Felder wird gegen dieses Paket geprüft, bevor sie angenommen wird; alles andere wird verworfen. Ein von dir angeforderter Datenexport wird dir als Datei übergeben und niemals ausgewertet oder ausgeführt. Über diesen Kanal können also weder Code noch Konfiguration oder Inhalte in deine Installation gelangen, auch nicht von einem übernommenen Collector.",
+    "visibility": "Teilnehmende Installationen können den gemeinsamen Datensatz der aktuellen Funktionskombinationen und Bestandszahlen einsehen, auch Gruppen mit nur einem Eintrag, sowie aggregierte Zeitverläufe. Betreuer können alle aufbewahrten Originalberichte und privates Feedback einsehen und exportieren. Das erlaubt keine Veröffentlichung privaten Feedbacks. Schema und Quellcode sind öffentlich. Dein Fingerabdruck und deine Daten sind pseudonym, nicht anonym. Halte deinen Abfrage-Hash geheim: Er gewährt Lesezugriff auf deine Berichte und den Teilnehmerdatensatz.",
+    "deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung deiner Berichte, Aggregatbeiträge, Rückmeldungen, Veröffentlichungen, Stimmen und Sitzungen an. Bei einem Ausfall bleiben nur die für die Löschung nötigen Zugangsdaten erhalten, die Oberfläche zeigt die ausstehende Löschung. Nach der 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 den 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 vorher geprüft. Eine Empfehlung fürs Marketing zu verwenden braucht 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",
+    "retry": "Jetzt senden",
     "openUsagePortal": "Nutzungsportal öffnen",
-    "transparency": "Öffentliches Schema & Datenschutzhinweise",
+    "portalReady": "Portal in neuem Tab öffnen",
+    "transparency": "Öffentliches Schema und Datenschutzhinweise",
     "linkCollector": "Wohin Berichte gesendet werden",
-    "hash": "Ihr vertraulicher Abfrage-Hash",
+    "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. 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.",
-    "schemaNotAccepted": "Der Collector hat das Paket rundheraus abgelehnt — er nimmt diese Berichtsversion also noch nicht an, meist weil er nicht aktualisiert wurde. Erneutes Senden ändert daran nichts. Es wurde nichts registriert; Sie können die Teilnahme verwerfen und erneut beitreten, sobald der Collector sie unterstützt.",
-    "inspect": "Genau sehen, was geteilt wird",
+    "deliveryProblem": "Die Übertragung ist gestört. Während einer Löschung oder bei einem Identitätskonflikt ist die Erfassung gestoppt. Versuche es erneut oder deaktiviere die Teilnahme, um die Daten zu löschen.",
+    "invalidCollectorUrl": "Die konfigurierte Collector-URL ist ungültig, deshalb kann die Teilnahme weder gestartet noch übermittelt werden. Setze USAGE_COLLECTOR_URL auf einen https-Origin ohne Pfad, Query oder Zugangsdaten, oder lass die Variable leer, um den Standard zu verwenden.",
+    "signingKeyUnreadable": "Der Signaturschlüssel für die Nutzungsdaten kann nicht gelesen werden. Meist wurde USAGE_ENCRYPTION_KEY geändert, oder das als Rückfallwert genutzte JWT_SECRET. Berichte können nicht gesendet werden, und auch die Löschanfrage lässt sich nicht signieren. Stelle das ursprüngliche Schlüsselmaterial wieder her, um die Löschung abzuschließen; erneutes Senden oder Deaktivieren allein hilft nicht.",
+    "schemaNotAccepted": "Der Collector hat das Paket als ungültig abgelehnt. Er nimmt diese Berichtsversion also noch nicht an, meist weil er noch nicht aktualisiert wurde. Erneutes Senden ändert daran nichts. Es wurde nichts registriert; du kannst die Teilnahme verwerfen und erneut beitreten, sobald der Collector sie unterstützt.",
+    "inspect": "Genau ansehen, was geteilt wird",
     "preview": "Nächsten Bericht ansehen",
     "lastPacket": "Zuletzt angenommener signierter Nutzungsbericht",
     "export": "Alle angenommenen Nutzungsberichte 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",
+    "feedbackTitle": "Feedback und Funktionswünsche",
     "kind": "Art",
     "subject": "Titel",
-    "message": "Ihre Nachricht",
+    "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.",
+    "saved": "Einstellung gespeichert. Neue Beiträge bleiben standardmäßig anonym.",
+    "allowPublic": "Ich erlaube, diesen Text und den angegebenen Namen nach Prüfung im Nutzungsportal zu veröffentlichen.",
+    "allowMarketing": "Ich erlaube zusätzlich, diese Empfehlung und den angegebenen Namen für Marketing auf der PicPeak-Homepage zu verwenden.",
     "sendFeedback": "Feedback absenden",
-    "feedbackSent": "Feedback erhalten. Eine Veröffentlichung erfordert Ihre Erlaubnis und die Prüfung durch Betreuer.",
+    "feedbackSent": "Feedback erhalten. Eine Veröffentlichung braucht deine Erlaubnis und die Prüfung durch die Betreuer.",
     "states": {
       "disabled": "Teilnahme ist deaktiviert",
       "activation_pending": "Aktivierung ausstehend",
-      "active": "Sie nehmen teil",
+      "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ü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. 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."
+      "disabled": "Es werden keine Produktnutzungsdaten erfasst oder gesendet. Sieh dir die Hinweise an, bevor du dich entscheidest.",
+      "activation_pending": "Die Zustimmung ist gespeichert. Die Registrierung wird wiederholt, sobald ein Admin angemeldet ist oder du „Jetzt senden“ wählst.",
+      "active": "Nur die beschriebenen Funktionssignale werden erfasst. Der Tagesbericht wird gesendet, während ein Admin angemeldet ist.",
+      "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 Collector erreichbar ist.",
+      "identity_conflict": "Diese Installation wurde möglicherweise wiederhergestellt oder kopiert, oder ihre Berichtsfolge passt nicht mehr zum Collector. Berichte sind gestoppt. Deaktiviere und lösche die alte Teilnahme, bevor du mit neuer Identität erneut beitrittst. Dabei 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.",
+    "retryScheduled": "Der nächste automatische Versuch erfolgt um {{time}}. „Jetzt senden“ 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.",
-    "abandonExplanationUnregistered": "Diese Teilnahme wurde vom Collector nie angenommen, dort ist also nichts gespeichert und es gibt nichts zu löschen. Sie können sie hier verwerfen und jederzeit neu beginnen.",
-    "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.",
+    "abandonExplanation": "Die Löschanfrage kann ohne das ursprüngliche Schlüsselmaterial nicht signiert werden. Wenn du es nicht wiederherstellen kannst, 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.",
+    "abandonExplanationUnregistered": "Diese Teilnahme wurde vom Collector nie angenommen, dort ist also nichts gespeichert und es gibt nichts zu löschen. Du kannst sie hier verwerfen und jederzeit neu beginnen.",
+    "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.",
     "abandonConfirmUnregistered": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector hat diese Teilnahme nie angenommen, es wird also nirgendwo sonst etwas entfernt. Danach ist eine neue Teilnahme wieder möglich.",
     "auditPreviousParticipation": "Löschbestätigungen beziehen sich auf eine frühere Teilnahme, nicht auf die aktuelle.",
     "inventoryTitle": "Zwei Bestandszahlen",
-    "inventoryExplanation": "Die Gesamtzahlen beschreiben aktuell gespeicherte Datenbankeinträge, keine Upload-Aktivität oder eindeutigen Dateien. Vorschaubilder und Videos zählen nicht zur Fotoanzahl. Nur diese beiden Zahlen verlassen die Installation.",
+    "inventoryExplanation": "Die Gesamtzahlen beschreiben aktuell gespeicherte Datenbankeinträge, keine Upload-Aktivität und keine eindeutigen Dateien. Vorschaubilder und Videos zählen nicht zur Fotoanzahl. Nur diese beiden Zahlen verlassen die Installation.",
     "inventory": {
       "galleries": {
         "name": "Gespeicherte Galerien",