feat: add auto-lock feature with customizable timeout settings and update UI for security preferences

This commit is contained in:
shuaiplus
2026-04-24 15:27:46 +08:00
parent d40b0514fd
commit acd59a7387
6 changed files with 233 additions and 49 deletions
+118 -19
View File
@@ -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<LockTimeoutMinutes>([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<PendingTotp | null>(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<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
const [confirm, setConfirm] = useState<AppConfirmState | null>(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 (
<AppGlobalOverlays
@@ -1115,6 +1210,7 @@ export default function App() {
users: usersQuery.data || [],
invites: invitesQuery.data || [],
totpEnabled: !!totpStatusQuery.data?.enabled,
lockTimeoutMinutes,
authorizedDevices: authorizedDevicesQuery.data || [],
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
onNavigate: navigate,
@@ -1161,6 +1257,7 @@ export default function App() {
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
onLockTimeoutChange: setLockTimeoutMinutes,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
@@ -1223,7 +1320,7 @@ export default function App() {
<AuthViews
mode={phase}
pendingAction={pendingAuthAction}
unlockReady={!!profile?.key && !!session}
unlockReady={!!session?.email}
unlockPreparing={unlockPreparing}
loginValues={loginValues}
registerValues={registerValues}
@@ -1265,12 +1362,14 @@ export default function App() {
onCancelTotp={() => {
if (totpSubmitting) return;
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setRememberDevice(true);
}}
onUseRecoveryCode={() => {
if (totpSubmitting) return;
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setRememberDevice(true);
navigate('/recover-2fa');