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, 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');
+4
View File
@@ -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>
+46 -19
View File
@@ -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">
+19 -3
View File
@@ -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();
} }
+28 -8
View File
@@ -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',
};
} }
+18
View File
@@ -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 = '待上传附件';