Files
ChatApp/apps/desktop/src/components/BackupPromptBanner.tsx
T
byGalax eb8f9857ff 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)
2026-04-20 19:07:31 +02:00

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}
/>
)}
</>
);
}