Merge pull request #1360 from PicPeak/feat/setup-usage-reporting-optin

feat(setup): add product usage consent to the first-run wizard
This commit is contained in:
Paul Nothaft
2026-09-08 19:23:06 +02:00
committed by GitHub
6 changed files with 419 additions and 178 deletions
@@ -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<HTMLDialogElement>(null);
const titleId = useId();
const [checked, setChecked] = useState(false);
useEffect(() => {
// React unmounts this <dialog> on close rather than only closing it, so
// the focus restoration showModal() normally performs has nothing left to
// return to and focus drops to <body> — 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 (
<dialog
ref={ref}
onCancel={(event) => {
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"
>
<header className="flex items-start gap-3 px-6 pt-6 pb-4">
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900/30">
<Sparkles className="h-5 w-5 text-primary-600 dark:text-primary-300" />
</span>
<div className="min-w-0">
<h2
id={titleId}
className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"
>
{t('productUsage.consentTitle')}
</h2>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('productUsage.purpose')}
</p>
</div>
</header>
{/* 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. */}
<div
tabIndex={0}
role="group"
aria-label={t('productUsage.consentTitle') as string}
className="min-h-0 flex-auto overflow-y-auto border-y border-neutral-200 dark:border-neutral-700 px-6 py-4 space-y-4 focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary-400"
>
{DISCLOSURE.map(({ key, heading, Icon }) => (
<section key={key}>
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<Icon className="h-3.5 w-3.5" />
{t(`productUsage.${heading}`)}
</h3>
<p className="mt-1 text-sm text-neutral-700 dark:text-neutral-300">
{t(`productUsage.${key}`, { collector })}
</p>
</section>
))}
<p className="text-sm">{t('productUsage.versionDisclosure')}</p>
<UsageCatalog />
<div className="flex flex-wrap gap-x-6 gap-y-1 pt-1 text-sm">
<a
className="text-primary-600 dark:text-primary-400 hover:underline"
href={collector}
target="_blank"
rel="noreferrer"
>
{t('productUsage.linkCollector')}
</a>
<a
className="text-primary-600 dark:text-primary-400 hover:underline"
href={`${collector}/transparency`}
target="_blank"
rel="noreferrer"
>
{t('productUsage.transparency')}
</a>
</div>
</div>
<footer className="px-6 pt-4 pb-6 space-y-4">
<label className="flex items-start gap-2.5 text-sm text-neutral-800 dark:text-neutral-200">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 flex-none"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
<span>{t('productUsage.consentCheck')}</span>
</label>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={close} disabled={busy}>
{t('productUsage.cancel')}
</Button>
<Button onClick={enable} disabled={!checked || busy || !collector}>
{t(upgrade ? 'productUsage.upgrade' : 'productUsage.enable')}
</Button>
</div>
</footer>
</dialog>
);
}
@@ -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<HTMLDialogElement>(null);
const [checked, setChecked] = useState(false);
useEffect(() => {
// React unmounts this <dialog> on close rather than only closing it, so
// the focus restoration showModal() normally performs has nothing left to
// return to and focus drops to <body> — 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 (
<dialog
ref={ref}
onCancel={close}
tabIndex={-1}
aria-labelledby="usage-consent-title"
// 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 shadow-xl backdrop:bg-black/50 focus:outline-none"
>
<header className="flex items-start gap-3 px-6 pt-6 pb-4">
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900/30">
<Sparkles className="h-5 w-5 text-primary-600 dark:text-primary-300" />
</span>
<div className="min-w-0">
<h2
id="usage-consent-title"
className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"
>
{t('productUsage.consentTitle')}
</h2>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('productUsage.purpose')}
</p>
</div>
</header>
{/* 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. */}
<div
tabIndex={0}
role="group"
aria-label={t('productUsage.consentTitle') as string}
className="min-h-0 flex-auto overflow-y-auto border-y border-neutral-200 dark:border-neutral-700 px-6 py-4 space-y-4 focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary-400"
>
{DISCLOSURE.map(({ key, heading, Icon }) => (
<section key={key}>
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<Icon className="h-3.5 w-3.5" />
{t(`productUsage.${heading}`)}
</h3>
<p className="mt-1 text-sm text-neutral-700 dark:text-neutral-300">
{t(`productUsage.${key}`, { collector })}
</p>
</section>
))}
<p className="text-sm">{t('productUsage.versionDisclosure')}</p>
<UsageCatalog />
<div className="flex flex-wrap gap-x-6 gap-y-1 pt-1 text-sm">
<a
className="text-primary-600 dark:text-primary-400 hover:underline"
href={collector}
target="_blank"
rel="noreferrer"
>
{t('productUsage.linkCollector')}
</a>
<a
className="text-primary-600 dark:text-primary-400 hover:underline"
href={`${collector}/transparency`}
target="_blank"
rel="noreferrer"
>
{t('productUsage.transparency')}
</a>
</div>
</div>
<footer className="px-6 pt-4 pb-6 space-y-4">
<label className="flex items-start gap-2.5 text-sm text-neutral-800 dark:text-neutral-200">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 flex-none"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
<span>{t('productUsage.consentCheck')}</span>
</label>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={close} disabled={busy}>
{t('productUsage.cancel')}
</Button>
<Button onClick={enable} disabled={!checked || busy}>
{t(upgrade ? 'productUsage.upgrade' : 'productUsage.enable')}
</Button>
</div>
</footer>
</dialog>
);
}
export default function ProductUsageTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -726,7 +561,7 @@ export default function ProductUsageTab() {
)}
{message && <p role="status">{message}</p>}
{consent && (
<ConsentDialog
<ProductUsageConsentDialog
upgrade={active}
collector={data.collector_url ?? ''}
busy={busy}
+15
View File
@@ -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": "Helfen Sie, PicPeak mit freiwilligen Produktnutzungsberichten weiterzuentwickeln. Die Berichte verwenden eine pseudonyme Installations-ID. Prüfen Sie vor Ihrer Entscheidung, was geteilt wird.",
"oneWayTitle": "Die Berichterstattung bleibt unter Ihrer Kontrolle",
"oneWayDesc": "PicPeak sendet signierte Nutzungsberichte. Dieser Kanal kann keine Befehle auf Ihrer Installation ausführen. Fotos, Kundennamen, E-Mails und die Verfolgung von Galeriebesuchern sind ausgeschlossen.",
"mutualTitle": "Sehen, was alle anderen nutzen",
"mutualDesc": "Teilnehmer können geteilte Funktionskombinationen und Galerie-/Fotoanzahlen einsehen, auch für Gruppen mit nur einer Installation. Die Einwilligungsinformationen erläutern den vollständigen Umfang.",
"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.",
+15
View File
@@ -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": "Help shape PicPeak with optional product usage reports. Reports use a pseudonymous installation ID; review exactly what is shared before deciding.",
"oneWayTitle": "Reporting stays under your control",
"oneWayDesc": "PicPeak sends signed usage reports. This channel cannot execute commands on your installation. Photos, customer names, emails and gallery visitor tracking are excluded.",
"mutualTitle": "See what everyone else uses",
"mutualDesc": "Participants can inspect shared feature combinations and gallery/photo totals, including groups of one. The consent disclosure explains the complete scope.",
"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.",
+103 -9
View File
@@ -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 { 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';
import { useTranslation } from 'react-i18next';
@@ -11,6 +11,8 @@ 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 { ProductUsageConsentDialog } from '../features/settings/components/ProductUsageConsentDialog';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
@@ -36,6 +38,15 @@ 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. The invitation opens the same complete consent disclosure as
// Settings → Product usage before any reporting can be enabled.
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
@@ -62,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'],
@@ -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<Record<string, string>>({});
const [selectedFeatures, setSelectedFeatures] = useState<Set<FeatureKey>>(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 <Loading fullScreen />;
@@ -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<FeatureFlags> = {};
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')}
</p>
{(step === 'token' || step === 'account' || step === 'usage') && (
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
@@ -537,8 +575,64 @@ export const SetupPage: React.FC = () => {
) : step === 'config' ? (
<SetupConfigStep
selectedFeatures={selectedFeatures}
onDone={() => setStep('community')}
onDone={() => setStep('usageReporting')}
/>
) : step === 'usageReporting' ? (
<div className="space-y-6">
<p className="text-sm text-neutral-700">{t('setup.usageReporting.intro')}</p>
<div className="space-y-2">
{USAGE_REPORTING_POINTS.map(({ key, icon: Icon }) => (
<div key={key} className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3">
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-primary, #5C8762)' }} />
<span className="min-w-0">
<span className="block text-sm font-medium text-neutral-800">
{t(`setup.usageReporting.${key}Title`)}
</span>
<span className="block text-xs text-neutral-500">
{t(`setup.usageReporting.${key}Desc`)}
</span>
</span>
</div>
))}
</div>
{(usageStatusError || usageStatus?.collector_error) && (
<p role="alert" className="text-sm text-neutral-700">{t('setup.usageReporting.enableFailed')}</p>
)}
<div className="space-y-3">
<Button
type="button"
variant="primary"
size="lg"
className="w-full"
isLoading={isEnablingUsageReporting}
disabled={!usageStatus?.collector_url}
onClick={() => setShowUsageConsent(true)}
>
{t('productUsage.review')}
</Button>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
disabled={isEnablingUsageReporting}
onClick={() => setStep('community')}
>
{t('setup.usageReporting.skip')}
</Button>
</div>
{showUsageConsent && usageStatus?.collector_url && (
<ProductUsageConsentDialog
collector={usageStatus.collector_url}
busy={isEnablingUsageReporting}
close={() => setShowUsageConsent(false)}
enable={enableUsageReporting}
/>
)}
</div>
) : (
<div className="space-y-6">
<p className="text-sm text-neutral-700">{t('setup.community.mission')}</p>
@@ -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 }) => <button onClick={onDone}>Finish event types</button>,
}));
vi.mock('../../components/admin/SetupConfigStep', () => ({
SetupConfigStep: ({ onDone }: { onDone: () => void }) => <button onClick={onDone}>Finish configuration</button>,
}));
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(<QueryClientProvider client={client}>
<MemoryRouter><SetupPage /></MemoryRouter>
</QueryClientProvider>);
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');
});