From 8d0c32902dd78324286a7585188d0c292692c8ec Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 8 Sep 2026 15:58:24 +0200 Subject: [PATCH 1/3] feat(setup): add anonymous usage-reporting opt-in to the first-run wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a step between the SMTP/site-URL config step and the final thank-you screen, asking whether to participate in the existing Product usage & feedback reporting. Kept short — three points on how it differs from typical telemetry (one-way only, no personal data, and participants can browse the same shared feature-adoption dataset other installations report) plus an explicit consent checkbox — and calls the same enable() endpoint the post-install Settings page uses. Skipping does nothing; participation can be toggled anytime from Settings → Product usage. --- frontend/src/i18n/locales/de.json | 15 +++++ frontend/src/i18n/locales/en.json | 15 +++++ frontend/src/pages/SetupPage.tsx | 98 +++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index cff4c6c8..551fc184 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -5043,6 +5043,21 @@ "saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen.", "deleteFailed": "Eine Löschung ist fehlgeschlagen — die Liste wurde neu geladen. Mitgelieferte Arten können nur hier gelöscht werden; versuchen Sie es erneut oder fahren Sie mit ihnen fort." }, + "usageReporting": { + "subtitle": "Noch eine Sache", + "intro": "Möchten Sie helfen, die Weiterentwicklung von PicPeak mitzugestalten? Die anonyme Nutzungsstatistik ist freiwillig — und funktioniert anders als das Tracking, das Sie sonst gewohnt sind abzulehnen.", + "oneWayTitle": "Anders als Google Analytics & Co.", + "oneWayDesc": "Ein reiner Einwegkanal: PicPeak sendet signierte Berichte, kann aber niemals Befehle zurückempfangen. Keine Fotos, Namen, E-Mails oder Klickverfolgung — niemals.", + "mutualTitle": "Sehen, was alle anderen nutzen", + "mutualDesc": "Als Teilnehmer sehen Sie denselben geteilten Datensatz — welche Funktionen andere Installationen wirklich verwenden, nicht nur das, was Sie selbst senden.", + "feedbackTitle": "Feedback direkt teilen", + "feedbackDesc": "Senden Sie kurzes Feedback oder Funktionswünsche direkt an den Entwickler — ganz einfach aus den Einstellungen.", + "consentCheck": "Ich bin einverstanden — jederzeit wieder abschaltbar unter Einstellungen → Produktnutzung.", + "enable": "Ja, ich möchte teilnehmen", + "skip": "Vielleicht später", + "enabled": "Danke für Ihre Teilnahme!", + "enableFailed": "Konnte gerade nicht aktiviert werden — Sie können es jederzeit unter Einstellungen → Produktnutzung erneut versuchen." + }, "community": { "subtitle": "Alles bereit", "mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass Sie es ausprobieren.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index aaa4ddc4..8f4b087d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4925,6 +4925,21 @@ "saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types.", "deleteFailed": "A deletion failed — the list has been reloaded. Built-in types can only be deleted here, so retry or continue with them kept." }, + "usageReporting": { + "subtitle": "One more thing", + "intro": "Want to help shape what PicPeak builds next? Anonymous usage reporting is optional, and it works differently from the analytics you're used to declining.", + "oneWayTitle": "Not like Google Analytics", + "oneWayDesc": "A one-way channel only: PicPeak sends signed reports but can never receive commands back. No photos, names, emails or click tracking — ever.", + "mutualTitle": "See what everyone else uses", + "mutualDesc": "As a participant, you can browse the same shared dataset — which features other installations actually use, not just what you send.", + "feedbackTitle": "Share feedback directly", + "feedbackDesc": "Send quick feedback or feature requests straight to the maintainer, right from Settings.", + "consentCheck": "I agree to participate — I can turn this off anytime in Settings → Product usage.", + "enable": "Yes, I'll participate", + "skip": "Maybe later", + "enabled": "Thanks for participating!", + "enableFailed": "Couldn't enable it just now — you can try again anytime in Settings → Product usage." + }, "community": { "subtitle": "You're all set", "mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index c5c35eea..8d186d00 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee } from 'lucide-react'; +import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee, ShieldOff, Users, MessageSquare } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; @@ -11,6 +11,7 @@ import { useAdminAuth } from '../contexts'; import { setupService } from '../services/setup.service'; import { settingsService } from '../services/settings.service'; import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service'; +import { productUsageService } from '../services/productUsage.service'; import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard'; import { SetupConfigStep } from '../components/admin/SetupConfigStep'; import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep'; @@ -36,6 +37,17 @@ const COMMUNITY_LINKS: { { key: 'support', href: 'https://www.buymeacoffee.com/theluap', icon: Coffee }, ]; +// Anonymous usage-reporting opt-in, one step before the final thank-you +// screen. Kept deliberately short — most people reflexively decline "send us +// data" prompts, so this leads with what makes PicPeak's reporting different +// from typical analytics rather than repeating the full disclosure the +// Settings → Product usage tab already shows in detail. +const USAGE_REPORTING_POINTS: { key: string; icon: LucideIcon }[] = [ + { key: 'oneWay', icon: ShieldOff }, + { key: 'mutual', icon: Users }, + { key: 'feedback', icon: MessageSquare }, +]; + // "How will you use PicPeak?" — the opt-in feature groups shown after the admin // account is created. galleries/analytics/userManagement are always on and not // listed. Labels/descriptions reuse the existing Settings→Features i18n keys @@ -70,7 +82,7 @@ export const SetupPage: React.FC = () => { staleTime: Infinity, }); - const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'community'>('token'); + const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'usageReporting' | 'community'>('token'); const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -79,6 +91,8 @@ export const SetupPage: React.FC = () => { const [errors, setErrors] = useState>({}); const [selectedFeatures, setSelectedFeatures] = useState>(new Set()); const [isSavingFeatures, setIsSavingFeatures] = useState(false); + const [usageReportingConsent, setUsageReportingConsent] = useState(false); + const [isEnablingUsageReporting, setIsEnablingUsageReporting] = useState(false); if (statusLoading) { return ; @@ -275,6 +289,22 @@ export const SetupPage: React.FC = () => { setStep('config'); }; + // Best-effort, same as the feature-flag save above: a collector hiccup on a + // fresh install must not trap the admin here. They can always opt in later + // from Settings → Product usage, where the full disclosure lives. + const enableUsageReporting = async () => { + setIsEnablingUsageReporting(true); + try { + await productUsageService.enable(); + toast.success(t('setup.usageReporting.enabled')); + } catch (_) { + toast.warn(t('setup.usageReporting.enableFailed')); + } finally { + setIsEnablingUsageReporting(false); + setStep('community'); + } + }; + const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; return ( @@ -305,9 +335,11 @@ export const SetupPage: React.FC = () => { ? t('setup.restoreStepSubtitle') : step === 'config' ? t('setup.config.subtitle') - : step === 'community' - ? t('setup.community.subtitle') - : t('setup.usageSubtitle')} + : step === 'usageReporting' + ? t('setup.usageReporting.subtitle') + : step === 'community' + ? t('setup.community.subtitle') + : t('setup.usageSubtitle')}

