From f1c7501807fd4feea224aba92699854693e38dba Mon Sep 17 00:00:00 2001 From: byGalax Date: Thu, 7 May 2026 17:24:17 +0200 Subject: [PATCH] fix(crypto): suppress approval banner for devices with existing wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After 0.16.2 some users saw the approval banner stack up to 6+ entries on first launch — every old device they ever registered (Tauri-era, test installs, dev builds) showed up because the only "already legit" filter was `created_at <= ownDevice.created_at`. That fails when own device is restored from Backup (older than every other entry) or when the user accumulated installs around the migration window. Add a semantic check: if a device already has at least one row in `conversation_keys` (recipient_device_id), it has been wrapped before and is by definition not awaiting approval. Treat as approved silently. Bulk query against the candidate IDs, no N+1. Plus UX: when more than one request is pending, render a sticky header with a count and "Alle ablehnen" button so users with stale piles can clear them in one click. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/DeviceApprovalBanner.tsx | 19 ++++++ apps/desktop/src/lib/deviceApproval.ts | 68 ++++++++++++++++--- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/components/DeviceApprovalBanner.tsx b/apps/desktop/src/components/DeviceApprovalBanner.tsx index ec340d3..ec88629 100644 --- a/apps/desktop/src/components/DeviceApprovalBanner.tsx +++ b/apps/desktop/src/components/DeviceApprovalBanner.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { approveDevice, + denyAllPending, denyDevice, type PendingApproval, subscribePendingApprovals, @@ -53,6 +54,24 @@ export function DeviceApprovalBanner() { return (
+ {pending.length > 1 && ( +
+ + {t('app:device_approval.bulk_count', { + count: pending.length, + defaultValue: '{{count}} Geräte warten auf Bestätigung', + })} + + +
+ )} {pending.map((req) => { const busy = busyId === req.deviceId; const errored = errorId === req.deviceId; diff --git a/apps/desktop/src/lib/deviceApproval.ts b/apps/desktop/src/lib/deviceApproval.ts index edd7e1f..539008f 100644 --- a/apps/desktop/src/lib/deviceApproval.ts +++ b/apps/desktop/src/lib/deviceApproval.ts @@ -166,22 +166,56 @@ async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise } const rows = (data ?? []) as DeviceRowLite[]; - // Find OWN device's createdAt — anything older than that is a pre-existing - // device that was already legit before this client came online and should - // NOT raise an approval banner. Without this guard, a freshly-installed - // client lights up with one banner per pre-existing device of the user - // ("zich messages zur freigabe") which is the opposite of what we want: - // approval makes sense on the OLDER device judging the NEWER one, never - // the other way round. + // Pre-filter: anything already in approved/dismissed sets, plus own + // device, never surfaces. + const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId)); + if (candidates.length === 0) return; + + // Semantic skip: any candidate that already has a `conversation_keys` + // wrap somewhere is by definition legit — it was either auto-shared + // back when that path was enabled (pre-0.16.2) or explicitly approved + // earlier. Surfacing it now would just nag the user about something + // already taken care of. Bulk query against `conversation_keys` for + // all candidate device IDs at once; cheap, avoids N+1. + const candidateIds = candidates.map((c) => c.id); + const wrappedIds = new Set(); + try { + const { data: keyRows, error: kErr } = await ( + supabase as unknown as { + from: (t: string) => { + select: (cols: string) => { + in: ( + col: string, + vals: string[], + ) => Promise<{ data: { recipient_device_id: string }[] | null; error: unknown }>; + }; + }; + } + ) + .from('conversation_keys') + .select('recipient_device_id') + .in('recipient_device_id', candidateIds); + if (!kErr && keyRows) { + for (const r of keyRows) wrappedIds.add(r.recipient_device_id); + } + } catch (err) { + console.warn('deviceApproval: conv-key existence probe failed', err); + } + + // Defense in depth: also keep the created_at guard. Devices older than + // own can't reasonably need approval — when own came online a fanout + // pass already covered them. Helps when the conv_keys probe returns + // partial data due to RLS. const own = rows.find((r) => r.id === ctx.ownDeviceId); const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY; - for (const row of rows) { - if (!shouldSurface(row.id, ctx.ownDeviceId)) continue; + for (const row of candidates) { + if (wrappedIds.has(row.id)) { + persistApproved(row.id); + continue; + } const rowCreatedAt = Date.parse(row.created_at); if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) { - // Older / equal-age device — auto-treat as already approved on this - // side so it never re-prompts (covers reloads + future fetches). persistApproved(row.id); continue; } @@ -274,3 +308,15 @@ export function denyDevice(deviceId: string): void { persistDismissed(deviceId); removePending(deviceId); } + +// Bulk-deny: persists every currently-pending deviceId into the dismissed +// set and clears the in-memory list in one notify. Useful when a user has +// accumulated stale entries from old test devices / migrations. +export function denyAllPending(): void { + if (pending.length === 0) return; + const dismissed = readIdSet(DISMISSED_KEY); + for (const p of pending) dismissed.add(p.deviceId); + writeIdSet(DISMISSED_KEY, dismissed); + pending = []; + notify(); +}