diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8db7304..c24d3f6 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -36,7 +36,19 @@ export function App() { - + }> } /> diff --git a/apps/desktop/src/components/AppShell.tsx b/apps/desktop/src/components/AppShell.tsx index 20c9122..43c8d2b 100644 --- a/apps/desktop/src/components/AppShell.tsx +++ b/apps/desktop/src/components/AppShell.tsx @@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { startConversationKeySync } from '../lib/conversationKeySync'; import { ensureNotificationPermission } from '../lib/osNotify'; +import { BackupPromptBanner } from './BackupPromptBanner'; import { CallUI } from './CallUI'; import { Sidebar } from './Sidebar'; @@ -32,6 +33,7 @@ export function AppShell() { + ); } diff --git a/apps/desktop/src/components/BackupExportDialog.tsx b/apps/desktop/src/components/BackupExportDialog.tsx new file mode 100644 index 0000000..01b6c75 --- /dev/null +++ b/apps/desktop/src/components/BackupExportDialog.tsx @@ -0,0 +1,251 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { exportDeviceBackup } from '../lib/deviceBackup'; +import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons'; + +interface Props { + open: boolean; + userId: string; + deviceId: string; + privateKey: Uint8Array; + onClose: () => void; +} + +// Exports the device's private key + identity into a passphrase-protected +// portable string. The user can store this string anywhere (password manager, +// printed paper, encrypted file on a USB stick). Without it, losing local +// storage on this install means losing all past conversation keys. +export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) { + const { t } = useTranslation(['app']); + const [passphrase, setPassphrase] = useState(''); + const [confirm, setConfirm] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [backup, setBackup] = useState(null); + const [copied, setCopied] = useState(false); + + const canGenerate = useMemo(() => { + return passphrase.length >= 8 && passphrase === confirm && !busy; + }, [passphrase, confirm, busy]); + + const reset = useCallback(() => { + setPassphrase(''); + setConfirm(''); + setBackup(null); + setError(null); + setCopied(false); + }, []); + + const handleClose = useCallback(() => { + reset(); + onClose(); + }, [reset, onClose]); + + const handleGenerate = useCallback(async () => { + if (!canGenerate) return; + setBusy(true); + setError(null); + try { + const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase }); + setBackup(str); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }, [canGenerate, userId, deviceId, privateKey, passphrase]); + + const handleCopy = useCallback(async () => { + if (!backup) return; + try { + await navigator.clipboard.writeText(backup); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + /* fall back — user can select manually */ + } + }, [backup]); + + const handleDownload = useCallback(() => { + if (!backup) return; + const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`; + a.click(); + URL.revokeObjectURL(url); + }, [backup, deviceId]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl" + > +
+
+ +

+ {t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })} +

+
+ +
+ +
+ {!backup ? ( + <> +

+ {t('app:backup.export_explainer', { + defaultValue: + 'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.', + })} +

+ +
+
+ + setPassphrase(e.target.value)} + placeholder="min. 8 Zeichen" + className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" + /> +
+
+ + setConfirm(e.target.value)} + className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" + /> +
+ {confirm.length > 0 && confirm !== passphrase && ( +

+ {t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })} +

+ )} +
+ +
+
+ + + {t('app:backup.export_warning', { + defaultValue: + 'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.', + })} + +
+
+ + {error && ( +

+ {error} +

+ )} + + ) : ( + <> +
+
+ + + {t('app:backup.export_success', { + defaultValue: + 'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.', + })} + +
+
+