eb8f9857ff
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)
252 lines
10 KiB
TypeScript
252 lines
10 KiB
TypeScript
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<string | null>(null);
|
|
const [backup, setBackup] = useState<string | null>(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 (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
onClick={handleClose}
|
|
>
|
|
<div
|
|
onClick={(e) => 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"
|
|
>
|
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldIcon className="h-4 w-4 text-accent" />
|
|
<h3 className="font-display text-sm font-semibold text-fg">
|
|
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
aria-label="Close"
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto p-5">
|
|
{!backup ? (
|
|
<>
|
|
<p className="text-sm text-fg-muted">
|
|
{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.',
|
|
})}
|
|
</p>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
<div className="space-y-1">
|
|
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
|
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
autoFocus
|
|
minLength={8}
|
|
value={passphrase}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
|
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={confirm}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
{confirm.length > 0 && confirm !== passphrase && (
|
|
<p className="text-xs text-rose-500 dark:text-rose-300">
|
|
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
|
<div className="flex items-start gap-2">
|
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
|
<span>
|
|
{t('app:backup.export_warning', {
|
|
defaultValue:
|
|
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
|
|
<div className="flex items-start gap-2">
|
|
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<span>
|
|
{t('app:backup.export_success', {
|
|
defaultValue:
|
|
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
readOnly
|
|
value={backup}
|
|
rows={8}
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
className="mt-3 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
|
/>
|
|
<div className="mt-3 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCopy()}
|
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
|
>
|
|
<CopyIcon className="h-4 w-4" />
|
|
<span>
|
|
{copied
|
|
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
|
|
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDownload}
|
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
|
>
|
|
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
|
{!backup ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
|
>
|
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleGenerate()}
|
|
disabled={!canGenerate}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
|
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
|
|
>
|
|
{t('app:backup.done', { defaultValue: 'Fertig' })}
|
|
</button>
|
|
)}
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|