Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68bc1f76f6 | |||
| f1c7501807 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.16.2",
|
||||
"version": "0.16.3",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
approveDevice,
|
||||
denyAllPending,
|
||||
denyDevice,
|
||||
type PendingApproval,
|
||||
subscribePendingApprovals,
|
||||
@@ -53,6 +54,24 @@ export function DeviceApprovalBanner() {
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
const busy = busyId === req.deviceId;
|
||||
const errored = errorId === req.deviceId;
|
||||
|
||||
@@ -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