feat: device backup/restore + quick wins + username casing
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)
This commit is contained in:
@@ -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() {
|
||||
</main>
|
||||
</div>
|
||||
<CallUI />
|
||||
<BackupPromptBanner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { BackupExportDialog } from './BackupExportDialog';
|
||||
import { ShieldIcon, XIcon } from './icons';
|
||||
|
||||
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
|
||||
const SESSION_KEY = 'chatapp.backup.prompt';
|
||||
|
||||
// Post-registration nudge: right after a fresh device provision we set
|
||||
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
|
||||
// shows a floating "mach jetzt ein Backup" banner until the user either
|
||||
// creates one or explicitly dismisses (persisted in localStorage so we stop
|
||||
// nagging across reloads).
|
||||
export function BackupPromptBanner() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
|
||||
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
|
||||
setVisible(true);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((persist: boolean) => {
|
||||
setVisible(false);
|
||||
try {
|
||||
window.sessionStorage.removeItem(SESSION_KEY);
|
||||
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openDialog = useCallback(async () => {
|
||||
if (!profile?.userId || !device?.id) return;
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||
if (!priv) return;
|
||||
setPrivateKey(priv);
|
||||
setDialogOpen(true);
|
||||
}, [profile, device]);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
if (privateKey) {
|
||||
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||
}
|
||||
setPrivateKey(null);
|
||||
// After the user interacts with the dialog, drop the banner regardless
|
||||
// of whether they actually completed the backup — they're aware now.
|
||||
dismiss(true);
|
||||
}, [privateKey, dismiss]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
|
||||
>
|
||||
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">
|
||||
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
|
||||
{t('app:backup.prompt_body', {
|
||||
defaultValue:
|
||||
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDialog()}
|
||||
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||||
>
|
||||
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(true)}
|
||||
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
|
||||
>
|
||||
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(false)}
|
||||
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
|
||||
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dialogOpen && profile?.userId && device?.id && privateKey && (
|
||||
<BackupExportDialog
|
||||
open={dialogOpen}
|
||||
userId={profile.userId}
|
||||
deviceId={device.id}
|
||||
privateKey={privateKey}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
type DeviceRecord,
|
||||
restoreDeviceFromServerRecord,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
decodePrivateKeyFromBackup,
|
||||
importDeviceBackup,
|
||||
} from '../lib/deviceBackup';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { writeLocalDeviceId } from '../lib/device';
|
||||
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
onRestored: (device: DeviceRecord) => void;
|
||||
}
|
||||
|
||||
// Restores a device from a user-provided backup string. The backup embeds
|
||||
// userId + deviceId + X25519 private key; we verify userId matches the current
|
||||
// session, confirm the device row still exists server-side, and then re-seed
|
||||
// the local vault + cached deviceId so the app treats this install as the
|
||||
// original device (conv-key bundles stay valid, no "awaiting" state).
|
||||
export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const [backup, setBackup] = useState('');
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!backup.trim() || passphrase.length < 1 || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
let privateKey: Uint8Array | null = null;
|
||||
try {
|
||||
const payload = await importDeviceBackup(backup.trim(), passphrase);
|
||||
privateKey = decodePrivateKeyFromBackup(payload);
|
||||
|
||||
const device = await restoreDeviceFromServerRecord({
|
||||
client: supabase,
|
||||
secretStore: devLocalSecretStore,
|
||||
userId: payload.userId,
|
||||
deviceId: payload.deviceId,
|
||||
privateKey,
|
||||
});
|
||||
|
||||
// Cache deviceId locally so findExistingDevice picks it up on next load.
|
||||
writeLocalDeviceId(userId, device.id);
|
||||
|
||||
onRestored(device);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (privateKey) {
|
||||
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[backup, passphrase, userId, onRestored, busy],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
|
||||
<ShieldIcon className="h-5 w-5 text-emerald-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-white">
|
||||
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{t('app:backup.restore_subtitle', {
|
||||
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={5}
|
||||
value={backup}
|
||||
onChange={(e) => setBackup(e.target.value)}
|
||||
placeholder="chatapp-backup-v1…"
|
||||
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
|
||||
<span className="min-w-0 flex-1 break-words">
|
||||
{t('app:backup.restore_hint', {
|
||||
defaultValue:
|
||||
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
|
||||
aria-busy={busy}
|
||||
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user