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,
|
revokeCurrentSession,
|
||||||
getTotpStatus,
|
getTotpStatus,
|
||||||
saveSession,
|
saveSession,
|
||||||
|
stripProfileSecrets,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
||||||
import { buildSendShareKey, getSends } from '@/lib/api/send';
|
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;
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||||
|
|
||||||
type ThemePreference = 'system' | 'light' | 'dark';
|
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 {
|
function readThemePreference(): ThemePreference {
|
||||||
if (typeof window === 'undefined') return 'system';
|
if (typeof window === 'undefined') return 'system';
|
||||||
@@ -95,6 +100,12 @@ function resolveSystemTheme(): 'light' | 'dark' {
|
|||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
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() {
|
export default function App() {
|
||||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||||
@@ -128,6 +139,7 @@ export default function App() {
|
|||||||
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
||||||
const [unlockPassword, setUnlockPassword] = useState('');
|
const [unlockPassword, setUnlockPassword] = useState('');
|
||||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||||
|
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [rememberDevice, setRememberDevice] = useState(true);
|
const [rememberDevice, setRememberDevice] = useState(true);
|
||||||
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||||
@@ -138,7 +150,8 @@ export default function App() {
|
|||||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
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 [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||||
const [mobileLayout, setMobileLayout] = useState(false);
|
const [mobileLayout, setMobileLayout] = useState(false);
|
||||||
@@ -245,11 +258,16 @@ export default function App() {
|
|||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'locked' && profile?.key && session) {
|
if (phase === 'locked' && session?.email) {
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
}
|
}
|
||||||
}, [phase, profile, session]);
|
}, [phase, profile, session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
||||||
|
}, [lockTimeoutMinutes]);
|
||||||
|
|
||||||
function handleToggleTheme() {
|
function handleToggleTheme() {
|
||||||
setThemePreference((prev) => {
|
setThemePreference((prev) => {
|
||||||
const current = prev === 'system' ? systemTheme : prev;
|
const current = prev === 'system' ? systemTheme : prev;
|
||||||
@@ -263,6 +281,11 @@ export default function App() {
|
|||||||
saveSession(next);
|
saveSession(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setLockTimeoutMinutes(next: LockTimeoutMinutes) {
|
||||||
|
setLockTimeoutMinutesState(next);
|
||||||
|
pushToast('success', t('txt_auto_lock_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
const authedFetch = useMemo(
|
const authedFetch = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createAuthedFetch(
|
createAuthedFetch(
|
||||||
@@ -309,7 +332,7 @@ export default function App() {
|
|||||||
setSession(boot.session);
|
setSession(boot.session);
|
||||||
setProfile(boot.profile);
|
setProfile(boot.profile);
|
||||||
setPhase(boot.phase);
|
setPhase(boot.phase);
|
||||||
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
|
setUnlockPreparing(boot.phase === 'locked' && !boot.session?.email);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -333,7 +356,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
setSession(result.session);
|
setSession(result.session);
|
||||||
if (result.profile) {
|
if (result.profile) {
|
||||||
setProfile(result.profile);
|
setProfile(stripProfileSecrets(result.profile));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -341,17 +364,19 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [phase, session?.email, location, navigate]);
|
}, [phase, session?.email, location, navigate]);
|
||||||
|
|
||||||
async function finalizeLogin(login: CompletedLogin) {
|
async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) {
|
||||||
setSession(login.session);
|
setSession(login.session);
|
||||||
setProfile(login.profile);
|
setProfile(login.profile);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
|
setPendingTotpMode(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
|
setUnlockPassword('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||||
navigate('/vault');
|
navigate('/vault');
|
||||||
}
|
}
|
||||||
pushToast('success', t('txt_login_success'));
|
pushToast('success', successMessage);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const hydratedProfile = await login.profilePromise;
|
const hydratedProfile = await login.profilePromise;
|
||||||
@@ -378,6 +403,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (result.kind === 'totp') {
|
if (result.kind === 'totp') {
|
||||||
setPendingTotp(result.pendingTotp);
|
setPendingTotp(result.pendingTotp);
|
||||||
|
setPendingTotpMode('login');
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
return;
|
return;
|
||||||
@@ -400,7 +426,7 @@ export default function App() {
|
|||||||
setTotpSubmitting(true);
|
setTotpSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||||
await finalizeLogin(login);
|
await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,20 +549,26 @@ export default function App() {
|
|||||||
|
|
||||||
async function handleUnlock() {
|
async function handleUnlock() {
|
||||||
if (pendingAuthAction) return;
|
if (pendingAuthAction) return;
|
||||||
if (!session || !profile) return;
|
if (!session?.email) return;
|
||||||
if (!unlockPassword) {
|
if (!unlockPassword) {
|
||||||
pushToast('error', t('txt_please_input_master_password'));
|
pushToast('error', t('txt_please_input_master_password'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingAuthAction('unlock');
|
setPendingAuthAction('unlock');
|
||||||
try {
|
try {
|
||||||
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||||
setSession(nextSession);
|
if (result.kind === 'success') {
|
||||||
setUnlockPassword('');
|
await finalizeLogin(result.login, t('txt_unlocked'));
|
||||||
setUnlockPreparing(false);
|
return;
|
||||||
setPhase('app');
|
}
|
||||||
if (location === '/' || location === '/lock') navigate('/vault');
|
if (result.kind === 'totp') {
|
||||||
pushToast('success', t('txt_unlocked'));
|
setPendingTotp(result.pendingTotp);
|
||||||
|
setPendingTotpMode('unlock');
|
||||||
|
setTotpCode('');
|
||||||
|
setRememberDevice(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
|
||||||
} catch {
|
} catch {
|
||||||
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
|
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -544,17 +576,30 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLock() {
|
function lockCurrentSession() {
|
||||||
if (!session) return;
|
const currentSession = sessionRef.current;
|
||||||
const nextSession = { ...session };
|
if (!currentSession) return;
|
||||||
|
const nextSession = { ...currentSession };
|
||||||
delete nextSession.symEncKey;
|
delete nextSession.symEncKey;
|
||||||
delete nextSession.symMacKey;
|
delete nextSession.symMacKey;
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setProfile((prev) => stripProfileSecrets(prev));
|
||||||
|
setDecryptedFolders([]);
|
||||||
|
setDecryptedCiphers([]);
|
||||||
|
setDecryptedSends([]);
|
||||||
|
setUnlockPassword('');
|
||||||
|
setPendingTotp(null);
|
||||||
|
setPendingTotpMode(null);
|
||||||
|
setTotpCode('');
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
setPhase('locked');
|
setPhase('locked');
|
||||||
navigate('/lock');
|
navigate('/lock');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLock() {
|
||||||
|
lockCurrentSession();
|
||||||
|
}
|
||||||
|
|
||||||
function logoutNow() {
|
function logoutNow() {
|
||||||
void revokeCurrentSession(sessionRef.current);
|
void revokeCurrentSession(sessionRef.current);
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
@@ -563,6 +608,7 @@ export default function App() {
|
|||||||
setProfile(null);
|
setProfile(null);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
|
setPendingTotpMode(null);
|
||||||
setPhase('login');
|
setPhase('login');
|
||||||
navigate('/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() {
|
function renderPassiveOverlays() {
|
||||||
return (
|
return (
|
||||||
<AppGlobalOverlays
|
<AppGlobalOverlays
|
||||||
@@ -1115,6 +1210,7 @@ export default function App() {
|
|||||||
users: usersQuery.data || [],
|
users: usersQuery.data || [],
|
||||||
invites: invitesQuery.data || [],
|
invites: invitesQuery.data || [],
|
||||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||||
|
lockTimeoutMinutes,
|
||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||||
onNavigate: navigate,
|
onNavigate: navigate,
|
||||||
@@ -1161,6 +1257,7 @@ export default function App() {
|
|||||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||||
onGetApiKey: accountSecurityActions.getApiKey,
|
onGetApiKey: accountSecurityActions.getApiKey,
|
||||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||||
|
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
@@ -1223,7 +1320,7 @@ export default function App() {
|
|||||||
<AuthViews
|
<AuthViews
|
||||||
mode={phase}
|
mode={phase}
|
||||||
pendingAction={pendingAuthAction}
|
pendingAction={pendingAuthAction}
|
||||||
unlockReady={!!profile?.key && !!session}
|
unlockReady={!!session?.email}
|
||||||
unlockPreparing={unlockPreparing}
|
unlockPreparing={unlockPreparing}
|
||||||
loginValues={loginValues}
|
loginValues={loginValues}
|
||||||
registerValues={registerValues}
|
registerValues={registerValues}
|
||||||
@@ -1265,12 +1362,14 @@ export default function App() {
|
|||||||
onCancelTotp={() => {
|
onCancelTotp={() => {
|
||||||
if (totpSubmitting) return;
|
if (totpSubmitting) return;
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
|
setPendingTotpMode(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
}}
|
}}
|
||||||
onUseRecoveryCode={() => {
|
onUseRecoveryCode={() => {
|
||||||
if (totpSubmitting) return;
|
if (totpSubmitting) return;
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
|
setPendingTotpMode(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
navigate('/recover-2fa');
|
navigate('/recover-2fa');
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface AppMainRoutesProps {
|
|||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
@@ -96,6 +97,7 @@ export interface AppMainRoutesProps {
|
|||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
@@ -222,6 +224,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<SettingsPage
|
<SettingsPage
|
||||||
profile={props.profile}
|
profile={props.profile}
|
||||||
totpEnabled={props.totpEnabled}
|
totpEnabled={props.totpEnabled}
|
||||||
|
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||||
onChangePassword={props.onChangePassword}
|
onChangePassword={props.onChangePassword}
|
||||||
onSavePasswordHint={props.onSavePasswordHint}
|
onSavePasswordHint={props.onSavePasswordHint}
|
||||||
onEnableTotp={props.onEnableTotp}
|
onEnableTotp={props.onEnableTotp}
|
||||||
@@ -229,6 +232,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
onGetApiKey={props.onGetApiKey}
|
onGetApiKey={props.onGetApiKey}
|
||||||
onRotateApiKey={props.onRotateApiKey}
|
onRotateApiKey={props.onRotateApiKey}
|
||||||
|
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ConfirmDialog from '@/components/ConfirmDialog';
|
|||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
@@ -16,9 +17,18 @@ interface SettingsPageProps {
|
|||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
onNotify?: (type: 'success' | 'error', text: string) => 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 {
|
function randomBase32Secret(length: number): string {
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
let out = '';
|
let out = '';
|
||||||
@@ -124,25 +134,42 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3>{t('txt_profile')}</h3>
|
<h3>{t('txt_security_preferences')}</h3>
|
||||||
<label className="field">
|
<div className="field-grid">
|
||||||
<span>{t('txt_password_hint_optional')}</span>
|
<label className="field">
|
||||||
<input
|
<span>{t('txt_auto_lock')}</span>
|
||||||
className="input"
|
<select
|
||||||
maxLength={120}
|
className="input"
|
||||||
value={passwordHint}
|
value={String(props.lockTimeoutMinutes)}
|
||||||
placeholder={t('txt_password_hint_placeholder')}
|
onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
|
||||||
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
>
|
||||||
/>
|
{LOCK_TIMEOUT_OPTIONS.map((option) => (
|
||||||
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
<option key={option.value} value={option.value}>
|
||||||
</label>
|
{t(option.labelKey)}
|
||||||
<button
|
</option>
|
||||||
type="button"
|
))}
|
||||||
className="btn btn-secondary"
|
</select>
|
||||||
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
<div className="field-help">{t('txt_auto_lock_description')}</div>
|
||||||
>
|
</label>
|
||||||
{t('txt_save_profile')}
|
<label className="field">
|
||||||
</button>
|
<span>{t('txt_password_hint_optional')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
maxLength={120}
|
||||||
|
value={passwordHint}
|
||||||
|
placeholder={t('txt_password_hint_placeholder')}
|
||||||
|
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||||
|
>
|
||||||
|
{t('txt_save_profile')}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
|
|||||||
@@ -122,9 +122,11 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
|
|||||||
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as Profile;
|
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;
|
if (email && parsed.email !== email) return null;
|
||||||
return parsed;
|
const snapshot = stripProfileSecrets(parsed);
|
||||||
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||||||
|
return snapshot;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -132,13 +134,27 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
|
|||||||
|
|
||||||
export function saveProfileSnapshot(profile: Profile | null): void {
|
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearProfileSnapshot(): void {
|
export function clearProfileSnapshot(): void {
|
||||||
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
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 {
|
export function getCurrentDeviceIdentifier(): string {
|
||||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,16 +372,36 @@ export async function performRegistration(args: {
|
|||||||
|
|
||||||
export async function performUnlock(
|
export async function performUnlock(
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
profile: Profile,
|
profile: Profile | null,
|
||||||
password: string,
|
password: string,
|
||||||
fallbackIterations: number
|
fallbackIterations: number
|
||||||
): Promise<SessionState> {
|
): Promise<PasswordLoginResult> {
|
||||||
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations);
|
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||||
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||||
const refreshedSession = await maybeRefreshSession(session);
|
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||||
if (!refreshedSession) {
|
|
||||||
throw new Error('Session expired');
|
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',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1485,6 +1485,24 @@ zhCNOverrides.txt_lock = '锁定';
|
|||||||
zhCNOverrides.txt_menu = '菜单';
|
zhCNOverrides.txt_menu = '菜单';
|
||||||
zhCNOverrides.txt_settings = '设置';
|
zhCNOverrides.txt_settings = '设置';
|
||||||
zhCNOverrides.txt_back = '返回';
|
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_attachments = '附件';
|
||||||
zhCNOverrides.txt_upload_attachments = '上传附件';
|
zhCNOverrides.txt_upload_attachments = '上传附件';
|
||||||
zhCNOverrides.txt_new_attachments = '待上传附件';
|
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||||
|
|||||||
Reference in New Issue
Block a user