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:
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user