f1c7501807
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>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
|
|
|
import { type SyncCtx, wrapForOneDevice } from './conversationKeySync';
|
|
import { supabase } from './supabase';
|
|
|
|
// Device-approval flow (Phase 1).
|
|
// ---------------------------------------------------------------------------
|
|
// When the user registers a brand-new device on top of an existing one, the
|
|
// existing device must explicitly approve it before any conv-key wraps are
|
|
// created. This module:
|
|
//
|
|
// 1. On startup, fetches every device row owned by the user and surfaces
|
|
// the ones that aren't this device, aren't already approved, and aren't
|
|
// dismissed. Covers the "I was offline when the new device registered"
|
|
// case.
|
|
// 2. Subscribes to realtime INSERTs on `devices` for the user's id, so a
|
|
// device that registers WHILE this client is online raises a banner
|
|
// immediately.
|
|
// 3. Persists approve/deny decisions in localStorage so a reload doesn't
|
|
// ask again for a device the user already answered for.
|
|
//
|
|
// Approval call: re-uses `wrapForOneDevice` from conversationKeySync — that
|
|
// helper already walks every shared conversation and writes the key bundle
|
|
// for the target device.
|
|
|
|
export interface PendingApproval {
|
|
deviceId: string;
|
|
userId: string;
|
|
name: string;
|
|
platform: DevicePlatform | string;
|
|
createdAt: string;
|
|
publicKey: string; // pg-hex-encoded bytea
|
|
}
|
|
|
|
export interface DeviceApprovalListenerCtx {
|
|
ownUserId: string;
|
|
ownDeviceId: string;
|
|
// Lazy getter so we never hold the privkey in memory for longer than the
|
|
// approve action that needs it. Returns null if the key isn't loadable
|
|
// (e.g. fresh-restored device that hasn't unsealed yet).
|
|
getPriv: () => Promise<Uint8Array | null>;
|
|
}
|
|
|
|
const APPROVED_KEY = 'chatapp.approvedDeviceIds';
|
|
const DISMISSED_KEY = 'chatapp.dismissedDeviceIds';
|
|
|
|
// In-process state. Module-level so the banner component and the listener
|
|
// share one source of truth without prop-drilling through context.
|
|
let pending: PendingApproval[] = [];
|
|
const subscribers = new Set<(list: PendingApproval[]) => void>();
|
|
let listenerCtx: DeviceApprovalListenerCtx | null = null;
|
|
|
|
function readIdSet(key: string): Set<string> {
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) return new Set();
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return new Set();
|
|
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function writeIdSet(key: string, set: Set<string>): void {
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify([...set]));
|
|
} catch {
|
|
/* storage unavailable — non-fatal */
|
|
}
|
|
}
|
|
|
|
function persistApproved(deviceId: string): void {
|
|
const s = readIdSet(APPROVED_KEY);
|
|
s.add(deviceId);
|
|
writeIdSet(APPROVED_KEY, s);
|
|
}
|
|
|
|
function persistDismissed(deviceId: string): void {
|
|
const s = readIdSet(DISMISSED_KEY);
|
|
s.add(deviceId);
|
|
writeIdSet(DISMISSED_KEY, s);
|
|
}
|
|
|
|
function notify(): void {
|
|
const snapshot = [...pending];
|
|
for (const cb of subscribers) {
|
|
try {
|
|
cb(snapshot);
|
|
} catch (err) {
|
|
console.warn('deviceApproval: subscriber threw', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getPendingApprovals(): PendingApproval[] {
|
|
return [...pending];
|
|
}
|
|
|
|
export function subscribePendingApprovals(
|
|
cb: (list: PendingApproval[]) => void,
|
|
): () => void {
|
|
subscribers.add(cb);
|
|
// Fire once with current state so the consumer can initialise without
|
|
// waiting for the next change.
|
|
try {
|
|
cb([...pending]);
|
|
} catch (err) {
|
|
console.warn('deviceApproval: initial subscriber call threw', err);
|
|
}
|
|
return () => {
|
|
subscribers.delete(cb);
|
|
};
|
|
}
|
|
|
|
function shouldSurface(deviceId: string, ownDeviceId: string): boolean {
|
|
if (deviceId === ownDeviceId) return false;
|
|
const approved = readIdSet(APPROVED_KEY);
|
|
if (approved.has(deviceId)) return false;
|
|
const dismissed = readIdSet(DISMISSED_KEY);
|
|
if (dismissed.has(deviceId)) return false;
|
|
return true;
|
|
}
|
|
|
|
interface DeviceRowLite {
|
|
id: string;
|
|
user_id: string;
|
|
name: string;
|
|
platform: DevicePlatform | string;
|
|
created_at: string;
|
|
public_key: string;
|
|
}
|
|
|
|
function rowToPending(row: DeviceRowLite): PendingApproval {
|
|
return {
|
|
deviceId: row.id,
|
|
userId: row.user_id,
|
|
name: row.name,
|
|
platform: row.platform,
|
|
createdAt: row.created_at,
|
|
publicKey: row.public_key,
|
|
};
|
|
}
|
|
|
|
function upsertPending(req: PendingApproval): void {
|
|
if (pending.some((p) => p.deviceId === req.deviceId)) return;
|
|
pending = [...pending, req];
|
|
notify();
|
|
}
|
|
|
|
function removePending(deviceId: string): void {
|
|
const next = pending.filter((p) => p.deviceId !== deviceId);
|
|
if (next.length === pending.length) return;
|
|
pending = next;
|
|
notify();
|
|
}
|
|
|
|
async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise<void> {
|
|
const { data, error } = await supabase
|
|
.from('devices')
|
|
.select('id, user_id, name, platform, created_at, public_key')
|
|
.eq('user_id', ctx.ownUserId);
|
|
if (error) {
|
|
console.warn('deviceApproval: initial devices lookup failed', error);
|
|
return;
|
|
}
|
|
const rows = (data ?? []) as DeviceRowLite[];
|
|
|
|
// 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 candidates) {
|
|
if (wrappedIds.has(row.id)) {
|
|
persistApproved(row.id);
|
|
continue;
|
|
}
|
|
const rowCreatedAt = Date.parse(row.created_at);
|
|
if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) {
|
|
persistApproved(row.id);
|
|
continue;
|
|
}
|
|
upsertPending(rowToPending(row));
|
|
}
|
|
}
|
|
|
|
// Starts the approval listener for the given user/device. Returns an
|
|
// unsubscribe function — call it on shell unmount to tear down the realtime
|
|
// channel and clear in-memory state.
|
|
export function startDeviceApprovalListener(
|
|
ctx: DeviceApprovalListenerCtx,
|
|
): () => void {
|
|
listenerCtx = ctx;
|
|
|
|
void loadInitialPending(ctx);
|
|
|
|
const channel = supabase
|
|
.channel(`device-approval:${ctx.ownUserId}`)
|
|
.on(
|
|
'postgres_changes',
|
|
{
|
|
event: 'INSERT',
|
|
schema: 'public',
|
|
table: 'devices',
|
|
filter: `user_id=eq.${ctx.ownUserId}`,
|
|
},
|
|
(payload: { new: Record<string, unknown> }) => {
|
|
const row = payload.new as unknown as DeviceRowLite;
|
|
if (!row?.id) return;
|
|
if (!shouldSurface(row.id, ctx.ownDeviceId)) return;
|
|
upsertPending(rowToPending(row));
|
|
},
|
|
)
|
|
.subscribe();
|
|
|
|
return () => {
|
|
void supabase.removeChannel(channel).catch(() => {
|
|
/* ignore — channel might already be gone */
|
|
});
|
|
pending = [];
|
|
listenerCtx = null;
|
|
notify();
|
|
};
|
|
}
|
|
|
|
// Approves a pending device: walks every conversation the current user is
|
|
// in and writes a conv-key bundle for the new device. On success the request
|
|
// is removed from the pending list and the deviceId is persisted in
|
|
// localStorage so a reload doesn't re-prompt.
|
|
export async function approveDevice(req: PendingApproval): Promise<void> {
|
|
const ctx = listenerCtx;
|
|
if (!ctx) {
|
|
throw new Error('deviceApproval: listener not started');
|
|
}
|
|
if (req.userId !== ctx.ownUserId) {
|
|
// Phase 1 only handles same-user approvals (own new device). Friend-side
|
|
// approval is a later phase.
|
|
throw new Error('deviceApproval: cross-user approval not supported yet');
|
|
}
|
|
|
|
const priv = await ctx.getPriv();
|
|
if (!priv) {
|
|
throw new Error('deviceApproval: own private key unavailable');
|
|
}
|
|
|
|
const sync: SyncCtx = {
|
|
myUserId: ctx.ownUserId,
|
|
myDeviceId: ctx.ownDeviceId,
|
|
priv,
|
|
};
|
|
|
|
try {
|
|
await wrapForOneDevice(sync, req.deviceId, req.userId, req.publicKey);
|
|
} finally {
|
|
// Wipe the priv copy we asked for. The original lives in the secret
|
|
// store; this is the transient working copy.
|
|
for (let i = 0; i < priv.length; i++) priv[i] = 0;
|
|
}
|
|
|
|
persistApproved(req.deviceId);
|
|
removePending(req.deviceId);
|
|
}
|
|
|
|
// Denies a pending device: just remembers the deviceId in the dismissed-set
|
|
// and removes the request. No server-side change — the new device simply
|
|
// stays without any conv-key wraps until the user changes their mind (e.g.
|
|
// from a settings screen later).
|
|
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();
|
|
}
|