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
+13 -1
View File
@@ -36,7 +36,19 @@ export function App() {
<FriendshipsProvider>
<ConversationsProvider>
<CallProvider>
<BrowserRouter>
<BrowserRouter
future={{
// Opt into v7 behaviour early so the upgrade is a no-op:
// - `v7_startTransition` wraps navigations in startTransition
// so Suspense / concurrent rendering deal with the new tree
// - `v7_relativeSplatPath` matches relative paths inside
// splat routes against the parent splat segment (not the
// full matched path). Our tree has no splat routes today
// but this kills the runtime warning and future-proofs.
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Routes>
<Route element={<RouteBoundary scope="auth" />}>
<Route path="/auth" element={<AuthPage />} />
+2
View File
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { ensureNotificationPermission } from '../lib/osNotify';
import { BackupPromptBanner } from './BackupPromptBanner';
import { CallUI } from './CallUI';
import { Sidebar } from './Sidebar';
@@ -32,6 +33,7 @@ export function AppShell() {
</main>
</div>
<CallUI />
<BackupPromptBanner />
</div>
);
}
@@ -0,0 +1,251 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { exportDeviceBackup } 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 [backup, setBackup] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const canGenerate = useMemo(() => {
return passphrase.length >= 8 && passphrase === confirm && !busy;
}, [passphrase, confirm, busy]);
const reset = useCallback(() => {
setPassphrase('');
setConfirm('');
setBackup(null);
setError(null);
setCopied(false);
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
const handleGenerate = useCallback(async () => {
if (!canGenerate) return;
setBusy(true);
setError(null);
try {
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
setBackup(str);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
const handleCopy = useCallback(async () => {
if (!backup) return;
try {
await navigator.clipboard.writeText(backup);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
/* fall back — user can select manually */
}
}, [backup]);
const handleDownload = useCallback(() => {
if (!backup) return;
const blob = new Blob([backup], { 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);
}, [backup, 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">
{!backup ? (
<>
<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>
<textarea
readOnly
value={backup}
rows={8}
onFocus={(e) => e.currentTarget.select()}
className="mt-3 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-3 flex gap-2">
<button
type="button"
onClick={() => void handleCopy()}
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
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
: t('app:backup.copy', { defaultValue: '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"
>
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
</button>
</div>
</>
)}
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{!backup ? (
<>
<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>
);
}
@@ -0,0 +1,122 @@
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}
/>
)}
</>
);
}
@@ -0,0 +1,160 @@
import {
type DeviceRecord,
restoreDeviceFromServerRecord,
} from '@chat-app/shared/auth';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
decodePrivateKeyFromBackup,
importDeviceBackup,
} from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { writeLocalDeviceId } from '../lib/device';
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
onRestored: (device: DeviceRecord) => void;
}
// Restores a device from a user-provided backup string. The backup embeds
// userId + deviceId + X25519 private key; we verify userId matches the current
// session, confirm the device row still exists server-side, and then re-seed
// the local vault + cached deviceId so the app treats this install as the
// original device (conv-key bundles stay valid, no "awaiting" state).
export function DeviceRestore({ userId, onRestored }: Props) {
const { t } = useTranslation(['app', 'errors']);
const [backup, setBackup] = useState('');
const [passphrase, setPassphrase] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!backup.trim() || passphrase.length < 1 || busy) return;
setBusy(true);
setError(null);
let privateKey: Uint8Array | null = null;
try {
const payload = await importDeviceBackup(backup.trim(), passphrase);
privateKey = decodePrivateKeyFromBackup(payload);
const device = await restoreDeviceFromServerRecord({
client: supabase,
secretStore: devLocalSecretStore,
userId: payload.userId,
deviceId: payload.deviceId,
privateKey,
});
// Cache deviceId locally so findExistingDevice picks it up on next load.
writeLocalDeviceId(userId, device.id);
onRestored(device);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setBusy(false);
}
},
[backup, passphrase, userId, onRestored, busy],
);
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
<ShieldIcon className="h-5 w-5 text-emerald-300" />
</div>
<div>
<h2 className="font-display text-lg font-semibold text-white">
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
</h2>
<p className="text-xs text-neutral-400">
{t('app:backup.restore_subtitle', {
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
})}
</p>
</div>
</div>
<div className="mt-6 space-y-3">
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
</label>
<textarea
required
rows={5}
value={backup}
onChange={(e) => setBackup(e.target.value)}
placeholder="chatapp-backup-v1…"
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
</label>
<input
type="password"
required
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
</div>
</div>
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
<div className="flex items-start gap-2">
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
<span className="min-w-0 flex-1 break-words">
{t('app:backup.restore_hint', {
defaultValue:
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
})}
</span>
</div>
</div>
<button
type="submit"
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
aria-busy={busy}
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
</>
) : (
<>
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
{error && (
<div
role="alert"
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
</form>
);
}
+13 -1
View File
@@ -44,10 +44,22 @@ export async function checkForUpdate(): Promise<UpdateState> {
error: null,
};
} catch (err: unknown) {
// Swallow "no release on GitHub yet" / network-unreachable cases silently.
// The updater endpoint serves `latest.json` from GitHub releases; a fresh
// repo or offline machine produces a generic "Could not fetch a valid
// release JSON" error that has no actionable information for the user —
// logging it on every launch just pollutes the console.
const msg = err instanceof Error ? err.message : String(err);
const benign =
/could not fetch a valid release json|network|timed? out|failed to fetch|connection/i.test(
msg,
);
if (!benign) {
console.warn('checkForUpdate failed', err);
}
return {
...IDLE_UPDATE_STATE,
error: err instanceof Error ? err.message : 'update check failed',
error: benign ? null : msg,
};
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ export function readLocalDeviceId(userId: string): string | null {
return window.localStorage.getItem(deviceIdStorageKey(userId));
}
function writeLocalDeviceId(userId: string, deviceId: string): void {
export function writeLocalDeviceId(userId: string, deviceId: string): void {
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
}
+49
View File
@@ -91,3 +91,52 @@ export async function importDeviceKey(
s.memzero(key);
}
}
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
// before encrypting, so a restore flow can re-seed localStorage + vault + server
// device row without requiring the user to remember IDs.
export interface DeviceBackupPayload {
v: 2;
userId: string;
deviceId: string;
privateKeyB64: string;
}
export async function exportDeviceBackup(
params: {
userId: string;
deviceId: string;
privateKey: Uint8Array;
passphrase: string;
},
): Promise<string> {
const payload: DeviceBackupPayload = {
v: 2,
userId: params.userId,
deviceId: params.deviceId,
privateKeyB64: b64url(params.privateKey),
};
const bytes = new TextEncoder().encode(JSON.stringify(payload));
return exportDeviceKey(bytes, params.passphrase);
}
export async function importDeviceBackup(
backup: string,
passphrase: string,
): Promise<DeviceBackupPayload> {
const plain = await importDeviceKey(backup, passphrase);
const text = new TextDecoder().decode(plain);
try {
const obj = JSON.parse(text) as DeviceBackupPayload;
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
return obj;
}
} catch {
/* fall through */
}
throw new Error('Backup format not supported — v2 expected');
}
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
return unb64url(payload.privateKeyB64);
}
+27 -2
View File
@@ -7,10 +7,31 @@ import {
import { isTauriRuntime } from './globalShortcut';
// Tracks whether permission has already been requested this session so we
// don't spam the OS prompt. Actual permission state lives in the OS.
// don't spam the OS prompt. Actual permission state lives in the OS, but we
// also persist a "we've asked" marker in localStorage so reloads don't
// re-request (OS would block anyway after denial, but calling it every reload
// triggers noisy plugin warnings on some platforms).
let permissionChecked = false;
let permissionGranted = false;
const ASKED_KEY = 'chatapp.notif.asked';
function readAskedMarker(): boolean {
try {
return window.localStorage.getItem(ASKED_KEY) === '1';
} catch {
return false;
}
}
function writeAskedMarker(): void {
try {
window.localStorage.setItem(ASKED_KEY, '1');
} catch {
/* quota / private mode */
}
}
export async function ensureNotificationPermission(): Promise<boolean> {
if (permissionChecked) return permissionGranted;
permissionChecked = true;
@@ -21,9 +42,13 @@ export async function ensureNotificationPermission(): Promise<boolean> {
}
try {
let granted = await isPermissionGranted();
if (!granted) {
if (!granted && !readAskedMarker()) {
// First-install: prompt the user once. After this we remember via the
// marker and never re-prompt — the user can re-enable later via OS
// system settings if they change their mind.
const result = await requestPermission();
granted = result === 'granted';
writeAskedMarker();
}
permissionGranted = granted;
} catch (err: unknown) {
+2 -2
View File
@@ -38,7 +38,7 @@ type UiState =
| { kind: 'sent'; email: string }
| { kind: 'error'; message: string };
const USERNAME_PATTERN = /^[a-z0-9_]{3,32}$/;
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,32}$/;
export function AuthPage() {
const { session } = useAuth();
@@ -314,7 +314,7 @@ function FormCard({
required
placeholder={t('auth:fields.username_placeholder')}
value={username}
onChange={(e) => onUsernameChange(e.target.value.toLowerCase())}
onChange={(e) => onUsernameChange(e.target.value)}
pattern={USERNAME_PATTERN.source}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
+69 -1
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">
<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">
+1 -1
View File
@@ -121,7 +121,7 @@ export function FriendsPage() {
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value.toLowerCase())}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('app:friends.search_placeholder')}
autoComplete="off"
autoCapitalize="none"
+34 -123
View File
@@ -8,11 +8,11 @@ import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog';
import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext';
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore';
import {
getPttSettings,
@@ -447,65 +447,32 @@ function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
function DeviceKeyBackupControls() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();
const [busy, setBusy] = useState(false);
const [backupOut, setBackupOut] = useState<string | null>(null);
const [exportPass, setExportPass] = useState('');
const [importPass, setImportPass] = useState('');
const [importBlob, setImportBlob] = useState('');
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [open, setOpen] = useState(false);
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
const [err, setErr] = useState<string | null>(null);
const canRun = !!profile?.userId && !!device?.id;
async function handleExport() {
async function handleOpen() {
if (!canRun) return;
setMsg(null);
setBusy(true);
setErr(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
if (!priv) throw new Error('No device key on this install');
const out = await exportDeviceKey(priv, exportPass);
setBackupOut(out);
setExportPass('');
setMsg({
kind: 'ok',
text: t('app:settings.backup_export_ok', {
defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.',
}),
});
} catch (err: unknown) {
setMsg({
kind: 'err',
text: err instanceof Error ? err.message : 'export failed',
});
} finally {
setBusy(false);
if (!priv) throw new Error(t('app:backup.no_key_here', {
defaultValue: 'Kein Geräteschlüssel auf dieser Installation.',
}));
setPrivateKey(priv);
setOpen(true);
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'failed to load key');
}
}
async function handleImport() {
if (!canRun) return;
setMsg(null);
setBusy(true);
try {
const priv = await importDeviceKey(importBlob.trim(), importPass);
await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv);
setImportBlob('');
setImportPass('');
setMsg({
kind: 'ok',
text: t('app:settings.backup_import_ok', {
defaultValue:
'Schlüssel importiert. Beim nächsten Reload sollten alte Nachrichten lesbar sein.',
}),
});
} catch (err: unknown) {
setMsg({
kind: 'err',
text: err instanceof Error ? err.message : 'import failed',
});
} finally {
setBusy(false);
function handleClose() {
setOpen(false);
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setPrivateKey(null);
}
return (
@@ -514,87 +481,31 @@ function DeviceKeyBackupControls() {
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
</div>
<p className="mt-1 text-xs text-fg-muted">
{t('app:settings.device_key_backup_hint', {
{t('app:settings.device_key_backup_hint_v2', {
defaultValue:
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.',
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
})}
</p>
<div className="mt-4 space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{t('app:settings.backup_export', { defaultValue: 'Export' })}
</div>
<div className="flex gap-2">
<input
type="password"
value={exportPass}
onChange={(e) => setExportPass(e.target.value)}
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || exportPass.length < 8 || !canRun}
onClick={() => void handleExport()}
className="cursor-pointer rounded-lg bg-accent px-4 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canRun}
onClick={() => void handleOpen()}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
</button>
</div>
{backupOut && (
<textarea
readOnly
value={backupOut}
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
rows={3}
className="w-full rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2 font-mono text-[10px] text-emerald-700 dark:text-emerald-200"
/>
{err && (
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
)}
</div>
<div className="mt-4 space-y-2 border-t border-line pt-4">
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{t('app:settings.backup_import', { defaultValue: 'Import' })}
</div>
<textarea
value={importBlob}
onChange={(e) => setImportBlob(e.target.value)}
rows={3}
placeholder={t('app:settings.backup_blob_placeholder', {
defaultValue: 'chatapp-backup-v1.…',
})}
className="w-full rounded-lg border border-line bg-surface-3 p-2 font-mono text-[11px] text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
{open && profile && device && privateKey && (
<BackupExportDialog
open={open}
userId={profile.userId}
deviceId={device.id}
privateKey={privateKey}
onClose={handleClose}
/>
<div className="flex gap-2">
<input
type="password"
value={importPass}
onChange={(e) => setImportPass(e.target.value)}
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || !importBlob || !importPass || !canRun}
onClick={() => void handleImport()}
className="cursor-pointer rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
</button>
</div>
</div>
{msg && (
<p
className={
'mt-3 text-xs ' +
(msg.kind === 'ok'
? 'text-emerald-600 dark:text-emerald-300'
: 'text-rose-600 dark:text-rose-300')
}
>
{msg.text}
</p>
)}
</div>
);
+50
View File
@@ -152,6 +152,56 @@ export async function saveDevicePrivateKey(
await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey);
}
// Restores a device record from a backup by re-seeding the local private key
// store for an EXISTING server-side device row. Does NOT insert a new row —
// the original row is kept intact so conversation-key bundles stay valid.
// Throws when the server-side device was removed (the backup is then unusable;
// user must provision a fresh device and get conv-keys shared from another
// live device).
export async function restoreDeviceFromServerRecord(params: {
client: AppSupabaseClient;
secretStore: SecretStore;
userId: string;
deviceId: string;
privateKey: Uint8Array;
}): Promise<DeviceRecord> {
const { data: session } = await params.client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
if (session.user.id !== params.userId) {
throw new Error(
'Backup is for a different account — sign in as the owner before restoring.',
);
}
const { data: row, error } = await params.client
.from('devices')
.select('id, name, platform, public_key, last_seen_at')
.eq('id', params.deviceId)
.eq('user_id', params.userId)
.maybeSingle();
if (error) throw error;
if (!row) {
throw new Error(
'Device record not found on server — it was removed. Backup is no longer valid; register a new device instead.',
);
}
await saveDevicePrivateKey(
params.secretStore,
params.userId,
params.deviceId,
params.privateKey,
);
return {
id: row.id,
name: row.name,
platform: row.platform,
publicKey: pgHexToBytes(row.public_key),
lastSeenAt: row.last_seen_at,
};
}
// Lightweight helpers for platforms that want to cache their current device id
// in JSON storage (separate from the secret store, which only holds raw bytes).
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
+5 -2
View File
@@ -3,7 +3,8 @@ import type { AppSupabaseClient } from '../supabase/client.js';
export interface SignupParams {
email: string;
// Login handle. Lowercased server-side; must match ^[a-z0-9_]{3,32}$ once lowercased.
// Login handle. Stored with original casing but uniqueness is case-insensitive
// (citext). Must match ^[A-Za-z0-9_]{3,32}$.
username: string;
// Optional human-readable name. Defaults to username server-side if omitted.
displayName?: string;
@@ -29,7 +30,9 @@ export async function signUpWithMagicLink(
shouldCreateUser: true,
data: {
invite_code: params.inviteCode,
username: params.username.toLowerCase(),
// Preserve case. `profiles.username` is citext so uniqueness + lookups
// stay case-insensitive regardless of stored casing.
username: params.username.trim(),
display_name: params.displayName ?? params.username,
...(params.locale ? { locale: params.locale } : {}),
},
+4 -2
View File
@@ -72,7 +72,8 @@ export async function getProfileByUsername(
const { data, error } = await client
.from('profiles')
.select(PROFILE_COLS)
.eq('username', username.toLowerCase())
// citext column compares CI server-side — send raw input, don't force case.
.eq('username', username.trim())
.maybeSingle();
if (error) throw error;
return data ? mapProfile(data as unknown as ProfileRow) : null;
@@ -85,7 +86,8 @@ export async function isUsernameAvailable(
const { count, error } = await client
.from('profiles')
.select('user_id', { count: 'exact', head: true })
.eq('username', username.toLowerCase());
// citext compares CI — no manual normalization needed.
.eq('username', username.trim());
if (error) throw error;
return (count ?? 0) === 0;
}
+1 -1
View File
@@ -55,7 +55,7 @@ export async function searchProfiles(
query: string,
limit = 10,
): Promise<ProfileBrief[]> {
const trimmed = query.trim().toLowerCase();
const trimmed = query.trim();
if (trimmed.length < 2) return [];
const myId = await currentUserId(client);
@@ -30,8 +30,8 @@
"email_placeholder": "du@beispiel.de",
"username": "Benutzername",
"username_placeholder": "dennis",
"username_hint": "Damit meldest du dich an. Nur Kleinbuchstaben.",
"username_invalid": "Kleinbuchstaben az, Ziffern, Unterstrich · 332 Zeichen.",
"username_hint": "Damit meldest du dich an. Groß-/Kleinschreibung bleibt, ist aber nicht unterscheidbar (dennis = Dennis).",
"username_invalid": "Buchstaben, Ziffern oder Unterstrich · 332 Zeichen.",
"invite_code": "Einladungscode",
"invite_hint": "Pflichtfeld · Zugang nur auf Einladung."
},
@@ -3,7 +3,7 @@
"network": "Netzwerkfehler. Prüfe deine Verbindung.",
"ERR_NOT_AUTH": "Du bist nicht angemeldet.",
"ERR_INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"ERR_USERNAME_INVALID": "Benutzername muss aus Kleinbuchstaben az, Ziffern oder Unterstrich bestehen (332 Zeichen).",
"ERR_USERNAME_INVALID": "Benutzername darf nur aus Buchstaben (AZ, az), Ziffern oder Unterstrich bestehen (332 Zeichen).",
"ERR_INVITES_DISABLED": "Registrierungen sind derzeit vom Admin deaktiviert.",
"ERR_INVITE_NOT_FOUND": "Einladungscode nicht gefunden.",
"ERR_INVITE_DISABLED": "Diese Einladung wurde deaktiviert.",
@@ -30,8 +30,8 @@
"email_placeholder": "you@example.com",
"username": "Username",
"username_placeholder": "dennis",
"username_hint": "You log in with this. Lowercase only.",
"username_invalid": "Lowercase az, digits, underscore · 332 chars.",
"username_hint": "You log in with this. Case is preserved but not unique (dennis = Dennis).",
"username_invalid": "Letters, digits, or underscore · 332 chars.",
"invite_code": "Invite code",
"invite_hint": "Required · invite-only access."
},
@@ -3,7 +3,7 @@
"network": "Network error. Check your connection.",
"ERR_NOT_AUTH": "You are not signed in.",
"ERR_INVITE_CODE_REQUIRED": "Invite code is required.",
"ERR_USERNAME_INVALID": "Username must be lowercase az, digits, underscore, 332 chars.",
"ERR_USERNAME_INVALID": "Username must be letters (AZ, az), digits, or underscore, 332 chars.",
"ERR_INVITES_DISABLED": "Signups are currently disabled by the admin.",
"ERR_INVITE_NOT_FOUND": "Invite code not found.",
"ERR_INVITE_DISABLED": "This invite has been disabled.",
@@ -0,0 +1,71 @@
-- Keep raw casing on usernames at signup.
--
-- `profiles.username` is already `citext` so unique + lookup checks are
-- case-insensitive regardless of stored casing. Previously the signup
-- trigger force-lowercased via `lower(trim(...))`, losing the user's
-- preferred display case. Drop the lower(), widen the validation regex to
-- accept A-Z, keep trim() + uniqueness semantics.
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
v_invite_code text;
v_username text;
v_display_name text;
v_locale text;
v_invite public.invites%rowtype;
begin
v_invite_code := new.raw_user_meta_data->>'invite_code';
v_username := trim(new.raw_user_meta_data->>'username');
v_display_name := nullif(trim(new.raw_user_meta_data->>'display_name'), '');
v_locale := lower(trim(new.raw_user_meta_data->>'locale'));
if v_display_name is null then
v_display_name := v_username;
end if;
if v_locale is null or v_locale not in ('en', 'de') then
v_locale := 'en';
end if;
if v_invite_code is null or length(v_invite_code) = 0 then
raise exception 'ERR_INVITE_CODE_REQUIRED';
end if;
if v_username is null or v_username !~ '^[A-Za-z0-9_]{3,32}$' then
raise exception 'ERR_USERNAME_INVALID';
end if;
if not coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) then
raise exception 'ERR_INVITES_DISABLED';
end if;
select * into v_invite from public.invites
where code = v_invite_code
for update;
if not found then
raise exception 'ERR_INVITE_NOT_FOUND';
end if;
if v_invite.disabled then
raise exception 'ERR_INVITE_DISABLED';
end if;
if v_invite.expires_at is not null and v_invite.expires_at < now() then
raise exception 'ERR_INVITE_EXPIRED';
end if;
if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then
raise exception 'ERR_INVITE_EXHAUSTED';
end if;
update public.invites
set uses_count = uses_count + 1
where code = v_invite.code;
insert into public.profiles (user_id, username, display_name, locale)
values (new.id, v_username, v_display_name, v_locale);
return new;
end;
$$;