From eb8f9857ff350472759df2ea86c5a10ad17d6fa0 Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Mon, 20 Apr 2026 19:07:31 +0200 Subject: [PATCH] feat: device backup/restore + quick wins + username casing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backup / restore flow: - deviceBackup.ts: v2 format wrapping userId + deviceId + privateKey in an encrypted JSON payload so restore can re-seed localStorage, vault, and reattach to the existing server-side device row without provisioning a new one (conv-key bundles stay valid, no "awaiting key" state) - shared/auth: restoreDeviceFromServerRecord — verifies session.user.id matches the backup's userId, confirms the server device row still exists, then writes the private key into the local secret store - BackupExportDialog — passphrase + confirm, generates portable string, copy + download .txt - DeviceRestore — textarea + passphrase → seeds vault + writes deviceId cache, treats this install as the original device - DevicePage now has tabs "Neu einrichten" / "Backup wiederherstellen" - BackupPromptBanner — post-registration nudge, reads sessionStorage signal from fresh provisions and persists "never-ask-again" in localStorage so it stops nagging - SettingsPage backup section: uses the new dialog; removes the dangerous in-place key import (restore now lives in the device flow) Username casing: - Migration 20260420000002 drops lower() from the handle_new_user trigger and widens the regex to [A-Za-z0-9_]. profiles.username is citext so uniqueness + lookups stay case-insensitive regardless of stored casing - Shared auth: trim() only, no toLowerCase on signup/lookups/search. ilike handles CI anyway and citext makes client normalisation redundant - AuthPage regex + input preserve case, FriendsPage search preserves case - i18n (de+en): updated username_hint / username_invalid / ERR_USERNAME_INVALID to reflect the new rule Quick wins: - React Router v7 future flags (v7_startTransition + v7_relativeSplatPath) set on BrowserRouter — silences the upgrade warning - appUpdates.checkForUpdate: swallow benign network/fetch/"could not fetch valid release JSON" cases silently instead of console spam - osNotify: persist an "asked" marker in localStorage so the permission prompt only fires once per install (OS already persists the answer, but the plugin re-queries loudly otherwise) --- apps/desktop/src/App.tsx | 14 +- apps/desktop/src/components/AppShell.tsx | 2 + .../src/components/BackupExportDialog.tsx | 251 ++++++++++++++++++ .../src/components/BackupPromptBanner.tsx | 122 +++++++++ apps/desktop/src/components/DeviceRestore.tsx | 160 +++++++++++ apps/desktop/src/lib/appUpdates.ts | 16 +- apps/desktop/src/lib/device.ts | 2 +- apps/desktop/src/lib/deviceBackup.ts | 49 ++++ apps/desktop/src/lib/osNotify.ts | 29 +- apps/desktop/src/pages/AuthPage.tsx | 4 +- apps/desktop/src/pages/DevicePage.tsx | 86 +++++- apps/desktop/src/pages/FriendsPage.tsx | 2 +- apps/desktop/src/pages/SettingsPage.tsx | 167 +++--------- packages/shared/src/auth/device.ts | 50 ++++ packages/shared/src/auth/magic-link.ts | 7 +- packages/shared/src/auth/profile.ts | 6 +- packages/shared/src/friends/index.ts | 2 +- packages/shared/src/i18n/locales/de/auth.json | 4 +- .../shared/src/i18n/locales/de/errors.json | 2 +- packages/shared/src/i18n/locales/en/auth.json | 4 +- .../shared/src/i18n/locales/en/errors.json | 2 +- .../20260420000002_username_preserve_case.sql | 71 +++++ 22 files changed, 895 insertions(+), 157 deletions(-) create mode 100644 apps/desktop/src/components/BackupExportDialog.tsx create mode 100644 apps/desktop/src/components/BackupPromptBanner.tsx create mode 100644 apps/desktop/src/components/DeviceRestore.tsx create mode 100644 supabase/migrations/20260420000002_username_preserve_case.sql 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.', + })} + +
+
+