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([]); const [busyId, setBusyId] = useState(null); const [errorId, setErrorId] = useState(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 (
{pending.length > 1 && (
{t('app:device_approval.bulk_count', { count: pending.length, defaultValue: '{{count}} Geräte warten auf Bestätigung', })}
)} {pending.map((req) => { const busy = busyId === req.deviceId; const errored = errorId === req.deviceId; return (

{t('app:device_approval.title', { defaultValue: 'Neues Gerät registriert', })}

{formatDeviceLabel(req)} ·{' '} {formatRelativeTime(req.createdAt, i18n.language)}

{t('app:device_approval.question', { defaultValue: 'War das du?', })}

{errored && (

{t('app:device_approval.error', { defaultValue: 'Genehmigung fehlgeschlagen. Versuch es nochmal.', })}

)}
); })}
); } 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; } }