mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add auto-lock feature with customizable timeout settings and update UI for security preferences
This commit is contained in:
+118
-19
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user