forked from akai/readest
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>
142 lines
5.2 KiB
TypeScript
142 lines
5.2 KiB
TypeScript
'use client';
|
|
|
|
import '@/utils/polyfill';
|
|
import i18n from '@/i18n/i18n';
|
|
import { useEffect } from 'react';
|
|
import { IconContext } from 'react-icons';
|
|
import { AuthProvider } from '@/context/AuthContext';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { CSPostHogProvider } from '@/context/PHContext';
|
|
import { SyncProvider } from '@/context/SyncContext';
|
|
import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
|
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
|
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
|
|
import { useEinkMode } from '@/hooks/useEinkMode';
|
|
import { getLocale } from '@/utils/misc';
|
|
import { getDirFromUILanguage } from '@/utils/rtl';
|
|
import { DropdownProvider } from '@/context/DropdownContext';
|
|
import { CommandPaletteProvider, CommandPalette } from '@/components/command-palette';
|
|
import AtmosphereOverlay from '@/components/AtmosphereOverlay';
|
|
import AppLockScreen from '@/components/AppLockScreen';
|
|
import AppLockDialog from '@/components/settings/AppLockDialog';
|
|
import PassphrasePrompt from '@/components/PassphrasePrompt';
|
|
import { upgradeToKeychainIfAvailable } from '@/libs/crypto/passphrase';
|
|
import { cryptoSession } from '@/libs/crypto/session';
|
|
import { useAppLockStore } from '@/store/appLockStore';
|
|
|
|
const Providers = ({ children }: { children: React.ReactNode }) => {
|
|
const { envConfig, appService } = useEnv();
|
|
const { applyUILanguage } = useSettingsStore();
|
|
const { applyBackgroundTexture } = useBackgroundTexture();
|
|
const { applyEinkMode } = useEinkMode();
|
|
const {
|
|
isInitialized: isLockInitialized,
|
|
isUnlocked,
|
|
initialize: initializeAppLock,
|
|
} = useAppLockStore();
|
|
const iconSize = useDefaultIconSize();
|
|
useSafeAreaInsets(); // Initialize safe area insets
|
|
|
|
useEffect(() => {
|
|
const handlerLanguageChanged = (lng: string) => {
|
|
document.documentElement.lang = lng;
|
|
// Set RTL class on document for targeted styling without affecting layout
|
|
const dir = getDirFromUILanguage();
|
|
if (dir === 'rtl') {
|
|
document.documentElement.classList.add('ui-rtl');
|
|
} else {
|
|
document.documentElement.classList.remove('ui-rtl');
|
|
}
|
|
};
|
|
|
|
const locale = getLocale();
|
|
handlerLanguageChanged(locale);
|
|
i18n.on('languageChanged', handlerLanguageChanged);
|
|
return () => {
|
|
i18n.off('languageChanged', handlerLanguageChanged);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadDataTheme();
|
|
if (appService) {
|
|
initSystemThemeListener(appService);
|
|
appService.loadSettings().then((settings) => {
|
|
const globalViewSettings = settings.globalViewSettings;
|
|
applyUILanguage(globalViewSettings.uiLanguage);
|
|
applyBackgroundTexture(envConfig, globalViewSettings);
|
|
if (globalViewSettings.isEink) {
|
|
applyEinkMode(true);
|
|
}
|
|
// Initialize the app-lock gate from on-disk settings. Until
|
|
// this runs, the gate renders nothing — guarantees the
|
|
// library can't flash on screen before the lock screen does.
|
|
initializeAppLock({
|
|
enabled: !!settings.pinCodeEnabled,
|
|
hash: settings.pinCodeHash,
|
|
salt: settings.pinCodeSalt,
|
|
});
|
|
});
|
|
}
|
|
}, [
|
|
envConfig,
|
|
appService,
|
|
applyUILanguage,
|
|
applyBackgroundTexture,
|
|
applyEinkMode,
|
|
initializeAppLock,
|
|
]);
|
|
|
|
// Sync-passphrase boot path: upgrade the passphrase store from
|
|
// ephemeral to OS keychain on Tauri (probe is async — must run after
|
|
// the platform check resolves), then attempt a silent unlock from
|
|
// the saved passphrase. Failures are silent — the gate prompts on
|
|
// first encrypted-field operation if we couldn't restore.
|
|
useEffect(() => {
|
|
void (async () => {
|
|
await upgradeToKeychainIfAvailable();
|
|
await cryptoSession.tryRestoreFromStore();
|
|
})();
|
|
}, []);
|
|
|
|
// Make sure appService is available in all children components
|
|
if (!appService) return;
|
|
|
|
// App-lock gate. While the lock store is uninitialized we render
|
|
// nothing — without this guard the library would flash on screen
|
|
// for a few hundred ms before `loadSettings` resolved and let the
|
|
// lock store decide whether to lock.
|
|
const showAppLockScreen = isLockInitialized && !isUnlocked;
|
|
const appShellHidden = !isLockInitialized || !isUnlocked;
|
|
|
|
return (
|
|
<CSPostHogProvider>
|
|
<AuthProvider>
|
|
<IconContext.Provider value={{ size: `${iconSize}px` }}>
|
|
<SyncProvider>
|
|
<DropdownProvider>
|
|
<CommandPaletteProvider>
|
|
<div
|
|
aria-hidden={appShellHidden}
|
|
style={appShellHidden ? { display: 'none' } : undefined}
|
|
>
|
|
{children}
|
|
<CommandPalette />
|
|
<AtmosphereOverlay />
|
|
<PassphrasePrompt />
|
|
</div>
|
|
<AppLockDialog />
|
|
{showAppLockScreen && <AppLockScreen />}
|
|
</CommandPaletteProvider>
|
|
</DropdownProvider>
|
|
</SyncProvider>
|
|
</IconContext.Provider>
|
|
</AuthProvider>
|
|
</CSPostHogProvider>
|
|
);
|
|
};
|
|
|
|
export default Providers;
|