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 { t } = useTranslation(); const navigate = useNavigate(); const { login } = useAdminAuth(); + const queryClient = useQueryClient(); const { data: status, isLoading: statusLoading, isError: statusError } = useQuery({ queryKey: ['setup-status'], @@ -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,14 @@ export const SetupPage: React.FC = () => { const [errors, setErrors] = useState>({}); const [selectedFeatures, setSelectedFeatures] = useState>(new Set()); const [isSavingFeatures, setIsSavingFeatures] = 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 ; @@ -191,7 +211,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 @@ -254,7 +274,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); @@ -275,6 +295,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 { + queryClient.setQueryData(['productUsage'], 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 +341,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 +575,64 @@ 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`)} + + +
+ ))} +
+ + {(usageStatusError || usageStatus?.collector_error) && ( +

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

+ )} + +
+ + +
+ {showUsageConsent && usageStatus?.collector_url && ( + setShowUsageConsent(false)} + enable={enableUsageReporting} + /> + )} +
) : (

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

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..bdd7798b --- /dev/null +++ b/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx @@ -0,0 +1,117 @@ +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); + +let client: QueryClient; +async function reachInvitation() { + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + 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); + expect(client.getQueryData(['productUsage'])).toMatchObject({ status: 'active' }); +}); + +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'); +});