Protect your session when you step away from this device.
+
+
+
+
+
+ You'll get a warning a minute before. Locking also forgets the cached wallet session, so a reload
+ needs a fresh connection.
+
+
+
+
+
+
+ Biometric unlock
+
+
+
+ {biometricAvailable === false
+ ? "No fingerprint, face or Windows Hello sensor was found on this device."
+ : "Optional. Require Touch ID, Face ID, Windows Hello or fingerprint to unlock a locked session. This stays on this device."}
+
+ {biometricMessage && (
+
{biometricMessage}
+ )}
+
+
+
+
+
+
Security tips
+
+
+
+ {SECURITY_TIPS.map((tip) => (
+
+ {tip.title}.{" "}
+ {tip.body}
+
+ ))}
+
+
+
+
+
+
Recent security activity
+ {events.length > 0 && (
+
+ )}
+
+ {events.length === 0 ? (
+
No security events recorded on this device.
+ ) : (
+
+ {events.map((e, i) => (
+
+ {EVENT_LABELS[e.type] ?? e.type}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/contexts/SessionLockContext.tsx b/src/contexts/SessionLockContext.tsx
index 00974231..05f621f2 100644
--- a/src/contexts/SessionLockContext.tsx
+++ b/src/contexts/SessionLockContext.tsx
@@ -1,6 +1,8 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react";
+import { isBiometricEnabled, verifyBiometric } from "@/lib/biometric";
+import { recordSecurityEvent } from "@/lib/walletSecurity";
type TimeoutOption = 5 | 15 | 30 | 60 | null;
@@ -9,12 +11,20 @@ interface SessionLockContextValue {
timeoutMinutes: TimeoutOption;
setTimeoutMinutes: (minutes: TimeoutOption) => void;
resume: () => void;
+ /** Seconds left before the session locks, or null when not in the warning window. */
+ secondsUntilLock: number | null;
}
const SessionLockContext = createContext(undefined);
const STORAGE_KEY = "stellarsplit_session_lock_timeout";
+/** Cached wallet connection written by `useWallet`; cleared on lock so a reload needs a fresh connect. */
+const WALLET_SESSION_KEY = "stellarsplit_wallet";
const INTERACTION_EVENTS: (keyof DocumentEventMap)[] = ["mousedown", "keydown", "touchstart"];
+const WARNING_SECONDS = 60;
+const TICK_MS = 1_000;
+
+export const SESSION_TIMEOUT_OPTIONS: TimeoutOption[] = [5, 15, 30, 60, null];
function getStoredTimeout(): TimeoutOption {
if (typeof window === "undefined") return 15;
@@ -27,14 +37,27 @@ function getStoredTimeout(): TimeoutOption {
return 15;
}
+/**
+ * Locks the app after a period of inactivity. Shows a countdown warning in
+ * the last minute, clears the cached wallet session when it locks, and — when
+ * the user has enabled it — requires a biometric check to unlock.
+ */
export function SessionLockProvider({ children }: { children: React.ReactNode }) {
const [locked, setLocked] = useState(false);
const [timeoutMinutes, setTimeoutMinutesState] = useState(getStoredTimeout);
+ const [secondsUntilLock, setSecondsUntilLock] = useState(null);
+ const [unlocking, setUnlocking] = useState(false);
+ const [unlockError, setUnlockError] = useState(null);
+ const [biometricRequired, setBiometricRequired] = useState(false);
const lastActivityRef = useRef(Date.now());
+ const lockedRef = useRef(false);
const timerRef = useRef | null>(null);
const resetActivity = useCallback(() => {
+ // Activity behind the lock screen must not silently extend the session.
+ if (lockedRef.current) return;
lastActivityRef.current = Date.now();
+ setSecondsUntilLock(null);
}, []);
const setTimeoutMinutes = useCallback((minutes: TimeoutOption) => {
@@ -42,15 +65,45 @@ export function SessionLockProvider({ children }: { children: React.ReactNode })
try {
localStorage.setItem(STORAGE_KEY, String(minutes));
} catch {}
+ lockedRef.current = false;
resetActivity();
setLocked(false);
}, [resetActivity]);
const resume = useCallback(() => {
+ lockedRef.current = false;
setLocked(false);
+ setUnlockError(null);
resetActivity();
}, [resetActivity]);
+ const lock = useCallback(() => {
+ lockedRef.current = true;
+ setLocked(true);
+ setSecondsUntilLock(null);
+ setBiometricRequired(isBiometricEnabled());
+ try {
+ sessionStorage.removeItem(WALLET_SESSION_KEY);
+ } catch {}
+ recordSecurityEvent({ type: "session_locked" });
+ }, []);
+
+ const unlockWithBiometric = async () => {
+ setUnlocking(true);
+ setUnlockError(null);
+ const ok = await verifyBiometric();
+ setUnlocking(false);
+ if (ok) resume();
+ else setUnlockError("Biometric check failed. Try again, or disconnect and sign in again.");
+ };
+
+ const disconnectAndReload = () => {
+ try {
+ sessionStorage.removeItem(WALLET_SESSION_KEY);
+ } catch {}
+ window.location.reload();
+ };
+
useEffect(() => {
INTERACTION_EVENTS.forEach((evt) => document.addEventListener(evt, resetActivity, true));
return () => {
@@ -61,37 +114,91 @@ export function SessionLockProvider({ children }: { children: React.ReactNode })
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current);
- if (timeoutMinutes === null) return;
+ if (timeoutMinutes === null) {
+ setSecondsUntilLock(null);
+ return;
+ }
const ms = timeoutMinutes * 60 * 1000;
timerRef.current = setInterval(() => {
- if (Date.now() - lastActivityRef.current >= ms) {
- setLocked(true);
+ if (lockedRef.current) return;
+ const remaining = ms - (Date.now() - lastActivityRef.current);
+ if (remaining <= 0) {
+ lock();
+ } else if (remaining <= WARNING_SECONDS * 1000) {
+ setSecondsUntilLock(Math.ceil(remaining / 1000));
}
- }, 10_000);
+ }, TICK_MS);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
- }, [timeoutMinutes]);
+ }, [timeoutMinutes, lock]);
return (
-
+
{children}
+ {!locked && secondsUntilLock !== null && (
+
+
+ Your session will lock in {secondsUntilLock}s due to inactivity.
+