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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user