From acd59a73872ea823abbede418fe510f29306875e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 24 Apr 2026 15:27:46 +0800 Subject: [PATCH] feat: add auto-lock feature with customizable timeout settings and update UI for security preferences --- webapp/src/App.tsx | 137 ++++++++++++++++++++---- webapp/src/components/AppMainRoutes.tsx | 4 + webapp/src/components/SettingsPage.tsx | 65 +++++++---- webapp/src/lib/api/auth.ts | 22 +++- webapp/src/lib/app-auth.ts | 36 +++++-- webapp/src/lib/i18n.ts | 18 ++++ 6 files changed, 233 insertions(+), 49 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 4629a4d..5f3956d 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -18,6 +18,7 @@ import { revokeCurrentSession, getTotpStatus, saveSession, + stripProfileSecrets, } from '@/lib/api/auth'; import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; import { buildSendShareKey, getSends } from '@/lib/api/send'; @@ -82,6 +83,10 @@ const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; type ThemePreference = 'system' | 'light' | 'dark'; +type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30; + +const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1'; +const LOCK_TIMEOUT_VALUES = new Set([0, 1, 5, 15, 30]); function readThemePreference(): ThemePreference { if (typeof window === 'undefined') return 'system'; @@ -95,6 +100,12 @@ function resolveSystemTheme(): 'light' | 'dark' { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } +function readLockTimeoutMinutes(): LockTimeoutMinutes { + if (typeof window === 'undefined') return 15; + const value = Number(window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY)); + return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15; +} + export default function App() { const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); @@ -128,6 +139,7 @@ export default function App() { const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [unlockPassword, setUnlockPassword] = useState(''); const [pendingTotp, setPendingTotp] = useState(null); + const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null); const [totpCode, setTotpCode] = useState(''); const [rememberDevice, setRememberDevice] = useState(true); const [totpSubmitting, setTotpSubmitting] = useState(false); @@ -138,7 +150,8 @@ export default function App() { const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [themePreference, setThemePreference] = useState(() => readThemePreference()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); - const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key); + const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState(() => readLockTimeoutMinutes()); + const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email); const [confirm, setConfirm] = useState(null); const [mobileLayout, setMobileLayout] = useState(false); @@ -245,11 +258,16 @@ export default function App() { }, [profile]); useEffect(() => { - if (phase === 'locked' && profile?.key && session) { + if (phase === 'locked' && session?.email) { setUnlockPreparing(false); } }, [phase, profile, session]); + useEffect(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes)); + }, [lockTimeoutMinutes]); + function handleToggleTheme() { setThemePreference((prev) => { const current = prev === 'system' ? systemTheme : prev; @@ -263,6 +281,11 @@ export default function App() { saveSession(next); } + function setLockTimeoutMinutes(next: LockTimeoutMinutes) { + setLockTimeoutMinutesState(next); + pushToast('success', t('txt_auto_lock_updated')); + } + const authedFetch = useMemo( () => createAuthedFetch( @@ -309,7 +332,7 @@ export default function App() { setSession(boot.session); setProfile(boot.profile); setPhase(boot.phase); - setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key); + setUnlockPreparing(boot.phase === 'locked' && !boot.session?.email); })(); return () => { @@ -333,7 +356,7 @@ export default function App() { } setSession(result.session); if (result.profile) { - setProfile(result.profile); + setProfile(stripProfileSecrets(result.profile)); } })(); return () => { @@ -341,17 +364,19 @@ export default function App() { }; }, [phase, session?.email, location, navigate]); - async function finalizeLogin(login: CompletedLogin) { + async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) { setSession(login.session); setProfile(login.profile); setUnlockPreparing(false); setPendingTotp(null); + setPendingTotpMode(null); setTotpCode(''); + setUnlockPassword(''); setPhase('app'); if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { navigate('/vault'); } - pushToast('success', t('txt_login_success')); + pushToast('success', successMessage); void (async () => { try { const hydratedProfile = await login.profilePromise; @@ -378,6 +403,7 @@ export default function App() { } if (result.kind === 'totp') { setPendingTotp(result.pendingTotp); + setPendingTotpMode('login'); setTotpCode(''); setRememberDevice(true); return; @@ -400,7 +426,7 @@ export default function App() { setTotpSubmitting(true); try { const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); - await finalizeLogin(login); + await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); } finally { @@ -523,20 +549,26 @@ export default function App() { async function handleUnlock() { if (pendingAuthAction) return; - if (!session || !profile) return; + if (!session?.email) return; if (!unlockPassword) { pushToast('error', t('txt_please_input_master_password')); return; } setPendingAuthAction('unlock'); try { - const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); - setSession(nextSession); - setUnlockPassword(''); - setUnlockPreparing(false); - setPhase('app'); - if (location === '/' || location === '/lock') navigate('/vault'); - pushToast('success', t('txt_unlocked')); + const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); + if (result.kind === 'success') { + await finalizeLogin(result.login, t('txt_unlocked')); + return; + } + if (result.kind === 'totp') { + setPendingTotp(result.pendingTotp); + setPendingTotpMode('unlock'); + setTotpCode(''); + setRememberDevice(true); + return; + } + pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect')); } catch { pushToast('error', t('txt_unlock_failed_master_password_is_incorrect')); } finally { @@ -544,17 +576,30 @@ export default function App() { } } - function handleLock() { - if (!session) return; - const nextSession = { ...session }; + function lockCurrentSession() { + const currentSession = sessionRef.current; + if (!currentSession) return; + const nextSession = { ...currentSession }; delete nextSession.symEncKey; delete nextSession.symMacKey; setSession(nextSession); + setProfile((prev) => stripProfileSecrets(prev)); + setDecryptedFolders([]); + setDecryptedCiphers([]); + setDecryptedSends([]); + setUnlockPassword(''); + setPendingTotp(null); + setPendingTotpMode(null); + setTotpCode(''); setUnlockPreparing(false); setPhase('locked'); navigate('/lock'); } + function handleLock() { + lockCurrentSession(); + } + function logoutNow() { void revokeCurrentSession(sessionRef.current); setConfirm(null); @@ -563,6 +608,7 @@ export default function App() { setProfile(null); setUnlockPreparing(false); setPendingTotp(null); + setPendingTotpMode(null); setPhase('login'); navigate('/login'); } @@ -578,6 +624,55 @@ export default function App() { }); } + useEffect(() => { + if (phase !== 'app' || lockTimeoutMinutes === 0) return; + if (typeof window === 'undefined') return; + + let timerId: number | null = null; + let lastActivityAt = 0; + const timeoutMs = lockTimeoutMinutes * 60 * 1000; + + const clearTimer = () => { + if (timerId !== null) { + window.clearTimeout(timerId); + timerId = null; + } + }; + const scheduleLock = () => { + clearTimer(); + timerId = window.setTimeout(() => { + if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) { + lockCurrentSession(); + } + }, timeoutMs); + }; + const markActivity = () => { + const now = Date.now(); + if (now - lastActivityAt < 1000) return; + lastActivityAt = now; + scheduleLock(); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') markActivity(); + }; + + scheduleLock(); + window.addEventListener('pointerdown', markActivity, { passive: true }); + window.addEventListener('keydown', markActivity); + window.addEventListener('scroll', markActivity, { passive: true }); + window.addEventListener('touchstart', markActivity, { passive: true }); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + clearTimer(); + window.removeEventListener('pointerdown', markActivity); + window.removeEventListener('keydown', markActivity); + window.removeEventListener('scroll', markActivity); + window.removeEventListener('touchstart', markActivity); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [phase, lockTimeoutMinutes]); + function renderPassiveOverlays() { return ( { if (totpSubmitting) return; setPendingTotp(null); + setPendingTotpMode(null); setTotpCode(''); setRememberDevice(true); }} onUseRecoveryCode={() => { if (totpSubmitting) return; setPendingTotp(null); + setPendingTotpMode(null); setTotpCode(''); setRememberDevice(true); navigate('/recover-2fa'); diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 55a985f..929b4e0 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -45,6 +45,7 @@ export interface AppMainRoutesProps { users: AdminUser[]; invites: AdminInvite[]; totpEnabled: boolean; + lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; authorizedDevices: AuthorizedDevice[]; authorizedDevicesLoading: boolean; onNavigate: (path: string) => void; @@ -96,6 +97,7 @@ export interface AppMainRoutesProps { onGetRecoveryCode: (masterPassword: string) => Promise; onGetApiKey: (masterPassword: string) => Promise; onRotateApiKey: (masterPassword: string) => Promise; + onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onRefreshAuthorizedDevices: () => Promise; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; @@ -222,6 +224,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index b669e57..e1c82e4 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -9,6 +9,7 @@ import ConfirmDialog from '@/components/ConfirmDialog'; interface SettingsPageProps { profile: Profile; totpEnabled: boolean; + lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; onSavePasswordHint: (masterPasswordHint: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; @@ -16,9 +17,18 @@ interface SettingsPageProps { onGetRecoveryCode: (masterPassword: string) => Promise; onGetApiKey: (masterPassword: string) => Promise; onRotateApiKey: (masterPassword: string) => Promise; + onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onNotify?: (type: 'success' | 'error', text: string) => void; } +const LOCK_TIMEOUT_OPTIONS = [ + { value: 1, labelKey: 'txt_lock_after_1_minute' }, + { value: 5, labelKey: 'txt_lock_after_5_minutes' }, + { value: 15, labelKey: 'txt_lock_after_15_minutes' }, + { value: 30, labelKey: 'txt_lock_after_30_minutes' }, + { value: 0, labelKey: 'txt_lock_after_never' }, +] as const; + function randomBase32Secret(length: number): string { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let out = ''; @@ -124,25 +134,42 @@ export default function SettingsPage(props: SettingsPageProps) { return (
-

{t('txt_profile')}

- - +

{t('txt_security_preferences')}

+
+ + +
diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 2d8ebce..ed91719 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -122,9 +122,11 @@ export function loadProfileSnapshot(email?: string | null): Profile | null { const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Profile; - if (!parsed?.email || !parsed?.key) return null; + if (!parsed?.email) return null; if (email && parsed.email !== email) return null; - return parsed; + const snapshot = stripProfileSecrets(parsed); + localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot)); + return snapshot; } catch { return null; } @@ -132,13 +134,27 @@ export function loadProfileSnapshot(email?: string | null): Profile | null { export function saveProfileSnapshot(profile: Profile | null): void { if (!profile) return; - localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile)); + localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile))); } export function clearProfileSnapshot(): void { localStorage.removeItem(PROFILE_SNAPSHOT_KEY); } +export function stripProfileSecrets(profile: Profile | null): Profile | null { + if (!profile) return null; + return { + id: String(profile.id || ''), + email: String(profile.email || ''), + name: String(profile.name || ''), + role: profile.role === 'admin' ? 'admin' : 'user', + masterPasswordHint: profile.masterPasswordHint ?? null, + publicKey: profile.publicKey ?? null, + key: '', + privateKey: null, + }; +} + export function getCurrentDeviceIdentifier(): string { return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); } diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index 645bdd5..7708ea7 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -372,16 +372,36 @@ export async function performRegistration(args: { export async function performUnlock( session: SessionState, - profile: Profile, + profile: Profile | null, password: string, fallbackIterations: number -): Promise { - const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations); - const keys = await unlockVaultKey(profile.key, derived.masterKey); - const refreshedSession = await maybeRefreshSession(session); - if (!refreshedSession) { - throw new Error('Session expired'); +): Promise { + const normalizedEmail = (profile?.email || session.email).trim().toLowerCase(); + const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations); + const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true }); + + if ('access_token' in token && token.access_token) { + return { + kind: 'success', + login: await completeLogin(token, normalizedEmail, derived.masterKey), + }; } - return { ...refreshedSession, ...keys }; + + const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string }; + if (tokenError.TwoFactorProviders) { + return { + kind: 'totp', + pendingTotp: { + email: normalizedEmail, + passwordHash: derived.hash, + masterKey: derived.masterKey, + }, + }; + } + + return { + kind: 'error', + message: tokenError.error_description || tokenError.error || 'Unlock failed', + }; } diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 439a25f..7639915 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -1485,6 +1485,24 @@ zhCNOverrides.txt_lock = '锁定'; zhCNOverrides.txt_menu = '菜单'; zhCNOverrides.txt_settings = '设置'; zhCNOverrides.txt_back = '返回'; +messages.en.txt_auto_lock = 'Auto-lock'; +messages.en.txt_auto_lock_description = 'Locks after inactivity. Closing and reopening the page always starts locked.'; +messages.en.txt_auto_lock_updated = 'Auto-lock updated'; +messages.en.txt_security_preferences = 'Security Preferences'; +messages.en.txt_lock_after_1_minute = 'After 1 minute'; +messages.en.txt_lock_after_5_minutes = 'After 5 minutes'; +messages.en.txt_lock_after_15_minutes = 'After 15 minutes'; +messages.en.txt_lock_after_30_minutes = 'After 30 minutes'; +messages.en.txt_lock_after_never = 'Never for inactivity'; +zhCNOverrides.txt_auto_lock = '自动锁定'; +zhCNOverrides.txt_auto_lock_description = '页面闲置后锁定;关闭页面或浏览器后再次打开始终进入锁定页。'; +zhCNOverrides.txt_auto_lock_updated = '自动锁定时间已更新'; +zhCNOverrides.txt_security_preferences = '安全偏好'; +zhCNOverrides.txt_lock_after_1_minute = '闲置 1 分钟后'; +zhCNOverrides.txt_lock_after_5_minutes = '闲置 5 分钟后'; +zhCNOverrides.txt_lock_after_15_minutes = '闲置 15 分钟后'; +zhCNOverrides.txt_lock_after_30_minutes = '闲置 30 分钟后'; +zhCNOverrides.txt_lock_after_never = '不因闲置锁定'; zhCNOverrides.txt_attachments = '附件'; zhCNOverrides.txt_upload_attachments = '上传附件'; zhCNOverrides.txt_new_attachments = '待上传附件';