Merge setup consent fixes from PR 1360
# Conflicts: # frontend/src/pages/SetupPage.tsx
This commit is contained in:
@@ -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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
productUsageService as service,
|
productUsageService as service,
|
||||||
type ProductFeedback
|
type ProductFeedback
|
||||||
} from '../../../services/productUsage.service';
|
} from '../../../services/productUsage.service';
|
||||||
import {
|
import { ExternalLink } from 'lucide-react';
|
||||||
ArrowUpFromLine,
|
|
||||||
ExternalLink,
|
|
||||||
Globe,
|
|
||||||
ListChecks,
|
|
||||||
MessageSquare,
|
|
||||||
Send,
|
|
||||||
ShieldOff,
|
|
||||||
Sparkles,
|
|
||||||
Trash2
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useConfirm } from '../../../components/common/ConfirmDialog';
|
import { useConfirm } from '../../../components/common/ConfirmDialog';
|
||||||
import { Button, Card } from '../../../components/common';
|
import { Button, Card } from '../../../components/common';
|
||||||
import { UsageCatalog } from '../UsageCatalog';
|
import { UsageCatalog } from '../UsageCatalog';
|
||||||
|
import { ProductUsageConsentDialog } from '../components/ProductUsageConsentDialog';
|
||||||
/**
|
|
||||||
* 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 }
|
|
||||||
];
|
|
||||||
|
|
||||||
// `.btn` is whitespace-nowrap and `.btn-md` a fixed 2.5rem tall — right for
|
// `.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
|
// 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.
|
// 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]';
|
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() {
|
export default function ProductUsageTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -726,7 +561,7 @@ export default function ProductUsageTab() {
|
|||||||
)}
|
)}
|
||||||
{message && <p role="status">{message}</p>}
|
{message && <p role="status">{message}</p>}
|
||||||
{consent && (
|
{consent && (
|
||||||
<ConsentDialog
|
<ProductUsageConsentDialog
|
||||||
upgrade={active}
|
upgrade={active}
|
||||||
collector={data.collector_url ?? ''}
|
collector={data.collector_url ?? ''}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
|||||||
@@ -5049,11 +5049,11 @@
|
|||||||
},
|
},
|
||||||
"usageReporting": {
|
"usageReporting": {
|
||||||
"subtitle": "Noch eine Sache",
|
"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.",
|
"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": "Anders als Google Analytics & Co.",
|
"oneWayTitle": "Die Berichterstattung bleibt unter Ihrer Kontrolle",
|
||||||
"oneWayDesc": "Ein reiner Einwegkanal: PicPeak sendet signierte Berichte, kann aber niemals Befehle zurückempfangen. Keine Fotos, Namen, E-Mails oder Klickverfolgung — niemals.",
|
"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",
|
"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.",
|
"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",
|
"feedbackTitle": "Feedback direkt teilen",
|
||||||
"feedbackDesc": "Senden Sie kurzes Feedback oder Funktionswünsche direkt an den Entwickler — ganz einfach aus den Einstellungen.",
|
"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.",
|
"consentCheck": "Ich bin einverstanden — jederzeit wieder abschaltbar unter Einstellungen → Produktnutzung.",
|
||||||
|
|||||||
@@ -4931,11 +4931,11 @@
|
|||||||
},
|
},
|
||||||
"usageReporting": {
|
"usageReporting": {
|
||||||
"subtitle": "One more thing",
|
"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.",
|
"intro": "Help shape PicPeak with optional product usage reports. Reports use a pseudonymous installation ID; review exactly what is shared before deciding.",
|
||||||
"oneWayTitle": "Not like Google Analytics",
|
"oneWayTitle": "Reporting stays under your control",
|
||||||
"oneWayDesc": "A one-way channel only: PicPeak sends signed reports but can never receive commands back. No photos, names, emails or click tracking — ever.",
|
"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",
|
"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.",
|
"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",
|
"feedbackTitle": "Share feedback directly",
|
||||||
"feedbackDesc": "Send quick feedback or feature requests straight to the maintainer, right from Settings.",
|
"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.",
|
"consentCheck": "I agree to participate — I can turn this off anytime in Settings → Product usage.",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { setupService } from '../services/setup.service';
|
|||||||
import { settingsService } from '../services/settings.service';
|
import { settingsService } from '../services/settings.service';
|
||||||
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
||||||
import { productUsageService } from '../services/productUsage.service';
|
import { productUsageService } from '../services/productUsage.service';
|
||||||
|
import { ProductUsageConsentDialog } from '../features/settings/components/ProductUsageConsentDialog';
|
||||||
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
||||||
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
||||||
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
|
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
|
||||||
@@ -81,8 +82,14 @@ export const SetupPage: React.FC = () => {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [selectedFeatures, setSelectedFeatures] = useState<Set<FeatureKey>>(new Set());
|
const [selectedFeatures, setSelectedFeatures] = useState<Set<FeatureKey>>(new Set());
|
||||||
const [isSavingFeatures, setIsSavingFeatures] = useState(false);
|
const [isSavingFeatures, setIsSavingFeatures] = useState(false);
|
||||||
const [usageReportingConsent, setUsageReportingConsent] = useState(false);
|
const [showUsageConsent, setShowUsageConsent] = useState(false);
|
||||||
const [isEnablingUsageReporting, setIsEnablingUsageReporting] = 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) {
|
if (statusLoading) {
|
||||||
return <Loading fullScreen />;
|
return <Loading fullScreen />;
|
||||||
@@ -195,7 +202,7 @@ export const SetupPage: React.FC = () => {
|
|||||||
general_site_url: window.location.origin.replace(/\/+$/, ''),
|
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'));
|
toast.success(t('setup.success'));
|
||||||
// Admin now exists and we're logged in (cookie set) — advance to the
|
// 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
|
// opt-in "How will you use PicPeak?" step rather than jumping straight to
|
||||||
@@ -258,7 +265,7 @@ export const SetupPage: React.FC = () => {
|
|||||||
const flags: Partial<FeatureFlags> = {};
|
const flags: Partial<FeatureFlags> = {};
|
||||||
for (const key of ALL_USAGE_FEATURES) flags[key] = selectedFeatures.has(key);
|
for (const key of ALL_USAGE_FEATURES) flags[key] = selectedFeatures.has(key);
|
||||||
await featureFlagsService.update(flags);
|
await featureFlagsService.update(flags);
|
||||||
} catch (_) {
|
} catch {
|
||||||
toast.warn(t('setup.featuresSaveFailed'));
|
toast.warn(t('setup.featuresSaveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingFeatures(false);
|
setIsSavingFeatures(false);
|
||||||
@@ -287,7 +294,7 @@ export const SetupPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await productUsageService.enable();
|
await productUsageService.enable();
|
||||||
toast.success(t('setup.usageReporting.enabled'));
|
toast.success(t('setup.usageReporting.enabled'));
|
||||||
} catch (_) {
|
} catch {
|
||||||
toast.warn(t('setup.usageReporting.enableFailed'));
|
toast.warn(t('setup.usageReporting.enableFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsEnablingUsageReporting(false);
|
setIsEnablingUsageReporting(false);
|
||||||
@@ -577,15 +584,9 @@ export const SetupPage: React.FC = () => {
|
|||||||
|
|
||||||
<UsageReportingPoints />
|
<UsageReportingPoints />
|
||||||
|
|
||||||
<label className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50 transition-colors">
|
{(usageStatusError || usageStatus?.collector_error) && (
|
||||||
<input
|
<p role="alert" className="text-sm text-neutral-700">{t('setup.usageReporting.enableFailed')}</p>
|
||||||
type="checkbox"
|
)}
|
||||||
className="mt-0.5 h-4 w-4 rounded border-neutral-300"
|
|
||||||
checked={usageReportingConsent}
|
|
||||||
onChange={(e) => setUsageReportingConsent(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-neutral-600">{t('setup.usageReporting.consentCheck')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -594,10 +595,10 @@ export const SetupPage: React.FC = () => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isLoading={isEnablingUsageReporting}
|
isLoading={isEnablingUsageReporting}
|
||||||
disabled={!usageReportingConsent}
|
disabled={!usageStatus?.collector_url}
|
||||||
onClick={enableUsageReporting}
|
onClick={() => setShowUsageConsent(true)}
|
||||||
>
|
>
|
||||||
{t('setup.usageReporting.enable')}
|
{t('productUsage.review')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -610,6 +611,14 @@ export const SetupPage: React.FC = () => {
|
|||||||
{t('setup.usageReporting.skip')}
|
{t('setup.usageReporting.skip')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{showUsageConsent && usageStatus?.collector_url && (
|
||||||
|
<ProductUsageConsentDialog
|
||||||
|
collector={usageStatus.collector_url}
|
||||||
|
busy={isEnablingUsageReporting}
|
||||||
|
close={() => setShowUsageConsent(false)}
|
||||||
|
enable={enableUsageReporting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -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: '[email protected]', 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);
|
||||||
|
|
||||||
|
async function reachInvitation() {
|
||||||
|
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||||
|
<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: '[email protected]' } });
|
||||||
|
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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user