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:
2026-04-20 19:07:31 +02:00
parent de431386ea
commit eb8f9857ff
22 changed files with 895 additions and 157 deletions
+77 -9
View File
@@ -1,11 +1,18 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { DeviceRegistration } from '../components/DeviceRegistration';
import { DeviceRestore } from '../components/DeviceRestore';
import { useAuth } from '../context/AuthContext';
type Mode = 'register' | 'restore';
export function DevicePage() {
const { t } = useTranslation(['app']);
const { session, profile, setDevice } = useAuth();
const navigate = useNavigate();
const [mode, setMode] = useState<Mode>('register');
if (!session) return null; // Guarded by RequireAuth, but be defensive.
@@ -15,20 +22,81 @@ export function DevicePage() {
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
<BackgroundStage />
<div className="relative z-10 w-full max-w-md">
<DeviceRegistration
userId={session.user.id}
defaultName={defaultName}
onRegistered={(device) => {
setDevice(device);
navigate('/chats', { replace: true });
}}
/>
<div className="relative z-10 w-full max-w-md space-y-3">
<div
role="tablist"
aria-label={t('app:backup.mode_label', { defaultValue: 'Gerätemodus' })}
className="flex gap-1 rounded-xl border border-white/10 bg-ink-900/60 p-1 backdrop-blur-xl"
>
<TabButton
active={mode === 'register'}
onClick={() => setMode('register')}
label={t('app:backup.mode_register', { defaultValue: 'Neu einrichten' })}
/>
<TabButton
active={mode === 'restore'}
onClick={() => setMode('restore')}
label={t('app:backup.mode_restore', { defaultValue: 'Backup wiederherstellen' })}
/>
</div>
{mode === 'register' ? (
<DeviceRegistration
userId={session.user.id}
defaultName={defaultName}
onRegistered={(device) => {
setDevice(device);
// Signal the chats page to open the post-registration backup
// prompt (AppShell reads this on mount).
try {
window.sessionStorage.setItem('chatapp.backup.prompt', '1');
} catch {
/* storage disabled — skip hint */
}
navigate('/chats', { replace: true });
}}
/>
) : (
<DeviceRestore
userId={session.user.id}
onRestored={(device) => {
setDevice(device);
navigate('/chats', { replace: true });
}}
/>
)}
</div>
</main>
);
}
function TabButton({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={
'flex-1 cursor-pointer rounded-lg px-3 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active
? 'bg-brand-500/20 text-brand-100 ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100')
}
>
{label}
</button>
);
}
function BackgroundStage() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">