From 9f4b9bab46264d83dcdf698ce5bc318703eb10ee Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:00:35 +0200 Subject: [PATCH] fix(usage): explain and de-emphasize the pending-packet button lock (#1363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(usage): explain and de-emphasize the pending-packet button lock An admin whose report delivery is stuck (old schema, network issue, etc.) saw the v5-upgrade and portal buttons greyed out with no indication why, or what to do about it — the backend's single-packet- in-flight guard is correct, but silent. Add an inline note pointing at "Retry / send if due" when a pending packet is the actual cause. Also: "Review expanded usage.v5 scope" didn't read as an upgrade action — renamed to "Upgrade to usage.v5" / "Auf usage.v5 upgraden". "Open usage portal" is now a primary (green) button in both its signed-in and pre-participation forms, matching the visual weight of the other primary actions on this tab instead of blending in as a secondary outline button. * fix(usage): scope the pending-packet note to controls it actually gates The note added in the previous commit rendered whenever pending_action was truthy, regardless of participation status. Outside `active` (activation_pending, deletion_pending) the portal renders as a plain un-gated link and no v5-upgrade section exists at all, so the note named two controls that either weren't blocked or weren't on screen. And when active but already on the current schema, it wrongly implied a v5-upgrade button existed. Gate on `active` (nothing is actually blocked outside it), and choose between the existing two-control message and a new portal-only one based on whether consent_update_available — which the v5-upgrade section itself is gated on — is true. Four new regression tests cover each shape: activation_pending, deletion_pending, active+current-schema (portal-only), and active+outdated-schema (both, the original case). Found by code review. --------- Co-authored-by: Paul Nothaft --- .../__tests__/ProductUsageTab.test.tsx | 26 +++++++++++++++++++ .../settings/tabs/ProductUsageTab.tsx | 19 ++++++++++++-- frontend/src/i18n/locales/de.json | 4 ++- frontend/src/i18n/locales/en.json | 4 ++- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx index 8e8c39db..844caf23 100644 --- a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -103,6 +103,32 @@ it('pending v2 confirmation clearly keeps v1 and cannot queue another upgrade', expect(screen.getByRole('button', { name: 'productUsage.reviewUpgrade' })).toBeDisabled(); expect(service.upgradeConsent).not.toHaveBeenCalled(); }); +it.each(['activation_pending', 'deletion_pending'] as const)('a stuck packet in %s names nothing, since neither control is actually gated by it', async (pendingStatus) => { + // Outside `active`, the portal renders as a plain always-enabled link and + // no v5-upgrade section exists — pending_action blocks neither, so the + // note must not claim it does. + vi.mocked(service.status).mockResolvedValue({ ...status, status: pendingStatus, collector_url: 'https://usage.picpeak.app', pending_action: pendingStatus === 'activation_pending' ? 'register' : 'delete' }); + mount(); + await screen.findByText(`productUsage.states.${pendingStatus}`); + expect(screen.queryByText('productUsage.pendingBlocksActions')).toBeNull(); + expect(screen.queryByText('productUsage.pendingBlocksPortal')).toBeNull(); +}); +it('a stuck report on an already-current schema names only the portal, not a non-existent upgrade button', async () => { + vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: false, pending_action: 'report' }); + mount(); + expect(await screen.findByText('productUsage.pendingBlocksPortal')).toBeInTheDocument(); + expect(screen.queryByText('productUsage.pendingBlocksActions')).toBeNull(); + expect(screen.queryByText('productUsage.reviewUpgrade')).toBeNull(); + expect(screen.getByRole('button', { name: 'productUsage.openUsagePortal' })).toBeDisabled(); +}); +it('a stuck report with a v5 upgrade available names both actually-gated controls', async () => { + vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: true, collector_url: 'https://usage.picpeak.app', pending_action: 'report' }); + mount(); + expect(await screen.findByText('productUsage.pendingBlocksActions')).toBeInTheDocument(); + expect(screen.queryByText('productUsage.pendingBlocksPortal')).toBeNull(); + expect(screen.getByRole('button', { name: 'productUsage.reviewUpgrade' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'productUsage.openUsagePortal' })).toBeDisabled(); +}); describe('product usage controls', () => { it('offers identity-free audit receipts after opt-out without restoring participation controls', async () => { vi.mocked(service.status).mockResolvedValue({ diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index d633db4d..8022c870 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -223,6 +223,22 @@ export default function ProductUsageTab() { )} + {active && data.pending_action && data.pending_action !== 'consent' && ( + // The portal button below (and the v5-upgrade button above, when + // present) are disabled by the same pending-packet guard the + // backend enforces (command() refuses a second packet while one is + // still unacknowledged) — without this note they just look broken, + // and "Retry" above isn't obviously the fix. Gated on `active`: + // outside that status the portal renders as a plain, un-gated link + // and no v5-upgrade section exists, so pending_action blocks + // nothing this note could correctly describe (e.g. + // activation_pending/deletion_pending with their own packet still + // in flight). Which controls it names depends on whether the + // v5-upgrade section is actually on screen. +

+ {t(data.consent_update_available ? 'productUsage.pendingBlocksActions' : 'productUsage.pendingBlocksPortal')} +

+ )}
{data.status === 'disabled' ? (