a04ecf7a19
- Voice messages: MediaRecorder → encrypted attachment, custom waveform player via OfflineAudioContext, 60s limit + live mic-level meter - Offline message queue: localStorage outbox, exponential backoff retries, optimistic pending bubble with retry/discard - Delivery indicator: message_deliveries table + RLS (reciprocal receipts), ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack) - Per-participant volume slider in calls via right-click tile menu, persisted to localStorage, applied to attached audio elements - Group call scaling: grid up to 12 tiles with pagination, active-speaker auto-promotion in fullscreen - Push notifications scaffolding: service worker, VAPID subscription registration, notify-push edge function skeleton - Backup recovery code: 24-char base32 code (~120 bits entropy) as alternative decrypt path, restore UI with mode toggle - Admin panel: conversations list, audit log (admin_audit_log table + admin_log_action RPC), audit entry on user flag toggle - Search v2: sender filter, attachment-only toggle, date range - Reactions pop animation (scale 0.4→1.15→1 on count change) - Message list windowing (150 default, expand via IntersectionObserver) - Stub cleanup: removed dead ScreenshareStub from CallParticipantTile Fixes: - Focus-triggered flicker: dropped window.focus listeners in three spots, throttled visibilitychange/online wake-refreshes to 30s, keep existing data visible during background re-syncs (no more spinner on every click) - Voice attachment audio element collapsed to 0px on peer side — now forces 280px min-width on bubble Migrations (push required): 20260421000001_message_deliveries.sql 20260421000002_admin_audit_log.sql Server TODO: VAPID keys + notify-push edge function deploy
307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { type BackupBundle, exportDeviceBackupWithRecovery } 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 [bundle, setBundle] = useState<BackupBundle | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const [copiedRecovery, setCopiedRecovery] = useState(false);
|
|
|
|
const canGenerate = useMemo(() => {
|
|
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
|
}, [passphrase, confirm, busy]);
|
|
|
|
const reset = useCallback(() => {
|
|
setPassphrase('');
|
|
setConfirm('');
|
|
setBundle(null);
|
|
setError(null);
|
|
setCopied(false);
|
|
setCopiedRecovery(false);
|
|
}, []);
|
|
|
|
const handleClose = useCallback(() => {
|
|
reset();
|
|
onClose();
|
|
}, [reset, onClose]);
|
|
|
|
const handleGenerate = useCallback(async () => {
|
|
if (!canGenerate) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const b = await exportDeviceBackupWithRecovery({
|
|
userId,
|
|
deviceId,
|
|
privateKey,
|
|
passphrase,
|
|
});
|
|
setBundle(b);
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
|
|
|
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
if (marker === 'main') {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1500);
|
|
} else {
|
|
setCopiedRecovery(true);
|
|
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
|
}
|
|
} catch {
|
|
/* fall back — user can select manually */
|
|
}
|
|
};
|
|
|
|
const handleDownload = useCallback(() => {
|
|
if (!bundle) return;
|
|
const text =
|
|
'=== Passphrase backup ===\n' +
|
|
bundle.passphraseBackup +
|
|
'\n\n=== Recovery code ===\n' +
|
|
bundle.recoveryCode +
|
|
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
|
|
bundle.recoveryBackup +
|
|
'\n';
|
|
const blob = new Blob([text], { 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);
|
|
}, [bundle, 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">
|
|
{!bundle ? (
|
|
<>
|
|
<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>
|
|
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
|
Backup-String
|
|
</label>
|
|
<textarea
|
|
readOnly
|
|
value={bundle.passphraseBackup}
|
|
rows={6}
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
className="mt-1 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-2 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
|
|
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 ? 'Kopiert!' : '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"
|
|
>
|
|
Als Datei speichern (alles)
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
|
<div className="flex items-start gap-2">
|
|
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
|
Recovery-Code (Passphrase vergessen?)
|
|
</p>
|
|
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
|
|
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
|
|
ohne Passphrase wiederherstellen.
|
|
</p>
|
|
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
|
|
{bundle.recoveryCode}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
|
Recovery-Backup-String
|
|
</label>
|
|
<textarea
|
|
readOnly
|
|
value={bundle.recoveryBackup}
|
|
rows={6}
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
|
|
className="mt-2 inline-flex w-full 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>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
|
{!bundle ? (
|
|
<>
|
|
<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>
|
|
);
|
|
}
|