'use client'; import clsx from 'clsx'; import { useEffect, useRef, useState } from 'react'; import PinInput from '@/components/PinInput'; import { useEnv } from '@/context/EnvContext'; import { PIN_LENGTH, verifyPin } from '@/libs/crypto/applock'; import { useAppLockStore } from '@/store/appLockStore'; import { useTranslation } from '@/hooks/useTranslation'; import { authenticateWithBiometrics, getBiometricStatus, getBiometryLabelKey, isBiometricSupported, shouldAttemptBiometricUnlock, } from '@/services/biometric'; export default function AppLockScreen() { const _ = useTranslation(); const { appService } = useEnv(); const { pinHash, pinSalt, unlock, biometricUnlockEnabled } = useAppLockStore(); const [biometryLabel, setBiometryLabel] = useState(''); const [pin, setPin] = useState(''); const [error, setError] = useState(''); const [shaking, setShaking] = useState(false); const [kbInset, setKbInset] = useState(0); const biometricAttemptedRef = useRef(false); const biometricInFlightRef = useRef(false); const autoFocusEnabled = !appService?.isMobile; const runBiometric = async () => { if (biometricInFlightRef.current) return; biometricInFlightRef.current = true; try { const ok = await authenticateWithBiometrics(_('Unlock Readest')); if (ok) unlock(); } finally { biometricInFlightRef.current = false; } }; useEffect(() => { if (!isBiometricSupported(appService)) return; let cancelled = false; void (async () => { const { available, biometryType } = await getBiometricStatus(); if (cancelled) return; if ( !shouldAttemptBiometricUnlock({ isMobileApp: !!appService?.isMobileApp, biometricUnlockEnabled, available, }) ) { return; } setBiometryLabel(_(getBiometryLabelKey(biometryType))); if (biometricAttemptedRef.current) return; biometricAttemptedRef.current = true; await runBiometric(); })(); return () => { cancelled = true; }; }, [appService]); useEffect(() => { const vv = window.visualViewport; if (!vv) return; const update = () => { const layoutH = document.documentElement.clientHeight; const offset = layoutH - vv.height - vv.offsetTop; setKbInset(offset > 1 ? offset : 0); }; vv.addEventListener('resize', update); vv.addEventListener('scroll', update); update(); return () => { vv.removeEventListener('resize', update); vv.removeEventListener('scroll', update); }; }, []); // Avoid React state for the in-flight guard — `setVerifying(true)` // would re-trigger the effect, the cleanup would set `cancelled=true`, // and the resolve handler would short-circuit before clearing the // failed PIN. A ref keeps the guard outside the render cycle. const verifyingRef = useRef(false); const handleChange = async (next: string) => { setPin(next); if (error) setError(''); if (next.length !== PIN_LENGTH || verifyingRef.current) return; if (!pinHash || !pinSalt) { // Settings flag is enabled but the hash/salt are missing — treat // as a corrupted-config bypass so the user isn't locked out // forever. This should be unreachable through normal flows. unlock(); return; } verifyingRef.current = true; try { const ok = await verifyPin(next, pinSalt, pinHash); if (ok) { unlock(); } else { setError(_('Incorrect PIN')); setPin(''); setShaking(true); window.setTimeout(() => setShaking(false), 400); } } finally { verifyingRef.current = false; } }; return (
{_('Readest is locked. Enter your 4-digit PIN to continue.')}
{error || ' '}
{biometryLabel && ( )}{_( "Forgetting your PIN locks you out of this device. You'll need to clear the app's data to reset it.", )}