From 7e4057921721919c27c40c6e46c755c5e03d6d86 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 6 Sep 2026 21:12:59 +0200 Subject: [PATCH] feat(usage): plain link to the public usage portal, German opt-in copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only way into the usage portal from the settings tab was the session-bound link behind "Connect", which needs an active participation and creates a 15-minute voting session. An operator deciding whether to join had no way to look at the portal first. The status card now carries a plain "Open usage portal" link to the collector base URL the status endpoint already reports, shown whenever that URL is valid, opening in a new tab with rel="noopener noreferrer". The session link stays as it is. German "Teilnahme prüfen" read as a technical check rather than reviewing the consent details; it is now "Details ansehen" in both places the key is used (notice link and opt-in button). The actual opt-in stays "Produktnutzung aktivieren". Relates to issue 1317 --- .../__tests__/ProductUsageTab.test.tsx | 27 ++++++++++++++++ .../settings/tabs/ProductUsageTab.tsx | 32 ++++++++++++++----- frontend/src/i18n/locales/de.json | 3 +- frontend/src/i18n/locales/en.json | 1 + 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx index 32515507..1a306b3c 100644 --- a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -317,3 +317,30 @@ it('states in the consent dialog that the connection only runs outwards', async await screen.findByText('productUsage.sectionOneWay'); await screen.findByText('productUsage.oneWay'); }); + +// The portal is public. Reaching it must not depend on having joined or on +// the 15-minute voting session that `connect` creates — an operator deciding +// whether to participate should be able to look at it first. +describe('the plain usage portal link', () => { + it('opens the collector in a new tab before participation, without a session', async () => { + mount(); + const link = await screen.findByRole('link', { name: 'productUsage.openUsagePortal' }); + expect(link).toHaveAttribute('href', 'https://usage.picpeak.app'); + 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 () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + collector_url: null, + collector_error: 'INVALID_COLLECTOR_URL' + }); + mount(); + await screen.findByText('productUsage.invalidCollectorUrl'); + expect(screen.queryByRole('link', { name: 'productUsage.openUsagePortal' })).toBeNull(); + }); +}); diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index fc731ed6..fb571e73 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -7,6 +7,7 @@ import { } from '../../../services/productUsage.service'; import { ArrowUpFromLine, + ExternalLink, Globe, ListChecks, MessageSquare, @@ -377,14 +378,29 @@ export default function ProductUsageTab() { )} {data.collector_url && ( - - {t('productUsage.transparency')} - + <> + {/* 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')} + + + {t('productUsage.transparency')} + + )} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 1d450d62..1dfc1e1a 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -435,7 +435,7 @@ "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": "Wenn Sie ignorieren, erscheint dieser Hinweis nicht erneut — Sie können weiterhin unter Einstellungen → Produktnutzung teilnehmen.", - "review": "Teilnahme prüfen", + "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.", @@ -458,6 +458,7 @@ "enable": "Produktnutzung aktivieren", "disable": "Deaktivieren & Daten löschen", "retry": "Erneut versuchen / fälligen Bericht senden", + "openUsagePortal": "Nutzungsportal öffnen", "transparency": "Öffentliches Schema & Datenschutzhinweise", "linkCollector": "Wohin Berichte gesendet werden", "hash": "Ihr vertraulicher Abfrage-Hash", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1b09a0ec..3a953470 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -458,6 +458,7 @@ "enable": "Enable product usage", "disable": "Disable & delete my data", "retry": "Retry / send if due", + "openUsagePortal": "Open usage portal", "transparency": "Read the public schema & privacy details", "linkCollector": "Where reports are sent", "hash": "Your private lookup hash",