diff --git a/backend/__tests__/integration/productUsagePg.test.js b/backend/__tests__/integration/productUsagePg.test.js index 08306b90..dd350bb1 100644 --- a/backend/__tests__/integration/productUsagePg.test.js +++ b/backend/__tests__/integration/productUsagePg.test.js @@ -54,6 +54,7 @@ maybe('product usage on Postgres', () => { await require('../../migrations/core/204_product_usage_privacy_receipts').up(db); await require('../../migrations/core/205_product_usage_consent_version').up(db); await require('../../migrations/core/206_product_usage_delivery_backoff').up(db); + await require('../../migrations/core/212_product_usage_prompt_shown').up(db); await db.schema.createTable('app_settings', (t) => { t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type'); @@ -120,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/212_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/212_product_usage_prompt_shown.test.js b/backend/__tests__/migrations/212_product_usage_prompt_shown.test.js new file mode 100644 index 00000000..372f163c --- /dev/null +++ b/backend/__tests__/migrations/212_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/212_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/__tests__/services/usageEnableDisableRace.test.js b/backend/__tests__/services/usageEnableDisableRace.test.js index b7d24ff8..edfd7690 100644 --- a/backend/__tests__/services/usageEnableDisableRace.test.js +++ b/backend/__tests__/services/usageEnableDisableRace.test.js @@ -42,6 +42,7 @@ async function bootDb() { t.string('status', 30).notNullable().defaultTo('disabled'); t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.boolean('prompt_shown').notNullable().defaultTo(false); t.string('installation_id', 64); t.string('public_key', 59); t.text('private_key_encrypted'); diff --git a/backend/__tests__/services/usageExportReceipt.test.js b/backend/__tests__/services/usageExportReceipt.test.js index 1dd4b5f0..16a7c61d 100644 --- a/backend/__tests__/services/usageExportReceipt.test.js +++ b/backend/__tests__/services/usageExportReceipt.test.js @@ -32,6 +32,7 @@ async function bootDb() { t.string('status', 30).notNullable().defaultTo('disabled'); t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2'); t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.boolean('prompt_shown').notNullable().defaultTo(false); t.string('installation_id', 64); t.string('public_key', 59); t.text('private_key_encrypted'); diff --git a/backend/__tests__/services/usageServiceKeyRotation.test.js b/backend/__tests__/services/usageServiceKeyRotation.test.js index 18485d2f..f4d7ec7c 100644 --- a/backend/__tests__/services/usageServiceKeyRotation.test.js +++ b/backend/__tests__/services/usageServiceKeyRotation.test.js @@ -26,6 +26,7 @@ async function bootDb() { t.string('status', 30).notNullable().defaultTo('disabled'); t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.boolean('prompt_shown').notNullable().defaultTo(false); t.string('installation_id', 64); t.string('public_key', 59); t.text('private_key_encrypted'); diff --git a/backend/__tests__/services/usageSnapshotSignals.test.js b/backend/__tests__/services/usageSnapshotSignals.test.js index ad4d3403..c1379126 100644 --- a/backend/__tests__/services/usageSnapshotSignals.test.js +++ b/backend/__tests__/services/usageSnapshotSignals.test.js @@ -25,6 +25,7 @@ async function bootDb() { t.string('status', 30).notNullable().defaultTo('disabled'); t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.boolean('prompt_shown').notNullable().defaultTo(false); t.string('installation_id', 64); t.string('public_key', 59); t.text('private_key_encrypted'); diff --git a/backend/migrations/core/212_product_usage_prompt_shown.js b/backend/migrations/core/212_product_usage_prompt_shown.js new file mode 100644 index 00000000..0a520fe6 --- /dev/null +++ b/backend/migrations/core/212_product_usage_prompt_shown.js @@ -0,0 +1,31 @@ +// Tracks whether this installation has ever been offered the one-time +// usage-reporting opt-in prompt shown to an existing admin on their first +// login after an update (see UsageService.markPromptShown()). A fresh +// install that went through the setup wizard's own opt-in step sets this +// 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'))) 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 ( + (await knex.schema.hasTable('product_usage_state')) && + (await knex.schema.hasColumn('product_usage_state', 'prompt_shown')) + ) { + await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('prompt_shown')); + } +}; diff --git a/backend/src/routes/adminUsage.js b/backend/src/routes/adminUsage.js index 159cc5ca..9511ed43 100644 --- a/backend/src/routes/adminUsage.js +++ b/backend/src/routes/adminUsage.js @@ -73,6 +73,13 @@ router.post( '/dismiss', wrap(async (_req, res) => res.json(await service.dismiss())) ); +// Acknowledges the one-time opt-in prompt (setup wizard or the post-update +// modal) regardless of whether the admin enabled or declined — either way it +// must not ask this installation again. +router.post( + '/prompt-seen', + wrap(async (_req, res) => res.json(await service.markPromptShown())) +); router.post( '/enable', wrap(async (req, res) => diff --git a/backend/src/usage/UsageService.js b/backend/src/usage/UsageService.js index d9a7e647..aa1f8be7 100644 --- a/backend/src/usage/UsageService.js +++ b/backend/src/usage/UsageService.js @@ -263,6 +263,7 @@ class UsageService { return { status: state.status, notice_dismissed: Boolean(state.notice_dismissed), + prompt_shown: Boolean(state.prompt_shown), installation_id: state.installation_id, collector_url: collectorUrl, collector_error: collectorError, @@ -343,6 +344,18 @@ class UsageService { .update({ notice_dismissed: formatBoolean(true) }); return this.status(); } + // The one-time opt-in prompt (setup wizard for a new install, a modal shown + // once to an existing admin after an update) calls this on either outcome — + // enable or decline — so it never asks the same installation twice. Kept + // separate from `notice_dismissed`: that one only silences the persistent, + // re-visitable dashboard banner and is unrelated to whether this one-time + // prompt has already been shown. + async markPromptShown() { + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ prompt_shown: formatBoolean(true) }); + return this.status(); + } async enable(consent) { if (!Object.values(CONSENT_VERSIONS).includes(consent)) throw new ValidationError('Explicit usage consent is required'); @@ -382,6 +395,7 @@ class UsageService { status: 'activation_pending', consent_version: consent, notice_dismissed: formatBoolean(true), + prompt_shown: formatBoolean(true), installation_id: identity.installation_id, public_key: identity.public_key, private_key_encrypted: this.encrypt(identity.private_key), diff --git a/docs/usage-coverage.v5.json b/docs/usage-coverage.v5.json index b7de6177..9a8dac40 100644 --- a/docs/usage-coverage.v5.json +++ b/docs/usage-coverage.v5.json @@ -1250,11 +1250,12 @@ "adminUsage.js": { "decision": "excluded", "signals": [], - "reason": "Consent, inspection, export, feedback, voting, deletion and abandoning an unsignable deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report.", + "reason": "Consent, prompt acknowledgement, inspection, export, feedback, voting, deletion and abandoning an unsignable deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report.", "route_signatures": [ "POST /activity", "GET /", "POST /dismiss", + "POST /prompt-seen", "POST /enable", "POST /consent", "POST /disable", diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 34804422..bc40f3ce 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -12,6 +12,7 @@ import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal'; const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed'; const ProductUsageNotice = lazy(() => import('./ProductUsageNotice')); +const UsageReportingPrompt = lazy(() => import('./UsageReportingPrompt')); export const AdminLayout: React.FC = () => { const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth(); @@ -127,6 +128,7 @@ const AdminLayoutInner: React.FC = ({ sidebarOpen, setSid docker-compose.yml. See #669. */} {!mustChangePassword && } + {!mustChangePassword && } {/* Page content - disabled when password change required. overflow moved up to the column so the scrollbar gutter is diff --git a/frontend/src/components/admin/UsageReportingPitch.tsx b/frontend/src/components/admin/UsageReportingPitch.tsx new file mode 100644 index 00000000..d77a2a73 --- /dev/null +++ b/frontend/src/components/admin/UsageReportingPitch.tsx @@ -0,0 +1,32 @@ +import type { LucideIcon } from 'lucide-react'; +import { ShieldOff, Users, MessageSquare } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +// 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 }, + { key: 'feedback', icon: MessageSquare }, +]; + +export const UsageReportingPoints: React.FC = () => { + const { t } = useTranslation(); + 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 new file mode 100644 index 00000000..9907b661 --- /dev/null +++ b/frontend/src/components/admin/UsageReportingPrompt.tsx @@ -0,0 +1,112 @@ +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 { ProductUsageConsentDialog } from '../../features/settings/components/ProductUsageConsentDialog'; +import { Button } from '../common/Button'; +import { UsageReportingPoints } from './UsageReportingPitch'; + +/** 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 [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 { + /* A failed acknowledgement may be offered again on a later visit. */ + } + }; + + const enable = async () => { + setIsEnabling(true); + try { + queryClient.setQueryData(['productUsage'], await productUsageService.enable()); + toast.success(t('setup.usageReporting.enabled')); + setHidden(true); + } catch { + toast.warn(t('setup.usageReporting.enableFailed')); + } finally { + setIsEnabling(false); + } + }; + + if (!visible || !data) return null; + + return ( + <> + { + event.preventDefault(); + if (!isEnabling) void dismiss(); + }} + className="w-[calc(100%-2rem)] max-w-md max-h-[90vh] 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" + > +
+

+ {t('productUsagePrompt.title')} +

+

{t('productUsagePrompt.intro')}

+
+ +
+ +
+ +
+ {data.collector_error && ( +

{t('setup.usageReporting.enableFailed')}

+ )} + + +
+
+ {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 37bed5bd..5b22e6df 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1,4 +1,8 @@ { + "productUsagePrompt": { + "title": "PicPeak mitgestalten?", + "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.", "catalogTitle": "Vollständiger Katalog: 87 Funktionssignale und 2 Bestandszahlen (usage.v5)", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f009860e..93ef1291 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1,4 +1,8 @@ { + "productUsagePrompt": { + "title": "Help shape PicPeak?", + "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.", "catalogTitle": "Full catalog: 87 capability signals and 2 inventory totals (usage.v5)", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 515c92b1..74e926ba 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee, ShieldOff, Users, MessageSquare } from 'lucide-react'; +import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; @@ -16,6 +16,7 @@ import { ProductUsageConsentDialog } from '../features/settings/components/Produ import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard'; import { SetupConfigStep } from '../components/admin/SetupConfigStep'; import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep'; +import { UsageReportingPoints } from '../components/admin/UsageReportingPitch'; import { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import type { AdminUser } from '../types'; @@ -38,15 +39,6 @@ const COMMUNITY_LINKS: { { key: 'support', href: 'https://www.buymeacoffee.com/theluap', icon: Coffee }, ]; -// Anonymous usage-reporting opt-in, one step before the final thank-you -// screen. The invitation opens the same complete consent disclosure as -// Settings → Product usage before any reporting can be enabled. -const USAGE_REPORTING_POINTS: { key: string; icon: LucideIcon }[] = [ - { key: 'oneWay', icon: ShieldOff }, - { key: 'mutual', icon: Users }, - { key: 'feedback', icon: MessageSquare }, -]; - // "How will you use PicPeak?" — the opt-in feature groups shown after the admin // account is created. galleries/analytics/userManagement are always on and not // listed. Labels/descriptions reuse the existing Settings→Features i18n keys @@ -311,6 +303,16 @@ export const SetupPage: React.FC = () => { } }; + // Declining here still counts as having been asked (#1360): without this, + // the one-time post-update prompt would immediately re-ask the same admin + // the same question seconds later on their first dashboard visit. + const skipUsageReporting = async () => { + try { + queryClient.setQueryData(['productUsage'], await productUsageService.promptSeen()); + } catch { /* best-effort — worst case the dashboard asks once more */ } + setStep('community'); + }; + const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; return ( @@ -581,21 +583,7 @@ export const SetupPage: React.FC = () => {

{t('setup.usageReporting.intro')}

-
- {USAGE_REPORTING_POINTS.map(({ key, icon: Icon }) => ( -
- - - - {t(`setup.usageReporting.${key}Title`)} - - - {t(`setup.usageReporting.${key}Desc`)} - - -
- ))} -
+ {(usageStatusError || usageStatus?.collector_error) && (

{t('setup.usageReporting.enableFailed')}

@@ -619,7 +607,7 @@ export const SetupPage: React.FC = () => { size="lg" className="w-full" disabled={isEnablingUsageReporting} - onClick={() => setStep('community')} + onClick={skipUsageReporting} > {t('setup.usageReporting.skip')} diff --git a/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx b/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx index bdd7798b..e3c21d41 100644 --- a/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx +++ b/frontend/src/pages/__tests__/SetupPage.usageReporting.test.tsx @@ -39,6 +39,7 @@ 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); + vi.mocked(usage.promptSeen).mockResolvedValue({ status: 'disabled', prompt_shown: true } as never); HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', ''); }; }); afterEach(cleanup); @@ -92,6 +93,8 @@ it('skipping the invitation never enables reporting', async () => { fireEvent.click(screen.getByRole('button', { name: 'setup.usageReporting.skip' })); await screen.findByText('setup.community.mission'); expect(usage.enable).not.toHaveBeenCalled(); + expect(usage.promptSeen).toHaveBeenCalledTimes(1); + expect(client.getQueryData(['productUsage'])).toMatchObject({ status: 'disabled', prompt_shown: true }); }); it.each(['failed', 'invalid'])('keeps setup usable when collector configuration is %s', async (failure) => { diff --git a/frontend/src/services/productUsage.service.ts b/frontend/src/services/productUsage.service.ts index 7b0344cc..28b57ebe 100644 --- a/frontend/src/services/productUsage.service.ts +++ b/frontend/src/services/productUsage.service.ts @@ -8,6 +8,8 @@ export interface UsageStatus { | 'deletion_pending' | 'identity_conflict'; notice_dismissed: boolean; + /** Whether the one-time opt-in prompt (setup wizard or post-update modal) has ever been shown. */ + prompt_shown: boolean; installation_id: string | null; collector_url: string | null; collector_error?: 'INVALID_COLLECTOR_URL' | null; @@ -46,6 +48,9 @@ export const productUsageService = { async dismiss(): Promise { return (await api.post('/admin/usage/dismiss')).data; }, + async promptSeen(): Promise { + return (await api.post('/admin/usage/prompt-seen')).data; + }, async enable(): Promise { return ( await api.post('/admin/usage/enable', {