193 lines
5.7 KiB
TypeScript
193 lines
5.7 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useRef } from 'react';
|
|
import {
|
|
AUTH_SESSION_KEY,
|
|
AUTH_TOKEN_KEY,
|
|
fetchAuthSession,
|
|
isPinRequiredError,
|
|
} from '@/lib/api';
|
|
|
|
const ACTIVITY_EVENTS = ['mousedown', 'keydown', 'touchstart', 'scroll', 'wheel', 'pointerdown', 'click'] as const;
|
|
const ACTIVITY_DEBOUNCE_MS = 250;
|
|
const SERVER_TOUCH_INTERVAL_MS = 30_000;
|
|
|
|
interface UsePinIdleLockOptions {
|
|
enabled: boolean;
|
|
timeoutMinutes: number;
|
|
accessToken: string | null;
|
|
onLockRequired: (sessionId: string) => void;
|
|
}
|
|
|
|
export function usePinIdleLock({
|
|
enabled,
|
|
timeoutMinutes,
|
|
accessToken,
|
|
onLockRequired,
|
|
}: UsePinIdleLockOptions) {
|
|
const onLockRequiredRef = useRef(onLockRequired);
|
|
const accessTokenRef = useRef(accessToken);
|
|
const timeoutMsRef = useRef(Math.max(1, timeoutMinutes) * 60_000);
|
|
const idleDeadlineRef = useRef<number>(Date.now() + timeoutMsRef.current);
|
|
const idleTimerRef = useRef<number | null>(null);
|
|
const activityDebounceRef = useRef<number | null>(null);
|
|
const lastServerTouchRef = useRef(0);
|
|
const checkInFlightRef = useRef(false);
|
|
const enabledRef = useRef(enabled);
|
|
|
|
useEffect(() => {
|
|
onLockRequiredRef.current = onLockRequired;
|
|
}, [onLockRequired]);
|
|
|
|
useEffect(() => {
|
|
accessTokenRef.current = accessToken;
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => {
|
|
enabledRef.current = enabled;
|
|
}, [enabled]);
|
|
|
|
useEffect(() => {
|
|
timeoutMsRef.current = Math.max(1, timeoutMinutes) * 60_000;
|
|
if (enabledRef.current) {
|
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
|
}
|
|
}, [timeoutMinutes]);
|
|
|
|
const clearIdleTimer = useCallback(() => {
|
|
if (idleTimerRef.current !== null) {
|
|
window.clearTimeout(idleTimerRef.current);
|
|
idleTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const syncServerActivity = useCallback(async () => {
|
|
const token = accessTokenRef.current ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
|
|
if (!token) return;
|
|
|
|
const now = Date.now();
|
|
if (now - lastServerTouchRef.current < SERVER_TOUCH_INTERVAL_MS) return;
|
|
lastServerTouchRef.current = now;
|
|
|
|
try {
|
|
await fetchAuthSession(token);
|
|
} catch (error) {
|
|
if (isPinRequiredError(error)) {
|
|
onLockRequiredRef.current(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const checkIdleLock = useCallback(async () => {
|
|
if (!enabledRef.current || checkInFlightRef.current) return;
|
|
|
|
const remainingMs = idleDeadlineRef.current - Date.now();
|
|
if (remainingMs > 0) {
|
|
clearIdleTimer();
|
|
idleTimerRef.current = window.setTimeout(() => {
|
|
void checkIdleLock();
|
|
}, remainingMs);
|
|
return;
|
|
}
|
|
|
|
checkInFlightRef.current = true;
|
|
clearIdleTimer();
|
|
|
|
const token = accessTokenRef.current ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
|
|
if (!token) {
|
|
checkInFlightRef.current = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const session = await fetchAuthSession(token);
|
|
if (session.requiresPin) {
|
|
onLockRequiredRef.current(session.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
|
return;
|
|
}
|
|
|
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
|
clearIdleTimer();
|
|
idleTimerRef.current = window.setTimeout(() => {
|
|
void checkIdleLock();
|
|
}, timeoutMsRef.current);
|
|
} catch (error) {
|
|
if (isPinRequiredError(error)) {
|
|
onLockRequiredRef.current(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
|
return;
|
|
}
|
|
|
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
|
clearIdleTimer();
|
|
idleTimerRef.current = window.setTimeout(() => {
|
|
void checkIdleLock();
|
|
}, timeoutMsRef.current);
|
|
} finally {
|
|
checkInFlightRef.current = false;
|
|
}
|
|
}, [clearIdleTimer]);
|
|
|
|
const registerActivity = useCallback(() => {
|
|
if (!enabledRef.current) return;
|
|
|
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
|
clearIdleTimer();
|
|
idleTimerRef.current = window.setTimeout(() => {
|
|
void checkIdleLock();
|
|
}, timeoutMsRef.current);
|
|
|
|
void syncServerActivity();
|
|
}, [checkIdleLock, clearIdleTimer, syncServerActivity]);
|
|
|
|
const scheduleActivityReset = useCallback(() => {
|
|
if (activityDebounceRef.current !== null) {
|
|
window.clearTimeout(activityDebounceRef.current);
|
|
}
|
|
activityDebounceRef.current = window.setTimeout(() => {
|
|
activityDebounceRef.current = null;
|
|
registerActivity();
|
|
}, ACTIVITY_DEBOUNCE_MS);
|
|
}, [registerActivity]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
clearIdleTimer();
|
|
if (activityDebounceRef.current !== null) {
|
|
window.clearTimeout(activityDebounceRef.current);
|
|
activityDebounceRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
|
lastServerTouchRef.current = Date.now();
|
|
void checkIdleLock();
|
|
|
|
for (const eventName of ACTIVITY_EVENTS) {
|
|
window.addEventListener(eventName, scheduleActivityReset, { passive: true });
|
|
}
|
|
|
|
function handleVisibilityChange() {
|
|
if (document.visibilityState !== 'visible') {
|
|
clearIdleTimer();
|
|
return;
|
|
}
|
|
void checkIdleLock();
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
return () => {
|
|
clearIdleTimer();
|
|
if (activityDebounceRef.current !== null) {
|
|
window.clearTimeout(activityDebounceRef.current);
|
|
activityDebounceRef.current = null;
|
|
}
|
|
for (const eventName of ACTIVITY_EVENTS) {
|
|
window.removeEventListener(eventName, scheduleActivityReset);
|
|
}
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
};
|
|
}, [checkIdleLock, clearIdleTimer, enabled, scheduleActivityReset]);
|
|
}
|