{(step === 'token' || step === 'account' || step === 'usage') && (

@@ -537,8 +569,62 @@ export const SetupPage: React.FC = () => { ) : step === 'config' ? ( setStep('community')} + onDone={() => setStep('usageReporting')} /> + ) : step === 'usageReporting' ? ( +

+

{t('setup.usageReporting.intro')}

+ +
+ {USAGE_REPORTING_POINTS.map(({ key, icon: Icon }) => ( +
+ + + + {t(`setup.usageReporting.${key}Title`)} + + + {t(`setup.usageReporting.${key}Desc`)} + + +
+ ))} +
+ + + +
+ + +
+
) : (

{t('setup.community.mission')}

From 9168bdd5048b4419db7b0f4444375f54c06f5bbb Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:14:37 +0200 Subject: [PATCH 2/3] fix(setup): require the full usage reporting disclosure --- .../components/ProductUsageConsentDialog.tsx | 165 +++++++++++++++++ .../settings/tabs/ProductUsageTab.tsx | 173 +----------------- frontend/src/i18n/locales/de.json | 8 +- frontend/src/i18n/locales/en.json | 8 +- frontend/src/pages/SetupPage.tsx | 47 +++-- .../SetupPage.usageReporting.test.tsx | 114 ++++++++++++ 6 files changed, 318 insertions(+), 197 deletions(-) create mode 100644 frontend/src/features/settings/components/ProductUsageConsentDialog.tsx create mode 100644 frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx diff --git a/frontend/src/features/settings/components/ProductUsageConsentDialog.tsx b/frontend/src/features/settings/components/ProductUsageConsentDialog.tsx new file mode 100644 index 00000000..3b595205 --- /dev/null +++ b/frontend/src/features/settings/components/ProductUsageConsentDialog.tsx @@ -0,0 +1,165 @@ +import { useEffect, useId, useRef, useState, type ComponentType } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ArrowUpFromLine, Globe, ListChecks, MessageSquare, Send, ShieldOff, Sparkles, Trash2 } from 'lucide-react'; +import { Button } from '../../../components/common/Button'; +import { UsageCatalog } from '../UsageCatalog'; + +/** + * Sections of the disclosure, in reading order. Each is a translated + * paragraph; the heading and icon give it a shape you can scan instead of + * seven identical blocks of prose. + */ +const DISCLOSURE: { + key: string; + heading: string; + Icon: ComponentType<{ className?: string }>; +}[] = [ + { key: 'fields', heading: 'sectionFields', Icon: ListChecks }, + { key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff }, + { key: 'transport', heading: 'sectionTransport', Icon: Send }, + // Directly after transport, because it is a property of the transport and + // the reason the transport is shaped this way: the connection only ever + // runs outwards, so this cannot become a way to push anything in. + { key: 'oneWay', heading: 'sectionOneWay', Icon: ArrowUpFromLine }, + { key: 'visibility', heading: 'sectionVisibility', Icon: Globe }, + { key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 }, + { key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare } +]; + +export function ProductUsageConsentDialog({ + close, + enable, + busy, + collector, + upgrade = false +}: { + close: () => void; + enable: () => void; + busy: boolean; + collector: string; + upgrade?: boolean; +}) { + const { t } = useTranslation(); + const ref = useRef(null); + const titleId = useId(); + const [checked, setChecked] = useState(false); + useEffect(() => { + // React unmounts this on close rather than only closing it, so + // the focus restoration showModal() normally performs has nothing left to + // return to and focus drops to — 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 + // dialog opened, and because the dialog clips its sides an inset ring + // reads as two coloured bars across the disclosure rather than a ring. + // 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 ( + { + event.preventDefault(); + if (!busy) close(); + }} + tabIndex={-1} + aria-labelledby={titleId} + // Column layout with its own scroll region, so the title stays put and + // the actions never scroll out of reach on a short screen. + // + // Surface is class-driven rather than `bg-theme-surface`: that variable + // does not follow dark mode, so it stayed white while the dark: text + // variants below turned near-white. neutral-800 is what `.card` + // resolves to in dark, which is what the rest of the admin UI uses. + className="w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden rounded-xl p-0 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 shadow-xl backdrop:bg-black/50 focus:outline-none" + > +
+ + + +
+

+ {t('productUsage.consentTitle')} +

+

+ {t('productUsage.purpose')} +

+
+
+ + {/* A scrollable region is focusable, which is correct for keyboard use — + but unstyled it drew a default ring that made the disclosure look + like a textarea. Given a real label and ring so it reads as what it + is: a document you can scroll. */} +
+ {DISCLOSURE.map(({ key, heading, Icon }) => ( +
+

+ + {t(`productUsage.${heading}`)} +

+

+ {t(`productUsage.${key}`, { collector })} +

+
+ ))} +

{t('productUsage.versionDisclosure')}

+ + + +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index bf9b1687..d633db4d 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -1,46 +1,15 @@ -import { useEffect, useRef, useState, type ComponentType } from 'react'; +import { 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 { - ArrowUpFromLine, - ExternalLink, - Globe, - ListChecks, - MessageSquare, - Send, - ShieldOff, - Sparkles, - Trash2 -} from 'lucide-react'; +import { ExternalLink } from 'lucide-react'; import { useConfirm } from '../../../components/common/ConfirmDialog'; import { Button, Card } from '../../../components/common'; import { UsageCatalog } from '../UsageCatalog'; - -/** - * Sections of the disclosure, in reading order. Each is a translated - * paragraph; the heading and icon give it a shape you can scan instead of - * seven identical blocks of prose. - */ -const DISCLOSURE: { - key: string; - heading: string; - Icon: ComponentType<{ className?: string }>; -}[] = [ - { key: 'fields', heading: 'sectionFields', Icon: ListChecks }, - { key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff }, - { key: 'transport', heading: 'sectionTransport', Icon: Send }, - // Directly after transport, because it is a property of the transport and - // the reason the transport is shaped this way: the connection only ever - // runs outwards, so this cannot become a way to push anything in. - { key: 'oneWay', heading: 'sectionOneWay', Icon: ArrowUpFromLine }, - { key: 'visibility', heading: 'sectionVisibility', Icon: Globe }, - { key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 }, - { key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare } -]; +import { ProductUsageConsentDialog } from '../components/ProductUsageConsentDialog'; // `.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 @@ -49,140 +18,6 @@ const DISCLOSURE: { // 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, - busy, - collector, - upgrade = false -}: { - close: () => void; - enable: () => void; - busy: boolean; - collector: string; - upgrade?: boolean; -}) { - const { t } = useTranslation(); - const ref = useRef(null); - const [checked, setChecked] = useState(false); - useEffect(() => { - // React unmounts this on close rather than only closing it, so - // the focus restoration showModal() normally performs has nothing left to - // return to and focus drops to — 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 - // dialog opened, and because the dialog clips its sides an inset ring - // reads as two coloured bars across the disclosure rather than a ring. - // 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 ( - -
- - - -
- -

- {t('productUsage.purpose')} -

-
-
- - {/* A scrollable region is focusable, which is correct for keyboard use — - but unstyled it drew a default ring that made the disclosure look - like a textarea. Given a real label and ring so it reads as what it - is: a document you can scroll. */} -
- {DISCLOSURE.map(({ key, heading, Icon }) => ( -
-

- - {t(`productUsage.${heading}`)} -

-

- {t(`productUsage.${key}`, { collector })} -

-
- ))} -

{t('productUsage.versionDisclosure')}

- - - -
- -
- -
- - -
-
-
- ); -} - export default function ProductUsageTab() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -726,7 +561,7 @@ export default function ProductUsageTab() { )} {message &&

{message}

} {consent && ( - { const [errors, setErrors] = useState>({}); const [selectedFeatures, setSelectedFeatures] = useState>(new Set()); const [isSavingFeatures, setIsSavingFeatures] = useState(false); - const [usageReportingConsent, setUsageReportingConsent] = useState(false); + const [showUsageConsent, setShowUsageConsent] = useState(false); const [isEnablingUsageReporting, setIsEnablingUsageReporting] = useState(false); + const { data: usageStatus, isError: usageStatusError } = useQuery({ + queryKey: ['productUsage'], + queryFn: productUsageService.status, + enabled: step === 'usageReporting', + retry: false, + }); if (statusLoading) { return ; @@ -205,7 +210,7 @@ export const SetupPage: React.FC = () => { general_site_url: window.location.origin.replace(/\/+$/, ''), }); } - } catch (_) { /* the config step offers the field again */ } + } catch { /* the config step offers the field again */ } toast.success(t('setup.success')); // Admin now exists and we're logged in (cookie set) — advance to the // opt-in "How will you use PicPeak?" step rather than jumping straight to @@ -268,7 +273,7 @@ export const SetupPage: React.FC = () => { const flags: Partial = {}; for (const key of ALL_USAGE_FEATURES) flags[key] = selectedFeatures.has(key); await featureFlagsService.update(flags); - } catch (_) { + } catch { toast.warn(t('setup.featuresSaveFailed')); } finally { setIsSavingFeatures(false); @@ -297,7 +302,7 @@ export const SetupPage: React.FC = () => { try { await productUsageService.enable(); toast.success(t('setup.usageReporting.enabled')); - } catch (_) { + } catch { toast.warn(t('setup.usageReporting.enableFailed')); } finally { setIsEnablingUsageReporting(false); @@ -591,15 +596,9 @@ export const SetupPage: React.FC = () => { ))}
- + {(usageStatusError || usageStatus?.collector_error) && ( +

{t('setup.usageReporting.enableFailed')}

+ )}
+ {showUsageConsent && usageStatus?.collector_url && ( + setShowUsageConsent(false)} + enable={enableUsageReporting} + /> + )} ) : (
diff --git a/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx b/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx new file mode 100644 index 00000000..0b23a258 --- /dev/null +++ b/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx @@ -0,0 +1,114 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { SetupPage } from '../SetupPage'; +import { productUsageService as usage } from '../../services/productUsage.service'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), + initReactI18next: { type: '3rdParty', init: () => {} }, +})); +vi.mock('../../contexts', () => ({ useAdminAuth: () => ({ login: vi.fn() }) })); +vi.mock('../../services/setup.service', () => ({ setupService: { + getSetupStatus: vi.fn().mockResolvedValue({ needsAdmin: true }), + verifyToken: vi.fn().mockResolvedValue({}), + createInitialAdmin: vi.fn().mockResolvedValue({ user: { + id: 1, username: 'owner', email: 'owner@example.test', role: { name: 'super_admin' }, + } }), + completeSetup: vi.fn().mockResolvedValue({}), +} })); +vi.mock('../../services/settings.service', () => ({ settingsService: { + getSettingsByType: vi.fn().mockResolvedValue({ general_site_url: 'https://picpeak.example.test' }), +} })); +vi.mock('../../services/featureFlags.service', () => ({ featureFlagsService: { + update: vi.fn().mockResolvedValue({}), +} })); +vi.mock('../../services/productUsage.service', () => ({ productUsageService: { + status: vi.fn(), enable: vi.fn(), promptSeen: vi.fn().mockResolvedValue({}), +} })); +vi.mock('../../components/admin/PicpeakBackupCard', () => ({ PicpeakRestoreCard: () => null })); +vi.mock('../../components/admin/SetupEventTypesStep', () => ({ + SetupEventTypesStep: ({ onDone }: { onDone: () => void }) => , +})); +vi.mock('../../components/admin/SetupConfigStep', () => ({ + SetupConfigStep: ({ onDone }: { onDone: () => void }) => , +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(usage.status).mockResolvedValue({ status: 'disabled', collector_url: 'https://custom-collector.example.test' } as never); + vi.mocked(usage.enable).mockResolvedValue({ status: 'active' } as never); + HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', ''); }; +}); +afterEach(cleanup); + +async function reachInvitation() { + render( + + ); + fireEvent.change(await screen.findByLabelText('setup.tokenLabel'), { target: { value: 'test-token' } }); + expect(usage.status).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'setup.continue' })); + fireEvent.change(await screen.findByLabelText('setup.emailLabel'), { target: { value: 'owner@example.test' } }); + fireEvent.change(screen.getByLabelText('setup.passwordLabel'), { target: { value: 'Review-test-password1' } }); + fireEvent.change(screen.getByLabelText('setup.confirmLabel'), { target: { value: 'Review-test-password1' } }); + fireEvent.click(screen.getByRole('button', { name: 'setup.submit' })); + fireEvent.click(await screen.findByRole('button', { name: 'setup.usageSkip' })); + fireEvent.click(await screen.findByText('Finish event types')); + fireEvent.click(await screen.findByText('Finish configuration')); + await waitFor(() => expect(usage.status).toHaveBeenCalled()); + return screen.getByRole('button', { name: 'productUsage.review' }); +} + +it('uses the full settings disclosure and configured collector before accepting fresh consent', async () => { + const review = await reachInvitation(); + await waitFor(() => expect(review).toBeEnabled()); + expect(usage.enable).not.toHaveBeenCalled(); + fireEvent.click(review); + let dialog = within(screen.getByRole('dialog')); + for (const key of ['fields', 'visibility', 'deletion', 'versionDisclosure', 'catalogTitle']) { + expect(dialog.getByText(`productUsage.${key}`)).toBeInTheDocument(); + } + expect(dialog.getByRole('link', { name: 'productUsage.linkCollector' })).toHaveAttribute('href', 'https://custom-collector.example.test'); + expect(dialog.getByRole('button', { name: 'productUsage.enable' })).toBeDisabled(); + fireEvent.click(dialog.getByRole('checkbox')); + fireEvent.click(dialog.getByRole('button', { name: 'productUsage.cancel' })); + expect(usage.enable).not.toHaveBeenCalled(); + fireEvent.click(review); + dialog = within(screen.getByRole('dialog')); + expect(dialog.getByRole('checkbox')).not.toBeChecked(); + fireEvent.click(dialog.getByRole('checkbox')); + fireEvent.click(dialog.getByRole('button', { name: 'productUsage.enable' })); + await screen.findByText('setup.community.mission'); + expect(usage.enable).toHaveBeenCalledTimes(1); +}); + +it('skipping the invitation never enables reporting', async () => { + await reachInvitation(); + fireEvent.click(screen.getByRole('button', { name: 'setup.usageReporting.skip' })); + await screen.findByText('setup.community.mission'); + expect(usage.enable).not.toHaveBeenCalled(); +}); + +it.each(['failed', 'invalid'])('keeps setup usable when collector configuration is %s', async (failure) => { + if (failure === 'failed') vi.mocked(usage.status).mockRejectedValue(new Error('Status unavailable')); + else vi.mocked(usage.status).mockResolvedValue({ status: 'disabled', collector_url: null, collector_error: 'INVALID_COLLECTOR_URL' } as never); + const review = await reachInvitation(); + await screen.findByRole('alert'); + expect(review).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'setup.usageReporting.skip' })); + await screen.findByText('setup.community.mission'); + expect(usage.enable).not.toHaveBeenCalled(); +}); + +it('an enable failure cannot prevent finishing setup', async () => { + vi.mocked(usage.enable).mockRejectedValue(new Error('Enable unavailable')); + const review = await reachInvitation(); + await waitFor(() => expect(review).toBeEnabled()); + fireEvent.click(review); + const dialog = within(screen.getByRole('dialog')); + fireEvent.click(dialog.getByRole('checkbox')); + fireEvent.click(dialog.getByRole('button', { name: 'productUsage.enable' })); + await screen.findByText('setup.community.mission'); +}); From a5f7b38e02f20e66047431c5cb04e6f34c60c689 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:25:20 +0200 Subject: [PATCH 3/3] fix(setup): refresh usage state after accepting consent --- frontend/src/pages/SetupPage.tsx | 5 +++-- .../src/pages/__tests__/SetupPage.usageReporting.test.tsx | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 329fe89a..515c92b1 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee, ShieldOff, Users, MessageSquare } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { toast } from 'react-toastify'; @@ -73,6 +73,7 @@ export const SetupPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { login } = useAdminAuth(); + const queryClient = useQueryClient(); const { data: status, isLoading: statusLoading, isError: statusError } = useQuery({ queryKey: ['setup-status'], @@ -300,7 +301,7 @@ export const SetupPage: React.FC = () => { const enableUsageReporting = async () => { setIsEnablingUsageReporting(true); try { - await productUsageService.enable(); + queryClient.setQueryData(['productUsage'], await productUsageService.enable()); toast.success(t('setup.usageReporting.enabled')); } catch { toast.warn(t('setup.usageReporting.enableFailed')); diff --git a/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx b/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx index 0b23a258..bdd7798b 100644 --- a/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx +++ b/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx @@ -43,8 +43,10 @@ beforeEach(() => { }); afterEach(cleanup); +let client: QueryClient; async function reachInvitation() { - render( + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( ); fireEvent.change(await screen.findByLabelText('setup.tokenLabel'), { target: { value: 'test-token' } }); @@ -82,6 +84,7 @@ it('uses the full settings disclosure and configured collector before accepting fireEvent.click(dialog.getByRole('button', { name: 'productUsage.enable' })); await screen.findByText('setup.community.mission'); expect(usage.enable).toHaveBeenCalledTimes(1); + expect(client.getQueryData(['productUsage'])).toMatchObject({ status: 'active' }); }); it('skipping the invitation never enables reporting', async () => {