Files
readest/apps/readest-app/src/store/appLockStore.ts
T
Huang Xin 2d5590ec1f feat(applock): 4-digit PIN gate at app launch (#4093)
Closes #2285.

Adds an opt-in 4-digit PIN that gates the library and reader on app
launch. Threat model: casual physical/browser access by another person
on a shared device — peace of mind, not defense against an attacker
with filesystem access. The PIN is stored as a salted PBKDF2-SHA256
hash (100k iterations) in settings.json; the plaintext PIN is never
persisted.

Configured from Settings → Advanced Settings → "Set PIN…" (and
"Change PIN…" / "Disable PIN…" once enabled). The lock screen and the
set/change/disable dialog share a single 4-dot input component
(PinInput) for a consistent UI; the dialog auto-advances focus from
Current → New → Confirm. Lock-on-resume, biometric unlock, and
account-based reset are out of scope for this MVP — disable for now is
"clear app data".

Bundles the previously-missed sync-passphrase i18n strings (PR #4090)
across all 33 locales so no `__STRING_NOT_TRANSLATED__` placeholders
remain in the tree.

New
- src/libs/crypto/applock.ts (PBKDF2 hash/verify; reuses derivePbkdf2Key)
- src/store/appLockStore.ts (gate + dialog state)
- src/components/PinInput.tsx (shared 4-dot input)
- src/components/AppLockScreen.tsx (full-screen lock gate)
- src/components/settings/AppLockDialog.tsx (set/change/disable)
- src/__tests__/libs/crypto/applock.test.ts

Modified
- src/types/settings.ts (pinCodeEnabled / pinCodeHash / pinCodeSalt)
- src/services/constants.ts (default off)
- src/components/Providers.tsx (mount gate + dialog above app shell)
- src/app/library/components/SettingsMenu.tsx (Advanced submenu entries)
- src/styles/globals.css (animate-pin-shake keyframe)
- public/locales/*/translation.json (21 PIN keys + 17 leftover passphrase keys × 33 locales)

Verified
- pnpm test (4018 pass)
- pnpm lint (clean)
- Manual web smoke: Set/Reload-locks/Wrong-PIN/Unlock/Change/Disable

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:10:34 +02:00

71 lines
2.3 KiB
TypeScript

import { create } from 'zustand';
export type AppLockDialogMode = 'set' | 'change' | 'disable';
interface AppLockState {
/**
* Has the gate been initialized from on-disk settings yet? Until
* this flips to `true` the gate renders nothing — guarantees the
* library/reader can never flash on screen before the lock screen
* does.
*/
isInitialized: boolean;
/**
* Session-scoped unlock flag. `true` if there is no PIN configured
* OR the user has entered the correct PIN since this page load.
* Flipping back to `false` is reserved for future re-lock-on-resume
* work; today nothing calls `lock()` after the initial unlock.
*/
isUnlocked: boolean;
/** Cached copy of the PIN salt + hash — mirrors `SystemSettings`. */
pinHash: string | null;
pinSalt: string | null;
/** Called once from `Providers` after `loadSettings` resolves. */
initialize: (config: { enabled: boolean; hash?: string; salt?: string }) => void;
/** Called by `<AppLockScreen />` after a verified PIN entry. */
unlock: () => void;
/** Reserved for future re-lock work (background timeout, etc.). */
lock: () => void;
/** Called from the settings dialog after persisting a new/changed PIN. */
setPin: (hash: string, salt: string) => void;
/** Called from the settings dialog after disabling the lock. */
clearPin: () => void;
/**
* Which app-lock dialog (if any) is currently open. Lifted out of
* `SettingsMenu` because that component unmounts when its dropdown
* closes — local dialog state would never get to render.
*/
dialogMode: AppLockDialogMode | null;
openDialog: (mode: AppLockDialogMode) => void;
closeDialog: () => void;
}
export const useAppLockStore = create<AppLockState>((set) => ({
isInitialized: false,
isUnlocked: true,
pinHash: null,
pinSalt: null,
initialize: ({ enabled, hash, salt }) =>
set({
isInitialized: true,
isUnlocked: !enabled,
pinHash: hash ?? null,
pinSalt: salt ?? null,
}),
unlock: () => set({ isUnlocked: true }),
lock: () => set({ isUnlocked: false }),
setPin: (hash, salt) => set({ pinHash: hash, pinSalt: salt }),
clearPin: () => set({ pinHash: null, pinSalt: null, isUnlocked: true }),
dialogMode: null,
openDialog: (mode) => set({ dialogMode: mode }),
closeDialog: () => set({ dialogMode: null }),
}));