2d5590ec1f
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>
71 lines
2.3 KiB
TypeScript
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 }),
|
|
}));
|