UMBRA/frontend/src/hooks/useLock.tsx
Kyle Pope 5ad0a610bd Address all QA review warnings and suggestions for lock screen feature
- [C-1] Add rate limiting and account lockout to /verify-password endpoint
- [W-3] Add max length validator (128 chars) to VerifyPasswordRequest
- [W-1] Move activeMutations to ref in useLock to prevent timer thrashing
- [W-5] Add user_id field to frontend Settings interface
- [S-1] Export auth schemas from schemas registry
- [S-3] Add aria-label to LockOverlay password input

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:20:42 +08:00

111 lines
3.1 KiB
TypeScript

import {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
type ReactNode,
} from 'react';
import { useIsMutating } from '@tanstack/react-query';
import { useSettings } from '@/hooks/useSettings';
import api from '@/lib/api';
interface LockContextValue {
isLocked: boolean;
lock: () => void;
unlock: (password: string) => Promise<void>;
}
const LockContext = createContext<LockContextValue | null>(null);
const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'] as const;
const THROTTLE_MS = 5_000; // only reset timer every 5s of activity
export function LockProvider({ children }: { children: ReactNode }) {
const [isLocked, setIsLocked] = useState(false);
const { settings } = useSettings();
const activeMutations = useIsMutating();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastActivityRef = useRef<number>(Date.now());
const activeMutationsRef = useRef(activeMutations);
activeMutationsRef.current = activeMutations;
const lock = useCallback(() => {
setIsLocked(true);
}, []);
const unlock = useCallback(async (password: string) => {
const { data } = await api.post<{ verified: boolean }>('/auth/verify-password', { password });
if (data.verified) {
setIsLocked(false);
lastActivityRef.current = Date.now();
}
}, []);
// Auto-lock idle timer
useEffect(() => {
const enabled = settings?.auto_lock_enabled ?? false;
const minutes = settings?.auto_lock_minutes ?? 5;
if (!enabled || isLocked) {
// Clear any existing timer when disabled or already locked
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
return;
}
const timeoutMs = minutes * 60_000;
const resetTimer = () => {
// Don't lock while TanStack mutations are in flight
if (activeMutationsRef.current > 0) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
lock();
}, timeoutMs);
};
const handleActivity = () => {
const now = Date.now();
if (now - lastActivityRef.current < THROTTLE_MS) return;
lastActivityRef.current = now;
resetTimer();
};
// Start the initial timer
resetTimer();
// Attach throttled listeners
for (const event of ACTIVITY_EVENTS) {
document.addEventListener(event, handleActivity, { passive: true });
}
return () => {
for (const event of ACTIVITY_EVENTS) {
document.removeEventListener(event, handleActivity);
}
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]);
return (
<LockContext.Provider value={{ isLocked, lock, unlock }}>
{children}
</LockContext.Provider>
);
}
export function useLock(): LockContextValue {
const ctx = useContext(LockContext);
if (!ctx) throw new Error('useLock must be used within a LockProvider');
return ctx;
}