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)
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|