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'); +});