Files
ChatApp/apps/desktop/src/components/SecurityCenter.tsx
T
byGalax 6c6828006b feat(P6B.T9): PIN-Idle-Auto-Lock setting + idle watcher
Adds opt-in (default OFF) auto-lock: after X minutes of no user input
the app calls signOut() (full memory wipe + PIN re-entry on next open).
Settings dropdown (Aus / 5 / 15 / 30 / 60 min) lives in SecurityCenter
below the existing wipe-on-close toggle. The idle timer is mounted in
AppShell via useIdleAutoLock; activity events are throttled to 1 Hz to
avoid timer thrash on rapid mouse movement. The localStorage key is
added to PRESERVE_LOCAL_STORAGE so a wipe never silently disables the
feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:59:27 +02:00

204 lines
8.7 KiB
TypeScript

import { useState } from 'react';
import {
type AutoLockMinutes,
getAutoLockMinutes,
notifyAutoLockChanged,
setAutoLockMinutes,
} from '../lib/autoLockSettings';
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
import {
changePin,
type LegacyMigrationReport,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
} from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { ShieldIcon, SpinnerIcon } from './icons';
interface Props { userId: string }
export function SecurityCenter({ userId }: Props) {
const [pinOld, setPinOld] = useState('');
const [pinNew, setPinNew] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [recovery, setRecovery] = useState<string | null>(null);
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
const [autoLockMinutes, setAutoLockMinutesState] = useState<AutoLockMinutes>(() => getAutoLockMinutes());
async function handleRetryMigration() {
setBusy(true); setMsg(null); setMigration(null);
try {
const report = await retryLegacyMigration(userId);
setMigration(report);
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
async function handleChangePin() {
setBusy(true); setMsg(null);
try {
await changePin({ userId, oldPin: pinOld, newPin: pinNew });
setMsg('PIN geändert.'); setPinOld(''); setPinNew('');
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
async function handleRegenerateRecovery() {
setBusy(true); setMsg(null);
try { setRecovery(await regenerateRecoveryCode({ userId })); }
catch (err) { setMsg(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
async function handleReset() {
if (!window.confirm('Identität wirklich zurücksetzen? Alle bisherigen Chats werden für dich unlesbar.')) return;
setBusy(true); setMsg(null);
try {
const code = await resetIdentity({ userId, pin: pinNew || pinOld });
setRecovery(code);
setMsg('Identität zurückgesetzt.');
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
return (
<div className="space-y-6 rounded-xl border border-line bg-surface-2 p-5">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-accent" />
<h2 className="text-sm font-semibold">Sicherheit</h2>
</div>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">PIN ändern</h3>
<div className="space-y-2">
<PinInput ariaLabel="Aktuelle PIN" value={pinOld} onChange={setPinOld} />
<PinInput ariaLabel="Neue PIN" value={pinNew} onChange={setPinNew} />
<button type="button"
disabled={busy || pinOld.length !== 6 || pinNew.length !== 6}
onClick={() => void handleChangePin()}
className="rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg disabled:opacity-60"
>
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}PIN ändern
</button>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Recovery-Code</h3>
<button type="button" disabled={busy} onClick={() => void handleRegenerateRecovery()}
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
>Neuen Recovery-Code erzeugen</button>
{recovery && (
<div className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest">
{recovery}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Schlüssel-Migration reparieren</h3>
<p className="mb-2 text-xs text-fg-muted">
Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen.
Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst.
</p>
<button type="button" disabled={busy} onClick={() => void handleRetryMigration()}
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
>
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}Migration erneut ausführen
</button>
{migration && (
<div className="mt-2 rounded border border-line bg-surface-3 p-3 text-xs text-fg-muted">
<div>Geräte (Server): {migration.serverDevices}</div>
<div>Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices}
{migration.strongholdKeysFromBundleScan > 0 && (
<> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan)</>
)}
</div>
<div>Versucht: {migration.attempted}, Erfolgreich: <span className="text-emerald-500">{migration.migrated}</span></div>
<div>Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}</div>
<div>Entschlüsselung gescheitert: {migration.decryptFailed}</div>
<div>Server-Fehler: {migration.rpcFailed}</div>
{migration.attempted > 0 && migration.migrated === 0 && (
<p className="mt-2 text-rose-300">
Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal.
Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren).
</p>
)}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
Cache beim Schließen leeren
</h3>
<p className="mb-2 text-xs text-fg-muted">
Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start
musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.
</p>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={wipeOnClose}
onChange={(e) => {
setWipeOnClose(e.target.checked);
setWipeOnCloseState(e.target.checked);
}}
/>
<span>Aktivieren</span>
</label>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
Auto-Lock nach Inaktivität
</h3>
<p className="mb-2 text-xs text-fg-muted">
Verlangt erneute PIN-Eingabe nach der gewählten Inaktivitätsdauer. Empfohlen für gemeinsam genutzte Rechner.
</p>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-fg">Automatisch sperren</div>
<div className="text-xs text-fg-muted">
Verlangt erneute PIN-Eingabe nach X Minuten Inaktivität.
</div>
</div>
<select
value={autoLockMinutes}
onChange={(e) => {
const next = Number(e.target.value) as AutoLockMinutes;
setAutoLockMinutes(next);
notifyAutoLockChanged(next);
setAutoLockMinutesState(next);
}}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<option value={0}>Aus</option>
<option value={5}>5 min</option>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
</select>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
<button type="button" disabled={busy} onClick={() => void handleReset()}
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-500/20"
>Identität zurücksetzen</button>
</section>
{msg && <p role="status" className="text-sm text-fg-muted">{msg}</p>}
</div>
);
}