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');
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface AppMainRoutesProps {
|
||||
users: AdminUser[];
|
||||
invites: AdminInvite[];
|
||||
totpEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
authorizedDevicesLoading: boolean;
|
||||
onNavigate: (path: string) => void;
|
||||
@@ -96,6 +97,7 @@ export interface AppMainRoutesProps {
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
@@ -222,6 +224,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
onChangePassword={props.onChangePassword}
|
||||
onSavePasswordHint={props.onSavePasswordHint}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
@@ -229,6 +232,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -9,6 +9,7 @@ import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
totpEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
@@ -16,9 +17,18 @@ interface SettingsPageProps {
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => 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 {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let out = '';
|
||||
@@ -124,7 +134,23 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>{t('txt_profile')}</h3>
|
||||
<h3>{t('txt_security_preferences')}</h3>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_auto_lock')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={String(props.lockTimeoutMinutes)}
|
||||
onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
|
||||
>
|
||||
{LOCK_TIMEOUT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="field-help">{t('txt_auto_lock_description')}</div>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_password_hint_optional')}</span>
|
||||
<input
|
||||
@@ -135,7 +161,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -143,6 +168,8 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
>
|
||||
{t('txt_save_profile')}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
|
||||
@@ -122,9 +122,11 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||
if (!raw) return null;
|
||||
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;
|
||||
return parsed;
|
||||
const snapshot = stripProfileSecrets(parsed);
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||||
return snapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -132,13 +134,27 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
|
||||
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||
if (!profile) return;
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile)));
|
||||
}
|
||||
|
||||
export function clearProfileSnapshot(): void {
|
||||
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 {
|
||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||
}
|
||||
|
||||
@@ -372,16 +372,36 @@ export async function performRegistration(args: {
|
||||
|
||||
export async function performUnlock(
|
||||
session: SessionState,
|
||||
profile: Profile,
|
||||
profile: Profile | null,
|
||||
password: string,
|
||||
fallbackIterations: number
|
||||
): Promise<SessionState> {
|
||||
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations);
|
||||
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession) {
|
||||
throw new Error('Session expired');
|
||||
): Promise<PasswordLoginResult> {
|
||||
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||
|
||||
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_settings = '设置';
|
||||
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_upload_attachments = '上传附件';
|
||||
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||
|
||||
Reference in New Issue
Block a user