Files
ChatApp/apps/desktop/src/components/DeviceApprovalBanner.tsx
T
byGalax f1c7501807 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>
2026-05-07 17:24:17 +02:00

197 lines
7.6 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
approveDevice,
denyAllPending,
denyDevice,
type PendingApproval,
subscribePendingApprovals,
} from '../lib/deviceApproval';
import { LockIcon, SpinnerIcon, XIcon } from './icons';
// Sticky bottom-right banner stack (Discord-style). One tile per pending
// device-approval request. Visual language deliberately mirrors
// `BackupPromptBanner` — fixed positioning, rounded panel, subtle border
// accent — but uses the brand/accent palette to distinguish "security
// decision" from the amber "you should make a backup" nudge.
//
// Data flow:
// 1. `startDeviceApprovalListener` (mounted from AppShell) seeds the
// pending list on connect + on realtime INSERTs.
// 2. This component subscribes to that module and re-renders.
// 3. On Genehmigen: calls `approveDevice` which re-uses the
// `wrapForOneDevice` helper from conversationKeySync to write conv-key
// bundles for the new device across every shared conversation.
// 4. On Ablehnen: persists the deviceId in localStorage so it doesn't
// re-surface on app reload.
export function DeviceApprovalBanner() {
const { t, i18n } = useTranslation(['app']);
const [pending, setPending] = useState<PendingApproval[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);
const [errorId, setErrorId] = useState<string | null>(null);
useEffect(() => subscribePendingApprovals(setPending), []);
const onApprove = useCallback(async (req: PendingApproval) => {
setErrorId(null);
setBusyId(req.deviceId);
try {
await approveDevice(req);
} catch (err) {
console.warn('deviceApproval: approve failed', err);
setErrorId(req.deviceId);
} finally {
setBusyId((curr) => (curr === req.deviceId ? null : curr));
}
}, []);
const onDeny = useCallback((deviceId: string) => {
denyDevice(deviceId);
}, []);
if (pending.length === 0) return null;
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;
return (
<div
key={req.deviceId}
role="alertdialog"
aria-labelledby={`device-approval-${req.deviceId}-title`}
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-line bg-surface-3 p-4 text-sm text-fg shadow-xl backdrop-blur-md"
>
<LockIcon className="mt-0.5 h-5 w-5 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p
id={`device-approval-${req.deviceId}-title`}
className="font-semibold"
>
{t('app:device_approval.title', {
defaultValue: 'Neues Gerät registriert',
})}
</p>
<p className="mt-0.5 text-xs text-fg-muted">
{formatDeviceLabel(req)} ·{' '}
{formatRelativeTime(req.createdAt, i18n.language)}
</p>
<p className="mt-1 text-xs text-fg-muted">
{t('app:device_approval.question', {
defaultValue: 'War das du?',
})}
</p>
{errored && (
<p className="mt-1 text-xs text-red-500">
{t('app:device_approval.error', {
defaultValue:
'Genehmigung fehlgeschlagen. Versuch es nochmal.',
})}
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={busy}
onClick={() => void onApprove(req)}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:cursor-wait disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5 animate-spin" />}
{t('app:device_approval.approve', {
defaultValue: 'Genehmigen',
})}
</button>
<button
type="button"
disabled={busy}
onClick={() => onDeny(req.deviceId)}
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1.5 text-xs font-semibold text-fg-muted transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
>
{t('app:device_approval.deny', {
defaultValue: 'Ablehnen',
})}
</button>
</div>
</div>
<button
type="button"
disabled={busy}
onClick={() => onDeny(req.deviceId)}
aria-label={t('app:device_approval.dismiss', {
defaultValue: 'Schließen',
})}
className="cursor-pointer text-fg-muted transition hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
>
<XIcon className="h-4 w-4" />
</button>
</div>
);
})}
</div>
);
}
function formatDeviceLabel(req: PendingApproval): string {
const platform = humanPlatform(req.platform);
const name = (req.name ?? '').trim();
if (name && platform) return `${platform} · ${name}`;
if (name) return name;
if (platform) return platform;
return 'Unbekanntes Gerät';
}
function humanPlatform(p: string): string {
switch (p) {
case 'windows':
return 'Windows';
case 'macos':
return 'macOS';
case 'linux':
return 'Linux';
case 'ios':
return 'iOS';
case 'android':
return 'Android';
default:
return p.length > 0 ? p.charAt(0).toUpperCase() + p.slice(1) : '';
}
}
// Best-effort relative-time formatter using Intl.RelativeTimeFormat.
// Falls back to absolute timestamp if anything goes sideways.
function formatRelativeTime(iso: string, locale: string): string {
try {
const ts = Date.parse(iso);
if (Number.isNaN(ts)) return iso;
const diffSec = Math.round((ts - Date.now()) / 1000);
const abs = Math.abs(diffSec);
const rtf = new Intl.RelativeTimeFormat(locale || 'de', { numeric: 'auto' });
if (abs < 60) return rtf.format(diffSec, 'second');
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
return rtf.format(Math.round(diffSec / 86400), 'day');
} catch {
return iso;
}
}