- [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>
111 lines
3.1 KiB
TypeScript
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;
|
|
}
|