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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user