fix(crypto): suppress approval banner for devices with existing wraps

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) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-07 17:24:17 +02:00
parent e16b248366
commit f1c7501807
2 changed files with 76 additions and 11 deletions
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { import {
approveDevice, approveDevice,
denyAllPending,
denyDevice, denyDevice,
type PendingApproval, type PendingApproval,
subscribePendingApprovals, subscribePendingApprovals,
@@ -53,6 +54,24 @@ export function DeviceApprovalBanner() {
return ( return (
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex w-[min(92vw,420px)] flex-col gap-3"> <div className="pointer-events-none fixed bottom-6 right-6 z-40 flex w-[min(92vw,420px)] flex-col gap-3">
{pending.length > 1 && (
<div className="pointer-events-auto flex items-center justify-between gap-3 rounded-xl border border-line bg-surface-3/80 px-4 py-2 text-xs text-fg-muted backdrop-blur-md">
<span>
{t('app:device_approval.bulk_count', {
count: pending.length,
defaultValue: '{{count}} Geräte warten auf Bestätigung',
})}
</span>
<button
type="button"
onClick={() => denyAllPending()}
disabled={busyId !== null}
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{t('app:device_approval.deny_all', { defaultValue: 'Alle ablehnen' })}
</button>
</div>
)}
{pending.map((req) => { {pending.map((req) => {
const busy = busyId === req.deviceId; const busy = busyId === req.deviceId;
const errored = errorId === req.deviceId; const errored = errorId === req.deviceId;
+57 -11
View File
@@ -166,22 +166,56 @@ async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise<void>
} }
const rows = (data ?? []) as DeviceRowLite[]; const rows = (data ?? []) as DeviceRowLite[];
// Find OWN device's createdAt — anything older than that is a pre-existing // Pre-filter: anything already in approved/dismissed sets, plus own
// device that was already legit before this client came online and should // device, never surfaces.
// NOT raise an approval banner. Without this guard, a freshly-installed const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId));
// client lights up with one banner per pre-existing device of the user if (candidates.length === 0) return;
// ("zich messages zur freigabe") which is the opposite of what we want:
// approval makes sense on the OLDER device judging the NEWER one, never // Semantic skip: any candidate that already has a `conversation_keys`
// the other way round. // 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<string>();
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 own = rows.find((r) => r.id === ctx.ownDeviceId);
const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY; const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY;
for (const row of rows) { for (const row of candidates) {
if (!shouldSurface(row.id, ctx.ownDeviceId)) continue; if (wrappedIds.has(row.id)) {
persistApproved(row.id);
continue;
}
const rowCreatedAt = Date.parse(row.created_at); const rowCreatedAt = Date.parse(row.created_at);
if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) { 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); persistApproved(row.id);
continue; continue;
} }
@@ -274,3 +308,15 @@ export function denyDevice(deviceId: string): void {
persistDismissed(deviceId); persistDismissed(deviceId);
removePending(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();
}