feat(desktop): Settings security center (PIN change / recovery / reset)

Drops the manual backup-string flow; replaces it with PIN change,
recovery-code regeneration, and identity reset (all sealed via the new
user_keys table).
This commit is contained in:
byGalax
2026-05-15 23:09:05 +02:00
parent 02e1af4517
commit b789f4b10d
7 changed files with 95 additions and 822 deletions
+5 -108
View File
@@ -8,17 +8,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog';
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
import { MicTestSection } from '../components/MicTestSection';
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
import { RingtoneSettings } from '../components/RingtoneSettings';
import { SecurityCenter } from '../components/SecurityCenter';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useTheme } from '../context/ThemeContext';
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart';
import {
AVATAR_TARGET_DIM,
@@ -34,7 +31,6 @@ import {
} from '../lib/bannerUpload';
import { ImageCropDialog } from '../components/ImageCropDialog';
import { Lightbox } from '../components/Lightbox';
import { devLocalSecretStore } from '../lib/secretStore';
import {
getPttSettings,
keyCodeToLabel,
@@ -83,7 +79,7 @@ const LOCALE_LABELS: Record<SupportedLocale, string> = {
export function SettingsPage() {
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
const { profile, device, refreshProfile, signOut } = useAuth();
const { profile, refreshProfile, signOut } = useAuth();
const [busy, setBusy] = useState(false);
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
@@ -222,22 +218,9 @@ export function SettingsPage() {
<ScreenShareControls />
</Section>
{/* Devices */}
<Section title={t('app:settings.section_devices')}>
{device && (
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-200">
<LockIcon className="h-4 w-4" />
{t('app:settings.this_device')}
</div>
<dl className="mt-3 space-y-1.5 text-xs">
<Row label={t('auth:signed_in.display_name')} value={device.name} />
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
</dl>
</div>
)}
<DeviceKeyBackupControls />
{/* Security */}
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
{profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section>
{/* Danger zone */}
@@ -1094,92 +1077,6 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
);
}
function DeviceKeyBackupControls() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();
const [open, setOpen] = useState(false);
const [restoreOpen, setRestoreOpen] = useState(false);
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
const [err, setErr] = useState<string | null>(null);
const canRun = !!profile?.userId && !!device?.id;
async function handleOpen() {
if (!canRun) return;
setErr(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
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');
}
}
function handleClose() {
setOpen(false);
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setPrivateKey(null);
}
return (
<div className="mt-4 rounded-xl border border-line bg-surface-3 p-4">
<div className="text-sm font-semibold text-fg">
{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_v2', {
defaultValue:
'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-3 flex flex-wrap gap-2">
<button
type="button"
disabled={!canRun}
onClick={() => void handleOpen()}
className="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:backup.export_open', { defaultValue: 'Backup erstellen' })}
</button>
<button
type="button"
disabled={!profile?.userId}
onClick={() => setRestoreOpen(true)}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-semibold text-fg transition hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:backup.restore_open', { defaultValue: 'Backup wiederherstellen' })}
</button>
</div>
{err && (
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
)}
{open && profile && device && privateKey && (
<BackupExportDialog
open={open}
userId={profile.userId}
deviceId={device.id}
privateKey={privateKey}
onClose={handleClose}
/>
)}
{profile && (
<BackupRestoreDialog
open={restoreOpen}
userId={profile.userId}
onClose={() => setRestoreOpen(false)}
/>
)}
</div>
);
}
function ScreenShareControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());