feat: expand opt-in capability coverage with versioned consent

This commit is contained in:
Paul Nothaft
2026-09-06 00:56:58 +02:00
parent 5d31b61c8d
commit a7382591bf
32 changed files with 6250 additions and 164 deletions
@@ -0,0 +1,35 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import catalog from './usageFeatures.v2.json';
/** Local, static disclosure: opening it never contacts the collector. */
export function UsageCatalog() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const entries = Object.entries(catalog.features).filter(([key]) =>
`${key} ${t(`productUsage.catalog.${key}.name`)}`.toLowerCase().includes(search.toLowerCase()));
return (
<details className="rounded border border-theme p-3">
<summary className="cursor-pointer font-semibold">{t('productUsage.catalogTitle')}</summary>
<p className="my-3 text-sm">{t('productUsage.catalogExplanation')}</p>
<label className="block text-sm">
{t('productUsage.catalogSearch')}
<input type="search" value={search} onChange={(e) => setSearch(e.target.value)}
className="my-2 w-full rounded border border-theme bg-theme-surface p-2" />
</label>
<div className="max-h-96 space-y-3 overflow-y-auto" tabIndex={0}>
{entries.map(([key, definition]) => (
<section key={key} className="border-t border-theme pt-2">
<h4 className="font-semibold">{t(`productUsage.catalog.${key}.name`)}</h4>
<p className="text-xs"><code>{key}</code> · {definition.since}</p>
<p className="text-sm">{t('productUsage.configuredLabel')}: {t(`productUsage.catalog.${key}.configured`)}</p>
<p className="text-sm">{definition.used
? `${t('productUsage.usedLabel')}: ${t(`productUsage.catalog.${key}.used`)}`
: t('productUsage.configurationOnly')}</p>
</section>
))}
{!entries.length && <p>{t('productUsage.catalogEmpty')}</p>}
</div>
</details>
);
}
@@ -3,7 +3,8 @@ import {
screen,
fireEvent,
waitFor,
cleanup
cleanup,
within
} from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
@@ -27,6 +28,7 @@ vi.mock('../../../services/productUsage.service', () => ({
productUsageService: {
status: vi.fn(),
enable: vi.fn(),
upgradeConsent: vi.fn(),
disable: vi.fn(),
retry: vi.fn(),
preview: vi.fn(),
@@ -66,6 +68,40 @@ beforeEach(() => {
};
});
afterEach(cleanup);
it('shows every v2 signal locally before participation, without collector calls', async () => {
mount();
await screen.findByText('productUsage.catalogTitle');
expect(screen.getAllByRole('heading', { level: 4, hidden: true })).toHaveLength(73);
expect(service.enable).not.toHaveBeenCalled();
expect(service.preview).not.toHaveBeenCalled();
expect(service.upgradeConsent).not.toHaveBeenCalled();
});
it('existing v1 requires renewed unchecked consent; cancellation keeps v1 unchanged', async () => {
vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: true });
vi.mocked(service.upgradeConsent).mockResolvedValue({ delivered: false, queued: true, state: { ...status, status: 'active', pending_action: 'consent' } });
mount();
fireEvent.click(await screen.findByText('productUsage.reviewUpgrade'));
let dialog = within(screen.getByRole('dialog'));
expect(dialog.getByRole('button', { name: 'productUsage.upgrade' })).toBeDisabled();
expect(dialog.getByRole('checkbox')).not.toBeChecked();
expect(dialog.getByText('productUsage.versionDisclosure')).toBeInTheDocument();
fireEvent.click(dialog.getByRole('button', { name: 'productUsage.cancel' }));
expect(service.upgradeConsent).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('productUsage.reviewUpgrade'));
dialog = within(screen.getByRole('dialog'));
fireEvent.click(dialog.getByRole('checkbox'));
fireEvent.click(dialog.getByRole('button', { name: 'productUsage.upgrade' }));
await waitFor(() => expect(service.upgradeConsent).toHaveBeenCalledTimes(1));
expect(service.enable).not.toHaveBeenCalled();
expect(await screen.findByText('productUsage.queued')).toBeInTheDocument();
});
it('pending v2 confirmation clearly keeps v1 and cannot queue another upgrade', async () => {
vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: true, pending_action: 'consent' });
mount();
expect(await screen.findByText('productUsage.upgradePending')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'productUsage.reviewUpgrade' })).toBeDisabled();
expect(service.upgradeConsent).not.toHaveBeenCalled();
});
describe('product usage controls', () => {
it('offers identity-free audit receipts after opt-out without restoring participation controls', async () => {
vi.mocked(service.status).mockResolvedValue({
@@ -16,6 +16,7 @@ import {
} 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
@@ -39,12 +40,14 @@ function ConsentDialog({
close,
enable,
busy,
collector
collector,
upgrade = false
}: {
close: () => void;
enable: () => void;
busy: boolean;
collector: string;
upgrade?: boolean;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDialogElement>(null);
@@ -112,6 +115,8 @@ function ConsentDialog({
</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
@@ -148,7 +153,7 @@ function ConsentDialog({
{t('productUsage.cancel')}
</Button>
<Button onClick={enable} disabled={!checked || busy}>
{t('productUsage.enable')}
{t(upgrade ? 'productUsage.upgrade' : 'productUsage.enable')}
</Button>
</div>
</footer>
@@ -212,6 +217,16 @@ export default function ProductUsageTab() {
{t(`productUsage.states.${data.status}`)}
</h3>
<p>{t(`productUsage.stateDetails.${data.status}`)}</p>
{data.status !== 'disabled' && <p>{t('productUsage.currentSchema', { schema: data.schema_version })}</p>}
{data.consent_update_available && (
<div className="rounded border border-theme p-3 space-y-2">
<p>{t('productUsage.upgradeExplanation')}</p>
<Button disabled={busy || Boolean(data.pending_action) || !data.collector_url} onClick={() => setConsent(true)}>
{t('productUsage.reviewUpgrade')}
</Button>
</div>
)}
{data.pending_action === 'consent' && <p role="status">{t('productUsage.upgradePending')}</p>}
{data.installation_id && (
<label className="block">
{t('productUsage.hash')}
@@ -300,6 +315,7 @@ export default function ProductUsageTab() {
)}
</div>
</Card>
<UsageCatalog />
{data.privacy_receipts &&
Object.keys(data.privacy_receipts).length > 0 && (
<Card padding="md" className="space-y-4">
@@ -549,12 +565,17 @@ export default function ProductUsageTab() {
{message && <p role="status">{message}</p>}
{consent && (
<ConsentDialog
upgrade={active}
collector={data.collector_url ?? ''}
busy={busy}
close={() => setConsent(false)}
enable={() =>
run(async () => {
await service.enable();
if (active) {
const result = await service.upgradeConsent();
if (!result.delivered) setMessage(t('productUsage.queued'));
setPreview(null);
} else await service.enable();
setConsent(false);
})
}
File diff suppressed because it is too large Load Diff