6c7c86f346
* feat(applock): wire up biometric plugin + biometricUnlockEnabled setting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(applock): add guarded biometric service wrapper * feat(applock): auto-prompt biometrics on the lock screen with PIN fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(applock): guard concurrent biometric prompts + tighten lock-screen tests - Add biometricInFlightRef to prevent concurrent authenticateWithBiometrics calls - Assert PIN input still rendered after biometric failure (test 2) - Replace flaky waitFor negative assertion with a 50ms flush in test 3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(applock): mobile biometric toggle + default-on at PIN setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(i18n): add biometric app-lock strings * fix(applock): seed biometricUnlockEnabled via app-lock store init; close test gaps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * build(applock): pin tauri-plugin-biometric in Cargo.lock Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
174 lines
5.3 KiB
TypeScript
174 lines
5.3 KiB
TypeScript
'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 (
|
|
<div
|
|
className='bg-base-100 full-height inset-0 z-[200] flex flex-col items-center justify-center px-6'
|
|
style={{ paddingBottom: kbInset || undefined }}
|
|
role='dialog'
|
|
aria-modal='true'
|
|
aria-label={_('App locked')}
|
|
>
|
|
<div className='flex max-w-sm flex-col items-center text-center'>
|
|
<h1 className='text-base-content mb-2 text-xl font-semibold tracking-tight'>
|
|
{_('Enter your PIN')}
|
|
</h1>
|
|
<p className='text-base-content/60 mb-8 text-sm leading-relaxed'>
|
|
{_('Readest is locked. Enter your 4-digit PIN to continue.')}
|
|
</p>
|
|
|
|
<PinInput
|
|
value={pin}
|
|
onChange={handleChange}
|
|
ariaLabel={_('PIN code')}
|
|
stickyFocus={autoFocusEnabled}
|
|
shake={shaking}
|
|
/>
|
|
|
|
<p
|
|
className={clsx(
|
|
'text-error mt-4 h-5 text-sm transition-opacity',
|
|
error ? 'opacity-100' : 'opacity-0',
|
|
)}
|
|
aria-live='polite'
|
|
>
|
|
{error || ' '}
|
|
</p>
|
|
|
|
{biometryLabel && (
|
|
<button
|
|
type='button'
|
|
onClick={runBiometric}
|
|
className={clsx(
|
|
'eink-bordered',
|
|
'mt-2 h-10 rounded-lg px-4 text-sm font-medium',
|
|
'text-base-content hover:bg-base-200 transition-colors duration-150',
|
|
)}
|
|
>
|
|
{_('Use {{biometry}}', { biometry: biometryLabel })}
|
|
</button>
|
|
)}
|
|
|
|
<p className='text-base-content/40 mt-10 text-xs leading-relaxed'>
|
|
{_(
|
|
"Forgetting your PIN locks you out of this device. You'll need to clear the app's data to reset it.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|