From db8b9263a1fb1e0c297b98a4f998a69ca023969a Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 25 Apr 2026 03:49:15 +0800 Subject: [PATCH] feat: implement session timeout feature with customizable actions and update UI components --- webapp/src/App.tsx | 44 +++- webapp/src/components/AppMainRoutes.tsx | 4 + webapp/src/components/SettingsPage.tsx | 295 ++++++++++++++---------- webapp/src/lib/i18n.ts | 36 ++- webapp/src/styles/management.css | 51 +++- webapp/src/styles/overlays.css | 2 +- webapp/src/styles/responsive.css | 5 + 7 files changed, 302 insertions(+), 135 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index f20f06e..49470dc 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -84,8 +84,10 @@ const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; type ThemePreference = 'system' | 'light' | 'dark'; type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30; +type SessionTimeoutAction = 'lock' | 'logout'; const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1'; +const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1'; const LOCK_TIMEOUT_VALUES = new Set([0, 1, 5, 15, 30]); function readThemePreference(): ThemePreference { @@ -106,6 +108,12 @@ function readLockTimeoutMinutes(): LockTimeoutMinutes { return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15; } +function readSessionTimeoutAction(): SessionTimeoutAction { + if (typeof window === 'undefined') return 'lock'; + const value = String(window.localStorage.getItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY) || '').trim(); + return value === 'logout' ? 'logout' : 'lock'; +} + export default function App() { const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); @@ -151,6 +159,7 @@ export default function App() { const [themePreference, setThemePreference] = useState(() => readThemePreference()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState(() => readLockTimeoutMinutes()); + const [sessionTimeoutAction, setSessionTimeoutActionState] = useState(() => readSessionTimeoutAction()); const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email); const [confirm, setConfirm] = useState(null); @@ -269,6 +278,11 @@ export default function App() { window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes)); }, [lockTimeoutMinutes]); + useEffect(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY, sessionTimeoutAction); + }, [sessionTimeoutAction]); + function handleToggleTheme() { setThemePreference((prev) => { const current = prev === 'system' ? systemTheme : prev; @@ -284,7 +298,12 @@ export default function App() { function setLockTimeoutMinutes(next: LockTimeoutMinutes) { setLockTimeoutMinutesState(next); - pushToast('success', t('txt_auto_lock_updated')); + pushToast('success', t('txt_session_timeout_updated')); + } + + function setSessionTimeoutAction(next: SessionTimeoutAction) { + setSessionTimeoutActionState(next); + pushToast('success', t('txt_session_timeout_updated')); } const authedFetch = useMemo( @@ -639,25 +658,32 @@ export default function App() { timerId = null; } }; - const scheduleLock = () => { + const runTimeoutAction = () => { + if (sessionTimeoutAction === 'logout') { + logoutNow(); + return; + } + if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) { + lockCurrentSession(); + } + }; + const scheduleTimeout = () => { clearTimer(); timerId = window.setTimeout(() => { - if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) { - lockCurrentSession(); - } + runTimeoutAction(); }, timeoutMs); }; const markActivity = () => { const now = Date.now(); if (now - lastActivityAt < 1000) return; lastActivityAt = now; - scheduleLock(); + scheduleTimeout(); }; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') markActivity(); }; - scheduleLock(); + scheduleTimeout(); window.addEventListener('pointerdown', markActivity, { passive: true }); window.addEventListener('keydown', markActivity); window.addEventListener('scroll', markActivity, { passive: true }); @@ -672,7 +698,7 @@ export default function App() { window.removeEventListener('touchstart', markActivity); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [phase, lockTimeoutMinutes]); + }, [phase, lockTimeoutMinutes, sessionTimeoutAction]); function renderPassiveOverlays() { return ( @@ -1213,6 +1239,7 @@ export default function App() { invites: invitesQuery.data || [], totpEnabled: !!totpStatusQuery.data?.enabled, lockTimeoutMinutes, + sessionTimeoutAction, authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevicesLoading: authorizedDevicesQuery.isFetching, onNavigate: navigate, @@ -1260,6 +1287,7 @@ export default function App() { onGetApiKey: accountSecurityActions.getApiKey, onRotateApiKey: accountSecurityActions.rotateApiKey, onLockTimeoutChange: setLockTimeoutMinutes, + onSessionTimeoutActionChange: setSessionTimeoutAction, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index a4a094c..38e0271 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -47,6 +47,7 @@ export interface AppMainRoutesProps { invites: AdminInvite[]; totpEnabled: boolean; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; + sessionTimeoutAction: 'lock' | 'logout'; authorizedDevices: AuthorizedDevice[]; authorizedDevicesLoading: boolean; onNavigate: (path: string) => void; @@ -99,6 +100,7 @@ export interface AppMainRoutesProps { onGetApiKey: (masterPassword: string) => Promise; onRotateApiKey: (masterPassword: string) => Promise; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; + onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onRefreshAuthorizedDevices: () => Promise; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; @@ -228,6 +230,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { profile={props.profile} totpEnabled={props.totpEnabled} lockTimeoutMinutes={props.lockTimeoutMinutes} + sessionTimeoutAction={props.sessionTimeoutAction} onChangePassword={props.onChangePassword} onSavePasswordHint={props.onSavePasswordHint} onEnableTotp={props.onEnableTotp} @@ -236,6 +239,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onGetApiKey={props.onGetApiKey} onRotateApiKey={props.onRotateApiKey} onLockTimeoutChange={props.onLockTimeoutChange} + onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onNotify={props.onNotify} /> diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index f65a4f5..83813fb 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; -import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact'; +import { Clipboard, KeyRound, Lightbulb, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact'; import { copyTextToClipboard } from '@/lib/clipboard'; import qrcode from 'qrcode-generator'; import type { Profile } from '@/lib/types'; @@ -10,6 +10,7 @@ interface SettingsPageProps { profile: Profile; totpEnabled: boolean; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; + sessionTimeoutAction: 'lock' | 'logout'; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; onSavePasswordHint: (masterPasswordHint: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; @@ -18,15 +19,16 @@ interface SettingsPageProps { onGetApiKey: (masterPassword: string) => Promise; onRotateApiKey: (masterPassword: string) => Promise; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; + onSessionTimeoutActionChange: (action: 'lock' | 'logout') => 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' }, + { value: 1, labelKey: 'txt_timeout_1_minute' }, + { value: 5, labelKey: 'txt_timeout_5_minutes' }, + { value: 15, labelKey: 'txt_timeout_15_minutes' }, + { value: 30, labelKey: 'txt_timeout_30_minutes' }, + { value: 0, labelKey: 'txt_timeout_never' }, ] as const; function randomBase32Secret(length: number): string { @@ -70,12 +72,13 @@ export default function SettingsPage(props: SettingsPageProps) { const [secret, setSecret] = useState(() => randomBase32Secret(32)); const [token, setToken] = useState(''); const [totpLocked, setTotpLocked] = useState(props.totpEnabled); - const [recoveryMasterPassword, setRecoveryMasterPassword] = useState(''); const [recoveryCode, setRecoveryCode] = useState(''); - const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState(''); const [apiKey, setApiKey] = useState(''); const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); + const [masterPasswordPrompt, setMasterPasswordPrompt] = useState(null); + const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState(''); + const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false); useEffect(() => { clearLegacyTotpSetupSecrets(); @@ -111,32 +114,51 @@ export default function SettingsPage(props: SettingsPageProps) { } } - async function loadRecoveryCode(): Promise { - const code = await props.onGetRecoveryCode(recoveryMasterPassword); - setRecoveryCode(code); - props.onNotify?.('success', t('txt_recovery_code_loaded')); + function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void { + setMasterPasswordPrompt(action); + setMasterPasswordPromptValue(''); } - async function loadApiKey(): Promise { + function closeMasterPasswordPrompt(): void { + if (masterPasswordPromptSubmitting) return; + setMasterPasswordPrompt(null); + setMasterPasswordPromptValue(''); + } + + async function submitMasterPasswordPrompt(): Promise { + if (!masterPasswordPrompt || masterPasswordPromptSubmitting) return; + const masterPassword = masterPasswordPromptValue; + setMasterPasswordPromptSubmitting(true); try { - const key = await props.onGetApiKey(apiKeyMasterPassword); - setApiKey(key); - setApiKeyDialogOpen(true); + if (masterPasswordPrompt === 'recovery') { + const code = await props.onGetRecoveryCode(masterPassword); + setRecoveryCode(code); + props.onNotify?.('success', t('txt_recovery_code_loaded')); + } else if (masterPasswordPrompt === 'apiKey') { + const key = await props.onGetApiKey(masterPassword); + setApiKey(key); + setApiKeyDialogOpen(true); + } else { + const key = await props.onRotateApiKey(masterPassword); + setApiKey(key); + setApiKeyDialogOpen(true); + props.onNotify?.('success', t('txt_api_key_rotated')); + } + setMasterPasswordPrompt(null); + setMasterPasswordPromptValue(''); } catch (error) { - props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); + props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2')); + } finally { + setMasterPasswordPromptSubmitting(false); } } - async function doRotateApiKey(): Promise { - try { - const key = await props.onRotateApiKey(apiKeyMasterPassword); - setApiKey(key); - setApiKeyDialogOpen(true); - props.onNotify?.('success', t('txt_api_key_rotated')); - } catch (error) { - props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); - } - } + const masterPasswordPromptTitle = + masterPasswordPrompt === 'recovery' + ? t('txt_view_recovery_code') + : masterPasswordPrompt === 'rotateApiKey' + ? t('txt_rotate_api_key') + : t('txt_view_api_key'); function formatDateTime(value: string | null | undefined): string { if (!value) return t('txt_dash'); @@ -146,12 +168,12 @@ export default function SettingsPage(props: SettingsPageProps) { } return ( -
-
-

{t('txt_security_preferences')}

-
+
+
+

{t('txt_session_timeout')}

+
-
+
+
+ +

{t('txt_change_master_password')}

-
-
-
-

{t('txt_totp')}

- {totpLocked &&
{t('txt_totp_is_enabled_for_this_account')}
} -
-
- TOTP QR -
-
-
- - -
- - - -
-
+
+

{t('txt_password_hint_optional')}

+ + +
+ +
+

{t('txt_totp')}

+ {totpLocked &&
{t('txt_totp_is_enabled_for_this_account')}
} +
+
+ TOTP QR +
+
+
+ + +
+ + +
-
+
+ +
-
-

{t('txt_recovery_code')}

-

- {t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')} -

- +
+

{t('txt_recovery_code_and_api_key')}

+
+
+
+

{t('txt_recovery_code')}

+

+ {t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')} +

+
- @@ -298,25 +333,19 @@ export default function SettingsPage(props: SettingsPageProps) {
{recoveryCode && ( -
+
{recoveryCode}
)}
-
-

{t('txt_api_key')}

- +
+
+

{t('txt_api_key')}

+

{t('txt_api_key_dialog_intro')}

+
- @@ -332,6 +361,28 @@ export default function SettingsPage(props: SettingsPageProps) {
+ void submitMasterPasswordPrompt()} + onCancel={closeMasterPasswordPrompt} + > + + { setRotateApiKeyConfirmOpen(false); - void doRotateApiKey(); + openMasterPasswordPrompt('rotateApiKey'); }} onCancel={() => setRotateApiKeyConfirmOpen(false)} /> diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 7639915..394a4f1 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -309,6 +309,7 @@ const messages: Record> = { txt_bulk_delete_sends_failed: "Bulk delete sends failed", txt_bulk_move_failed: "Bulk move failed", txt_cancel: "Cancel", + txt_continue: "Continue", txt_card: "Card", txt_card_details: "Card Details", txt_cardholder_name: "Cardholder Name", @@ -417,6 +418,7 @@ const messages: Record> = { txt_encrypted_file_2: "Encrypted file", txt_enter_a_folder_name: "Enter a folder name.", txt_enter_master_password_to_disable_two_step_verification: "Enter master password to disable two-step verification.", + txt_enter_master_password_to_continue: "Enter your master password to continue.", txt_enter_master_password_to_view_this_item: "Enter master password to view this item.", txt_expiration_date: "Expiration Date", txt_expiration_days_0_never: "Expiration Days (0 = never)", @@ -598,6 +600,7 @@ const messages: Record> = { txt_recover_two_step_login: "Recover Two-step Login", txt_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.", txt_recovery_code: "Recovery Code", + txt_recovery_code_and_api_key: "Recovery Code and API Key", txt_recovery_code_copied: "Recovery code copied", txt_recovery_code_is_empty: "Recovery code is empty", txt_recovery_code_loaded: "Recovery code loaded", @@ -1041,6 +1044,7 @@ const zhCNOverrides: Record = { txt_confirm_master_password: '确认主密码', txt_submit: '提交', txt_cancel: '取消', + txt_continue: '继续', txt_yes: '是', txt_no: '否', txt_loading: '加载中...', @@ -1308,6 +1312,7 @@ const zhCNOverrides: Record = { txt_encrypted_file_2: '加密文件', txt_enter_a_folder_name: '请输入文件夹名称', txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证', + txt_enter_master_password_to_continue: '输入主密码以继续', txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目', txt_expiry: '有效期', txt_expiry_month: '有效期月', @@ -1376,6 +1381,7 @@ const zhCNOverrides: Record = { txt_recover_2fa_failed: '恢复 2FA 失败', txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录', txt_recovery_code_copied: '恢复代码已复制', + txt_recovery_code_and_api_key: '恢复代码和 API 密钥', txt_recovery_code_is_empty: '恢复代码为空', txt_recovery_code_loaded: '恢复代码已加载', txt_api_key: 'API 密钥', @@ -1488,16 +1494,40 @@ 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_session_timeout = 'Session timeout'; +messages.en.txt_session_timeout_updated = 'Session timeout updated'; +messages.en.txt_timeout_time = 'Timeout time'; +messages.en.txt_timeout_action = 'Timeout action'; +messages.en.txt_timeout_action_logout = 'Log out'; +messages.en.txt_timeout_action_lock = 'Lock'; +messages.en.txt_in_planning = 'In planning'; messages.en.txt_security_preferences = 'Security Preferences'; +messages.en.txt_timeout_1_minute = '1 minute'; +messages.en.txt_timeout_5_minutes = '5 minutes'; +messages.en.txt_timeout_15_minutes = '15 minutes'; +messages.en.txt_timeout_30_minutes = '30 minutes'; +messages.en.txt_timeout_never = 'Never'; 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_auto_lock = '会话超时'; +zhCNOverrides.txt_auto_lock_description = '页面闲置后执行会话超时动作;关闭页面或浏览器后再次打开始终进入锁定页。'; +zhCNOverrides.txt_auto_lock_updated = '会话超时已更新'; +zhCNOverrides.txt_session_timeout = '会话超时'; +zhCNOverrides.txt_session_timeout_updated = '会话超时已更新'; +zhCNOverrides.txt_timeout_time = '超时时间'; +zhCNOverrides.txt_timeout_action = '超时动作'; +zhCNOverrides.txt_timeout_action_logout = '注销'; +zhCNOverrides.txt_timeout_action_lock = '锁定'; +zhCNOverrides.txt_in_planning = '构思中'; zhCNOverrides.txt_security_preferences = '安全偏好'; +zhCNOverrides.txt_timeout_1_minute = '1 分钟'; +zhCNOverrides.txt_timeout_5_minutes = '5 分钟'; +zhCNOverrides.txt_timeout_15_minutes = '15 分钟'; +zhCNOverrides.txt_timeout_30_minutes = '30 分钟'; +zhCNOverrides.txt_timeout_never = '从不'; zhCNOverrides.txt_lock_after_1_minute = '闲置 1 分钟后'; zhCNOverrides.txt_lock_after_5_minutes = '闲置 5 分钟后'; zhCNOverrides.txt_lock_after_15_minutes = '闲置 15 分钟后'; diff --git a/webapp/src/styles/management.css b/webapp/src/styles/management.css index 95700a0..2db58e0 100644 --- a/webapp/src/styles/management.css +++ b/webapp/src/styles/management.css @@ -425,8 +425,57 @@ @apply mb-2; } +.settings-modules-grid { + @apply grid gap-3; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.settings-module { + @apply min-w-0; +} + +.settings-module h3 { + @apply mb-4 mt-0 text-base font-extrabold; + color: var(--text); +} + +.settings-module-placeholder { + @apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold; + color: var(--muted); +} + +.settings-module-placeholder svg { + color: var(--primary-strong); +} + +.settings-module .field:last-child, +.session-timeout-fields .field { + @apply mb-0; +} + +.session-timeout-fields { + @apply grid gap-3; +} + +.sensitive-actions-grid { + @apply grid gap-3; +} + +.sensitive-action { + @apply rounded-lg border p-3.5; + border-color: var(--line-soft); + background: color-mix(in srgb, var(--surface) 74%, transparent); +} + +.sensitive-action h4 { + @apply mb-1 mt-0 text-base font-extrabold; + color: var(--text); +} + .recovery-code-card { - @apply mb-0 mt-2.5; + @apply mb-0 mt-2.5 rounded-lg border p-3; + border-color: var(--line-soft); + background: color-mix(in srgb, var(--surface) 84%, transparent); } .recovery-code-value { diff --git a/webapp/src/styles/overlays.css b/webapp/src/styles/overlays.css index d0b8f95..4cc63ea 100644 --- a/webapp/src/styles/overlays.css +++ b/webapp/src/styles/overlays.css @@ -9,7 +9,7 @@ .dialog-card { @apply rounded-[20px] border bg-white p-5 text-center; - width: min(460px, 100%); + width: min(5000px, 100%); border: 1px solid var(--line); box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2); transform-origin: 50% 30%; diff --git a/webapp/src/styles/responsive.css b/webapp/src/styles/responsive.css index 0b425b7..0167fd0 100644 --- a/webapp/src/styles/responsive.css +++ b/webapp/src/styles/responsive.css @@ -462,6 +462,11 @@ gap: 10px; } + .settings-modules-grid, + .password-settings-grid { + grid-template-columns: 1fr; + } + .import-export-panel .actions .btn, .settings-subcard .actions .btn, .section-head .actions .btn {