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>
197 lines
7.6 KiB
TypeScript
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;
|
|
}
|
|
}
|