fix(setup): require the full usage reporting disclosure
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 { 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}
|
||||
|
||||
Reference in New Issue
Block a user