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
+14 -2
View File
@@ -44,10 +44,22 @@ export async function checkForUpdate(): Promise<UpdateState> {
error: null,
};
} catch (err: unknown) {
console.warn('checkForUpdate failed', err);
// 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) {