feat: expand opt-in capability coverage with versioned consent
This commit is contained in:
@@ -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
Reference in New Issue
Block a user