diff --git a/apps/desktop/src/components/AppShell.tsx b/apps/desktop/src/components/AppShell.tsx index 47d2fd0..fc6396f 100644 --- a/apps/desktop/src/components/AppShell.tsx +++ b/apps/desktop/src/components/AppShell.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; +import { useIdleAutoLock } from '../hooks/useIdleAutoLock'; import { startConversationKeySync } from '../lib/conversationKeySync'; import { startDeviceApprovalListener } from '../lib/deviceApproval'; import { ensureInstallId } from '../lib/installId'; @@ -14,6 +15,7 @@ import { Sidebar } from './Sidebar'; export function AppShell() { const { session } = useAuth(); + useIdleAutoLock(); useMentionNotifications(session?.user.id); useEffect(() => { // Prompt once per authenticated shell mount. Module-level guard prevents diff --git a/apps/desktop/src/components/SecurityCenter.tsx b/apps/desktop/src/components/SecurityCenter.tsx index 1be195a..8f02551 100644 --- a/apps/desktop/src/components/SecurityCenter.tsx +++ b/apps/desktop/src/components/SecurityCenter.tsx @@ -1,5 +1,11 @@ import { useState } from 'react'; +import { + type AutoLockMinutes, + getAutoLockMinutes, + notifyAutoLockChanged, + setAutoLockMinutes, +} from '../lib/autoLockSettings'; import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings'; import { changePin, @@ -21,6 +27,7 @@ export function SecurityCenter({ userId }: Props) { const [recovery, setRecovery] = useState(null); const [migration, setMigration] = useState(null); const [wipeOnClose, setWipeOnCloseState] = useState(() => isWipeOnCloseEnabled()); + const [autoLockMinutes, setAutoLockMinutesState] = useState(() => getAutoLockMinutes()); async function handleRetryMigration() { setBusy(true); setMsg(null); setMigration(null); @@ -149,6 +156,39 @@ export function SecurityCenter({ userId }: Props) { +
+

+ Auto-Lock nach Inaktivität +

+

+ Verlangt erneute PIN-Eingabe nach der gewählten Inaktivitätsdauer. Empfohlen für gemeinsam genutzte Rechner. +

+
+
+
Automatisch sperren
+
+ Verlangt erneute PIN-Eingabe nach X Minuten Inaktivität. +
+
+ +
+
+

Identität zurücksetzen

Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.

diff --git a/apps/desktop/src/hooks/useIdleAutoLock.ts b/apps/desktop/src/hooks/useIdleAutoLock.ts new file mode 100644 index 0000000..fd8bb5b --- /dev/null +++ b/apps/desktop/src/hooks/useIdleAutoLock.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; + +import { useAuth } from '../context/AuthContext'; +import { + getAutoLockMinutes, + subscribeAutoLockSetting, + type AutoLockMinutes, +} from '../lib/autoLockSettings'; + +const ACTIVITY_EVENTS: Array = [ + 'keydown', + 'mousedown', + 'pointermove', + 'touchstart', + 'wheel', +]; + +// Throttle activity-event resets to once per second to avoid thrashing the +// timer on rapid mouse movement. +const RESET_THROTTLE_MS = 1000; + +export function useIdleAutoLock(): void { + const { session, signOut } = useAuth(); + const minutesRef = useRef(getAutoLockMinutes()); + const timerRef = useRef(null); + const lastResetAtRef = useRef(0); + + // Keep minutesRef live to the setting. + useEffect(() => { + const unsub = subscribeAutoLockSetting((v) => { + minutesRef.current = v; + scheduleNext(); + }); + return unsub; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Helper: schedule the lock based on the current setting. + function scheduleNext(): void { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + const min = minutesRef.current; + if (min === 0) return; // disabled + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + // Fire the lock. signOut wipes local state and navigates to /device + // (PIN re-entry screen). + void signOut().catch((err) => console.warn('auto-lock signOut failed', err)); + }, min * 60 * 1000); + } + + useEffect(() => { + if (!session) return; + scheduleNext(); + + const onActivity = () => { + const now = Date.now(); + if (now - lastResetAtRef.current < RESET_THROTTLE_MS) return; + lastResetAtRef.current = now; + scheduleNext(); + }; + + for (const ev of ACTIVITY_EVENTS) { + window.addEventListener(ev, onActivity, { passive: true }); + } + return () => { + for (const ev of ACTIVITY_EVENTS) { + window.removeEventListener(ev, onActivity); + } + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session]); +} diff --git a/apps/desktop/src/lib/autoLockSettings.ts b/apps/desktop/src/lib/autoLockSettings.ts new file mode 100644 index 0000000..3b2ba17 --- /dev/null +++ b/apps/desktop/src/lib/autoLockSettings.ts @@ -0,0 +1,43 @@ +// Per-install setting for PIN-idle-auto-lock. 0 = disabled. +// Values match the dropdown options (5/15/30/60 minutes). + +const KEY = 'chatapp.autoLockMinutes.v1'; + +export type AutoLockMinutes = 0 | 5 | 15 | 30 | 60; + +const VALID: AutoLockMinutes[] = [0, 5, 15, 30, 60]; + +export function getAutoLockMinutes(): AutoLockMinutes { + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return 0; + const n = Number(raw); + if (VALID.includes(n as AutoLockMinutes)) return n as AutoLockMinutes; + return 0; + } catch { + return 0; + } +} + +type Listener = (value: AutoLockMinutes) => void; +const listeners = new Set(); + +export function subscribeAutoLockSetting(l: Listener): () => void { + listeners.add(l); + return () => listeners.delete(l); +} + +export function notifyAutoLockChanged(value: AutoLockMinutes): void { + for (const l of listeners) { + try { l(value); } catch (err) { console.warn(err); } + } +} + +export function setAutoLockMinutes(value: AutoLockMinutes): void { + try { + window.localStorage.setItem(KEY, String(value)); + } catch { + /* quota */ + } + notifyAutoLockChanged(value); +} diff --git a/apps/desktop/src/lib/memoryWipe.ts b/apps/desktop/src/lib/memoryWipe.ts index a469a2b..47363df 100644 --- a/apps/desktop/src/lib/memoryWipe.ts +++ b/apps/desktop/src/lib/memoryWipe.ts @@ -16,6 +16,7 @@ const PRESERVE_LOCAL_STORAGE = new Set([ 'chatapp.locale', 'chatapp.installId', 'chatapp.wipeOnClose.v1', + 'chatapp.autoLockMinutes.v1', 'i18nextLng', ]);