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:
@@ -166,22 +166,56 @@ async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise<void>
|
||||
}
|
||||
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<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 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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user