diff --git a/backend/__tests__/integration/productUsagePg.test.js b/backend/__tests__/integration/productUsagePg.test.js
index 75967d76..6bb9c8fa 100644
--- a/backend/__tests__/integration/productUsagePg.test.js
+++ b/backend/__tests__/integration/productUsagePg.test.js
@@ -121,6 +121,24 @@ maybe('product usage on Postgres', () => {
// back as a STRING — the tick() gate compares it against a number.
expect(cols.attempts).toBeDefined();
expect(cols.next_attempt_at).toBeDefined();
+ expect(cols.prompt_shown).toBeDefined();
+ });
+
+ it('backfills the prompt for existing participation using PostgreSQL booleans', async () => {
+ const migration = require('../../migrations/core/211_product_usage_prompt_shown');
+ await migration.down(db);
+ await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
+ await migration.up(db);
+ await migration.up(db);
+ expect(await service().status()).toMatchObject({ status: 'active', prompt_shown: true, consent_version: 'usage-consent.v2' });
+ await db('product_usage_state').where({ id: 1 }).update({ status: 'disabled' });
+ expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true });
+ });
+
+ it('persists a fresh installation declining without opting in on PostgreSQL', async () => {
+ expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: false });
+ await service().markPromptShown();
+ expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
});
it('reruns the backoff migration safely', async () => {
diff --git a/backend/__tests__/migrations/211_product_usage_prompt_shown.test.js b/backend/__tests__/migrations/211_product_usage_prompt_shown.test.js
new file mode 100644
index 00000000..5e84a28d
--- /dev/null
+++ b/backend/__tests__/migrations/211_product_usage_prompt_shown.test.js
@@ -0,0 +1,80 @@
+const knex = require('knex');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { UsageService } = require('../../src/usage/UsageService');
+const { generateIdentity, digest, canonical } = require('../../src/usage/protocol.cjs');
+const migration = require('../../migrations/core/211_product_usage_prompt_shown');
+
+let db;
+let directory;
+beforeEach(async () => {
+ db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-prompt-test-'));
+ for (const name of [
+ '201_product_usage', '202_product_usage_cancel_requested', '203_product_usage_cancel_seq',
+ '204_product_usage_privacy_receipts', '205_product_usage_consent_version', '206_product_usage_delivery_backoff'
+ ]) await require(`../../migrations/core/${name}`).up(db);
+});
+afterEach(async () => {
+ await db.destroy();
+ fs.rmSync(directory, { recursive: true, force: true });
+});
+
+test.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])(
+ 'preserves an existing %s participation without altering its consent or pending packet', async (status) => {
+ await db('product_usage_state').where({ id: 1 }).update({
+ status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet',
+ });
+ await migration.up(db);
+ await migration.up(db);
+ const state = await db('product_usage_state').where({ id: 1 }).first();
+ expect(state).toMatchObject({ status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet', prompt_shown: 1 });
+ }
+);
+
+test('a previously participating installation stays acknowledged after a confirmed withdrawal', async () => {
+ const actions = [];
+ const service = new UsageService(db, {
+ secret: 'test-only-prompt-encryption-secret-32-characters',
+ endpoint: 'https://collector.example.test',
+ bindingPath: path.join(directory, 'instance.key'),
+ fetch: async (_url, init) => {
+ const { packet } = JSON.parse(init.body);
+ actions.push(packet.action);
+ return new Response(JSON.stringify({
+ packet_id: packet.packet_id, installation_id: packet.installation_id,
+ packet_digest: digest(canonical(packet)), action: packet.action,
+ sequence: packet.sequence, status: 'deleted',
+ }));
+ },
+ });
+ const identity = generateIdentity();
+ await db('product_usage_state').where({ id: 1 }).update({
+ status: 'active', notice_dismissed: 1, consent_version: 'usage-consent.v5', sequence: 1,
+ installation_id: identity.installation_id, public_key: identity.public_key,
+ private_key_encrypted: service.encrypt(identity.private_key), instance_binding: await service.binding(true),
+ });
+ await migration.up(db);
+ const state = await service.disable();
+ expect(actions).toEqual(['delete']);
+ expect(state).toMatchObject({ status: 'disabled', prompt_shown: true, installation_id: null });
+ expect(state.privacy_receipts.last_deletion.status).toBe('collector-confirmed');
+});
+
+test('a fresh installation can decline once without changing consent or the separate banner', async () => {
+ await migration.up(db);
+ const fetch = jest.fn();
+ const service = new UsageService(db, { fetch });
+ expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: false, notice_dismissed: false });
+ await service.markPromptShown();
+ await migration.up(db);
+ expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
+ expect(fetch).not.toHaveBeenCalled();
+});
+
+test('migration guards tolerate a missing table', async () => {
+ await db.schema.dropTable('product_usage_state');
+ await expect(migration.up(db)).resolves.toBeUndefined();
+ await expect(migration.down(db)).resolves.toBeUndefined();
+});
diff --git a/backend/__tests__/routes/adminUsage.test.js b/backend/__tests__/routes/adminUsage.test.js
index de92918e..aae0169c 100644
--- a/backend/__tests__/routes/adminUsage.test.js
+++ b/backend/__tests__/routes/adminUsage.test.js
@@ -29,6 +29,7 @@ jest.mock('../../src/services/productUsageService', () =>
'tick',
'status',
'dismiss',
+ 'markPromptShown',
'enable',
'disable',
'abandon',
@@ -129,6 +130,7 @@ const ROUTES = [
['post', '/abandon'],
['post', '/retry'],
['post', '/dismiss'],
+ ['post', '/prompt-seen'],
['get', '/preview'],
['get', '/export'],
['put', '/feedback-preferences'],
@@ -178,6 +180,15 @@ test('owner sees no-store status and supplies consent to the service', async ()
.expect(200);
expect(service.enable).toHaveBeenCalledWith('usage-consent.v1');
});
+test('only a settings editor can acknowledge the prompt without opting in', async () => {
+ await request(app)
+ .post('/api/admin/usage/prompt-seen')
+ .set('Authorization', `Bearer ${token('admin')}`)
+ .expect('Cache-Control', 'no-store')
+ .expect(200);
+ expect(service.markPromptShown).toHaveBeenCalledTimes(1);
+ expect(service.enable).not.toHaveBeenCalled();
+});
test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => {
const { EventEmitter } = require('events');
const simulate = (path, admin, statusCode) => {
diff --git a/backend/migrations/core/211_product_usage_prompt_shown.js b/backend/migrations/core/211_product_usage_prompt_shown.js
index 584bb031..0a520fe6 100644
--- a/backend/migrations/core/211_product_usage_prompt_shown.js
+++ b/backend/migrations/core/211_product_usage_prompt_shown.js
@@ -5,15 +5,21 @@
// too, so upgraded and brand-new installs share one "already asked" marker
// and neither gets asked twice. Separate from `notice_dismissed`, which
// governs the persistent, re-visitable dashboard banner instead.
+const { formatBoolean } = require('../../src/utils/dbCompat');
+
exports.up = async function (knex) {
- if (
- (await knex.schema.hasTable('product_usage_state')) &&
- !(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))
- ) {
+ if (!(await knex.schema.hasTable('product_usage_state'))) return;
+ if (!(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))) {
await knex.schema.alterTable('product_usage_state', (t) => {
t.boolean('prompt_shown').notNullable().defaultTo(false);
});
}
+ // Existing participants already made their choice before this marker
+ // existed. Preserve it through withdrawal, pending delivery and identity
+ // recovery; none of those transitions should produce a fresh invitation.
+ await knex('product_usage_state')
+ .whereNot('status', 'disabled')
+ .update({ prompt_shown: formatBoolean(true) });
};
exports.down = async function (knex) {
if (
diff --git a/frontend/src/components/admin/UsageReportingPitch.tsx b/frontend/src/components/admin/UsageReportingPitch.tsx
index 59ce7c3e..d77a2a73 100644
--- a/frontend/src/components/admin/UsageReportingPitch.tsx
+++ b/frontend/src/components/admin/UsageReportingPitch.tsx
@@ -2,12 +2,8 @@ import type { LucideIcon } from 'lucide-react';
import { ShieldOff, Users, MessageSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
-// Shared between the setup wizard's opt-in step and the one-time post-update
-// prompt (#1360) so the two surfaces never drift apart. Kept deliberately
-// short — most people reflexively decline "send us data" prompts, so this
-// leads with what makes PicPeak's reporting different from typical analytics
-// rather than repeating the full disclosure the Settings → Product usage tab
-// already shows in detail.
+// Shared invitation copy. Both entry points open the complete consent
+// disclosure before enabling reporting.
export const USAGE_REPORTING_POINTS: { key: string; icon: LucideIcon }[] = [
{ key: 'oneWay', icon: ShieldOff },
{ key: 'mutual', icon: Users },
@@ -19,13 +15,13 @@ export const UsageReportingPoints: React.FC = () => {
return (
{USAGE_REPORTING_POINTS.map(({ key, icon: Icon }) => (
-
+
-
+
{t(`setup.usageReporting.${key}Title`)}
-
+
{t(`setup.usageReporting.${key}Desc`)}
diff --git a/frontend/src/components/admin/UsageReportingPrompt.tsx b/frontend/src/components/admin/UsageReportingPrompt.tsx
index baad8b50..9907b661 100644
--- a/frontend/src/components/admin/UsageReportingPrompt.tsx
+++ b/frontend/src/components/admin/UsageReportingPrompt.tsx
@@ -1,40 +1,48 @@
-import React, { useState } from 'react';
+import { useEffect, useId, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { usePermissions } from '../../contexts/PermissionsContext';
import { productUsageService } from '../../services/productUsage.service';
-import { Button, Card } from '../common';
+import { ProductUsageConsentDialog } from '../../features/settings/components/ProductUsageConsentDialog';
+import { Button } from '../common/Button';
import { UsageReportingPoints } from './UsageReportingPitch';
-/**
- * One-time opt-in prompt for an admin who already had PicPeak installed
- * before this feature existed (#1360). A brand-new install gets the same
- * choice inside the setup wizard instead — both paths call
- * POST /admin/usage/prompt-seen on either outcome, so whichever one an
- * installation went through, this never shows a second time and never shows
- * once participation is already active.
- */
+/** The invitation is acknowledged once per installation; consent is a separate, explicit choice. */
export default function UsageReportingPrompt() {
const { t } = useTranslation();
const { hasPermission } = usePermissions();
const queryClient = useQueryClient();
const [hidden, setHidden] = useState(false);
- const [consent, setConsent] = useState(false);
+ const [showConsent, setShowConsent] = useState(false);
const [isEnabling, setIsEnabling] = useState(false);
+ const ref = useRef(null);
+ const titleId = useId();
const { data } = useQuery({
queryKey: ['productUsage'],
queryFn: productUsageService.status,
enabled: hasPermission('settings.edit'),
});
+ const visible = hasPermission('settings.edit') && !hidden && data?.status === 'disabled' && !data.prompt_shown;
+ useEffect(() => {
+ if (!visible) return;
+ const dialog = ref.current;
+ const opener = document.activeElement as HTMLElement | null;
+ dialog?.showModal();
+ dialog?.focus();
+ return () => {
+ dialog?.close();
+ if (opener?.isConnected) opener.focus();
+ };
+ }, [visible]);
const dismiss = async () => {
setHidden(true);
try {
queryClient.setQueryData(['productUsage'], await productUsageService.promptSeen());
} catch {
- /* Worst case the query refetches stale data and this shows once more. */
+ /* A failed acknowledgement may be offered again on a later visit. */
}
};
@@ -51,57 +59,54 @@ export default function UsageReportingPrompt() {
}
};
- if (!hasPermission('settings.edit') || !data || hidden) return null;
- if (data.status !== 'disabled' || data.prompt_shown) return null;
+ if (!visible || !data) return null;
return (
-
-
-
-
-
- {t('productUsagePrompt.title')}
-
-
{t('productUsagePrompt.intro')}
-
+ <>
+
+
+
+
+ {showConsent && data.collector_url && (
+ setShowConsent(false)}
+ enable={enable}
+ />
+ )}
+ >
);
}
diff --git a/frontend/src/components/admin/__tests__/UsageReportingPrompt.test.tsx b/frontend/src/components/admin/__tests__/UsageReportingPrompt.test.tsx
new file mode 100644
index 00000000..afe83c8f
--- /dev/null
+++ b/frontend/src/components/admin/__tests__/UsageReportingPrompt.test.tsx
@@ -0,0 +1,126 @@
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import UsageReportingPrompt from '../UsageReportingPrompt';
+import { productUsageService as service } from '../../../services/productUsage.service';
+
+const permission = vi.hoisted(() => ({ allowed: true }));
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+ initReactI18next: { type: '3rdParty', init: () => {} },
+}));
+vi.mock('../../../contexts/PermissionsContext', () => ({
+ usePermissions: () => ({ hasPermission: () => permission.allowed }),
+}));
+vi.mock('../../../services/productUsage.service', () => ({ productUsageService: {
+ status: vi.fn(), promptSeen: vi.fn(), enable: vi.fn(),
+} }));
+const status = { status: 'disabled', prompt_shown: false, collector_url: 'https://custom-collector.example.test' };
+beforeEach(() => {
+ vi.clearAllMocks();
+ permission.allowed = true;
+ vi.mocked(service.status).mockResolvedValue({ ...status } as never);
+ vi.mocked(service.promptSeen).mockResolvedValue({ ...status, prompt_shown: true } as never);
+ vi.mocked(service.enable).mockResolvedValue({ ...status, status: 'active', prompt_shown: true } as never);
+ HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', ''); };
+ HTMLDialogElement.prototype.close = function () { this.removeAttribute('open'); };
+});
+afterEach(cleanup);
+
+function mount() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const tree = ;
+ return { ...render(tree), client, tree };
+}
+
+it('focuses an accessible native dialog, acknowledges Escape and restores focus', async () => {
+ const opener = document.createElement('button');
+ document.body.append(opener);
+ opener.focus();
+ try {
+ mount();
+ const dialog = await screen.findByRole('dialog', { name: 'productUsagePrompt.title' });
+ expect(dialog.tagName).toBe('DIALOG');
+ expect(dialog).toHaveFocus();
+ fireEvent(dialog, new Event('cancel', { cancelable: true }));
+ await waitFor(() => expect(service.promptSeen).toHaveBeenCalledTimes(1));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(opener).toHaveFocus();
+ expect(service.enable).not.toHaveBeenCalled();
+ } finally { opener.remove(); }
+});
+
+it('declining survives remount and does not opt in', async () => {
+ const { rerender, tree, client } = mount();
+ fireEvent.click(await screen.findByRole('button', { name: 'setup.usageReporting.skip' }));
+ await waitFor(() => expect(client.getQueryData(['productUsage'])).toEqual({ ...status, prompt_shown: true }));
+ vi.mocked(service.status).mockResolvedValue({ ...status, prompt_shown: true } as never);
+ rerender(<>>);
+ rerender(tree);
+ await waitFor(() => expect(client.isFetching()).toBe(0));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(service.enable).not.toHaveBeenCalled();
+});
+
+it.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])('does not invite a %s installation', async (state) => {
+ vi.mocked(service.status).mockResolvedValue({ ...status, status: state } as never);
+ const { client } = mount();
+ await waitFor(() => expect(client.getQueryData(['productUsage'])).toBeDefined());
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+it('does not query or invite an admin without settings.edit', () => {
+ permission.allowed = false;
+ mount();
+ expect(service.status).not.toHaveBeenCalled();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+it('requires the complete disclosure and a fresh checkbox; cancelling is not consent', async () => {
+ mount();
+ const review = await screen.findByRole('button', { name: 'productUsage.review' });
+ fireEvent.click(review);
+ let consent = within(screen.getByRole('dialog', { name: 'productUsage.consentTitle' }));
+ for (const key of ['fields', 'visibility', 'deletion', 'versionDisclosure', 'catalogTitle']) {
+ expect(consent.getByText(`productUsage.${key}`)).toBeInTheDocument();
+ }
+ expect(consent.getByRole('link', { name: 'productUsage.linkCollector' })).toHaveAttribute('href', status.collector_url);
+ expect(consent.getByRole('button', { name: 'productUsage.enable' })).toBeDisabled();
+ fireEvent.click(consent.getByRole('checkbox'));
+ fireEvent.click(consent.getByRole('button', { name: 'productUsage.cancel' }));
+ expect(service.enable).not.toHaveBeenCalled();
+ expect(service.promptSeen).not.toHaveBeenCalled();
+ fireEvent.click(review);
+ consent = within(screen.getByRole('dialog', { name: 'productUsage.consentTitle' }));
+ expect(consent.getByRole('checkbox')).not.toBeChecked();
+ fireEvent.click(consent.getByRole('checkbox'));
+ fireEvent.click(consent.getByRole('button', { name: 'productUsage.enable' }));
+ await waitFor(() => expect(service.enable).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+});
+
+it('an unavailable collector cannot enable reporting and does not prevent dismissal', async () => {
+ vi.mocked(service.status).mockResolvedValue({ ...status, collector_url: null, collector_error: 'INVALID_COLLECTOR_URL' } as never);
+ mount();
+ expect(await screen.findByRole('button', { name: 'productUsage.review' })).toBeDisabled();
+ fireEvent.click(screen.getByRole('button', { name: 'setup.usageReporting.skip' }));
+ await waitFor(() => expect(service.promptSeen).toHaveBeenCalled());
+ expect(service.enable).not.toHaveBeenCalled();
+});
+
+it('keeps consent open after failure and prevents Escape during an in-flight opt-in', async () => {
+ let rejectEnable!: (error: Error) => void;
+ vi.mocked(service.enable).mockReturnValue(new Promise((_resolve, reject) => { rejectEnable = reject; }));
+ mount();
+ fireEvent.click(await screen.findByRole('button', { name: 'productUsage.review' }));
+ const dialog = screen.getByRole('dialog', { name: 'productUsage.consentTitle' });
+ const consent = within(dialog);
+ fireEvent.click(consent.getByRole('checkbox'));
+ fireEvent.click(consent.getByRole('button', { name: 'productUsage.enable' }));
+ fireEvent(dialog, new Event('cancel', { cancelable: true }));
+ expect(dialog).toBeInTheDocument();
+ expect(consent.getByRole('button', { name: 'productUsage.cancel' })).toBeDisabled();
+ rejectEnable(new Error('Collector unavailable'));
+ await waitFor(() => expect(consent.getByRole('button', { name: 'productUsage.cancel' })).toBeEnabled());
+ expect(service.promptSeen).not.toHaveBeenCalled();
+});
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 95ca70ac..5b22e6df 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1,7 +1,7 @@
{
"productUsagePrompt": {
"title": "PicPeak mitgestalten?",
- "intro": "Diese Installation wurde noch nie nach der anonymen Nutzungsstatistik gefragt. Sie ist freiwillig — und funktioniert anders als das Tracking, das Sie sonst gewohnt sind abzulehnen."
+ "intro": "Helfen Sie, PicPeak mit freiwilligen Produktnutzungsberichten weiterzuentwickeln. Prüfen Sie vor Ihrer Entscheidung, welche pseudonymen Daten geteilt werden, wer sie sehen kann und wohin sie gesendet werden."
},
"productUsage": {
"fields": "usage.v5-Berichte enthalten einen Installationsfingerabdruck, die PicPeak-Version, UTC-Berichtsdatum und Erstellungszeit, Schema- und Signaturmetadaten, die Galerie-Layouts aus einer festen Liste, 87 Funktionssignale (64 Paare aus Konfiguriert und Genutzt, 23 reine Konfigurationswerte) sowie zwei Gesamtzahlen der Installation: gespeicherte Galerien und Fotoeinträge ohne Videos. Entwürfe, archivierte Galerien und deren erhaltene Fotoeinträge zählen mit. Der Katalog unten erklärt jedes Feld. Keine Aktionszähler, keine Beobachtung von Besuchern.",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 1e90c322..93ef1291 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1,7 +1,7 @@
{
"productUsagePrompt": {
"title": "Help shape PicPeak?",
- "intro": "This installation has never been asked about anonymous usage reporting. It's optional, and works differently from the analytics you're used to declining."
+ "intro": "Help shape PicPeak with optional product usage reports. Review the pseudonymous data, who can see it and where it is sent before deciding."
},
"productUsage": {
"fields": "usage.v5 reports contain an installation fingerprint, PicPeak version, UTC report date and generation time, schema/signing metadata, controlled gallery layouts, 87 fixed capability signals (64 configured/used pairs and 23 configuration-only booleans), and two installation totals: stored galleries and photo records excluding videos. Drafts and archived galleries and their retained photo records are included. The catalog below defines every field. There are no action counts or visitor observations.",
diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx
index 1dcfa8b4..fc3b5a6e 100644
--- a/frontend/src/pages/SetupPage.tsx
+++ b/frontend/src/pages/SetupPage.tsx
@@ -308,7 +308,7 @@ export const SetupPage: React.FC = () => {
const skipUsageReporting = async () => {
try {
await productUsageService.promptSeen();
- } catch (_) { /* best-effort — worst case the dashboard asks once more */ }
+ } catch { /* best-effort — worst case the dashboard asks once more */ }
setStep('community');
};