fix(usage): explain and de-emphasize the pending-packet button lock (#1363)

* 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 <paul@MacStudio-von-Paul.local>
This commit is contained in:
Paul Nothaft
2026-09-08 22:00:35 +02:00
committed by GitHub
parent de3ae1f176
commit 9f4b9bab46
4 changed files with 49 additions and 4 deletions
@@ -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({
@@ -223,6 +223,22 @@ export default function ProductUsageTab() {
</Button>
</div>
)}
{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.
<p role="status" className="text-sm text-neutral-600 dark:text-neutral-400">
{t(data.consent_update_available ? 'productUsage.pendingBlocksActions' : 'productUsage.pendingBlocksPortal')}
</p>
)}
<div className="flex flex-wrap gap-3">
{data.status === 'disabled' ? (
<Button disabled={busy} onClick={() => setConsent(true)}>
@@ -274,7 +290,6 @@ export default function ProductUsageTab() {
{active ? (
<>
<Button
variant="outline"
disabled={busy || Boolean(data.pending_action)}
onClick={openPortal}
>
@@ -294,7 +309,7 @@ export default function ProductUsageTab() {
</>
) : (
<a
className="btn btn-outline btn-md"
className="btn btn-primary btn-md"
href={data.collector_url}
target="_blank"
rel="noopener noreferrer"
+3 -1
View File
@@ -14,10 +14,12 @@
"configurationOnly": "Nur Konfiguration, die tatsächliche Nutzung wird nicht erfasst.",
"versionDisclosure": "Diese Zustimmung gilt für usage.v5 / usage-consent.v5: 87 Funktionen und dieselben zwei Bestandszahlen. Neue Ja/Nein-Werte unterscheiden echte CMS-, Vorlagen-, Branding-, SEO-, Kategorie- und Ereignistyp-Änderungen sowie die Annahme echter Vorlagen-E-Mails durch den Mailtransport (auch im Hintergrund) von Standards, unverändertem Speichern, Vorschauen und Tests. Inhalte, Empfänger, Vorlagenkennungen, Aktionszeitpunkte und Häufigkeiten werden nicht erfasst. Die Beobachtung beginnt erst nach bestätigter Zustimmung; Nein bedeutet nicht, dass noch Standards verwendet werden. Frühere Versionen behalten bis zum Upgrade Bedeutung und Umfang. Wartende Pakete bleiben unverändert; Markierungen starten nach Bestätigung neu.",
"currentSchema": "Aktuelles Berichtsschema: {{schema}}",
"reviewUpgrade": "Erweiterten Umfang von usage.v5 ansehen",
"reviewUpgrade": "Auf usage.v5 upgraden",
"upgrade": "usage.v5 ausdrücklich zustimmen",
"upgradeExplanation": "Deine bestehende Teilnahme behält ihren bisherigen Umfang. Sieh dir den erweiterten Katalog und die beiden Bestandszahlen an, bevor du dich entscheidest. Ablehnen beendet die Teilnahme nicht.",
"upgradePending": "Die signierte Erweiterung wartet auf die Bestätigung des Collectors. Bis dahin wird nur der bisher bestätigte Umfang erfasst. Versuche es erneut, wenn der Collector erreichbar ist, oder deaktiviere die Teilnahme, um zu stoppen und zu löschen.",
"pendingBlocksActions": "Das v5-Upgrade und das Nutzungsportal sind gesperrt, bis der ausstehende Bericht erneut gesendet wurde oder zugestellt ist — nutze oben „Jetzt senden“.",
"pendingBlocksPortal": "Das Nutzungsportal ist gesperrt, bis der ausstehende Bericht erneut gesendet wurde oder zugestellt ist — nutze oben „Jetzt senden“.",
"catalog": {
"crm": {
"name": "Kundenverwaltung",
+3 -1
View File
@@ -14,10 +14,12 @@
"configurationOnly": "Configuration only — actual use is not collected.",
"versionDisclosure": "This consent covers usage.v5 / usage-consent.v5: 87 capabilities and the same two inventory totals. New booleans distinguish real CMS/template/branding/SEO/category/event-type edits and actual template-email transport acceptance, including background sends, from defaults, unchanged saves, previews and tests. No content, recipient, template key, action timestamp or frequency is collected. Observations begin only after accepted consent; false does not mean an installation still uses defaults. Previous versions keep their meanings and scope until upgrade; queued packets stay unchanged and markers restart after confirmation.",
"currentSchema": "Current reporting schema: {{schema}}",
"reviewUpgrade": "Review expanded usage.v5 scope",
"reviewUpgrade": "Upgrade to usage.v5",
"upgrade": "Explicitly agree to usage.v5",
"upgradeExplanation": "Your existing participation keeps its current scope. Review the expanded catalog and the two inventory totals before deciding whether to upgrade. Declining does not end participation.",
"upgradePending": "The signed consent upgrade is pending confirmation. Only the previously accepted scope is collected. Retry when the collector is available, or disable participation to stop and delete.",
"pendingBlocksActions": "The v5 upgrade and the usage portal are unavailable until the pending report is retried or delivered — use \"Retry / send if due\" above.",
"pendingBlocksPortal": "The usage portal is unavailable until the pending report is retried or delivered — use \"Retry / send if due\" above.",
"catalog": {
"crm": {
"name": "Client management",