Files
nodewarden/webapp/src/App.tsx
T

1450 lines
53 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
import AuthViews from '@/components/AuthViews';
import PublicSendPage from '@/components/PublicSendPage';
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage';
import {
createAuthedFetch,
getAuthorizedDevices,
clearProfileSnapshot,
getCurrentDeviceIdentifier,
getPasswordHint,
loadProfileSnapshot,
saveProfileSnapshot,
revokeCurrentSession,
getTotpStatus,
saveSession,
stripProfileSecrets,
} from '@/lib/api/auth';
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
import { buildSendShareKey, getSends } from '@/lib/api/send';
import {
getCiphers,
getFolders,
updateFolder,
} from '@/lib/api/vault';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
import {
buildPublicSendUrl,
deriveSendKeyParts,
looksLikeCipherString,
parseSignalRTextFrames,
readInviteCodeFromUrl,
} from '@/lib/app-support';
import {
bootstrapAppSession,
type CompletedLogin,
readInitialAppBootstrapState,
performPasswordLogin,
performRecoverTwoFactorLogin,
performRegistration,
performTotpLogin,
hydrateLockedSession,
performUnlock,
type JwtUnsafeReason,
type PendingTotp,
} from '@/lib/app-auth';
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
import useAdminActions from '@/hooks/useAdminActions';
import useBackupActions from '@/hooks/useBackupActions';
import useVaultSendActions from '@/hooks/useVaultSendActions';
import { useToastManager } from '@/hooks/useToastManager';
import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
if (!value || typeof value !== 'object') return false;
const detail = value as Record<string, unknown>;
const operation = detail.operation;
return (
(operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run')
&& typeof detail.step === 'string'
&& typeof detail.fileName === 'string'
);
}
const IMPORT_ROUTE = '/backup/import-export';
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
const SETTINGS_HOME_ROUTE = '/settings';
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
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';
const stored = String(window.localStorage.getItem(THEME_STORAGE_KEY) || '').trim();
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
return 'system';
}
function resolveSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return '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() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
const [loginValues, setLoginValues] = useState({ email: '', password: '' });
const [registerValues, setRegisterValues] = useState({
name: '',
email: '',
password: '',
password2: '',
passwordHint: '',
inviteCode: initialInviteCode,
});
const [loginHintState, setLoginHintState] = useState<{
email: string;
loading: boolean;
hint: string | null;
}>({
email: '',
loading: false,
hint: null,
});
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);
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
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);
const [mobileSidebarToggleKey, setMobileSidebarToggleKey] = useState(0);
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>('');
const { toasts, pushToast, removeToast } = useToastManager();
useEffect(() => {
const handleAppNotify = (event: Event) => {
const detail = (event as CustomEvent<AppNotifyDetail>).detail;
if (!detail?.text) return;
pushToast(detail.type, detail.text);
};
window.addEventListener(APP_NOTIFY_EVENT, handleAppNotify as EventListener);
return () => window.removeEventListener(APP_NOTIFY_EVENT, handleAppNotify as EventListener);
}, [pushToast]);
useEffect(() => {
const syncInviteFromUrl = () => {
setInviteCodeFromUrl(readInviteCodeFromUrl());
};
syncInviteFromUrl();
window.addEventListener('hashchange', syncInviteFromUrl);
window.addEventListener('popstate', syncInviteFromUrl);
return () => {
window.removeEventListener('hashchange', syncInviteFromUrl);
window.removeEventListener('popstate', syncInviteFromUrl);
};
}, []);
useEffect(() => {
if (!inviteCodeFromUrl) return;
setRegisterValues((prev) => (prev.inviteCode === inviteCodeFromUrl ? prev : { ...prev, inviteCode: inviteCodeFromUrl }));
}, [inviteCodeFromUrl]);
useEffect(() => {
const normalizedEmail = loginValues.email.trim().toLowerCase();
setLoginHintState((prev) => (
prev.email && prev.email !== normalizedEmail
? { email: '', loading: false, hint: null }
: prev
));
}, [loginValues.email]);
useEffect(() => {
if (!inviteCodeFromUrl) return;
if (phase === 'locked' || phase === 'app') return;
setPhase('register');
if (location !== '/register') navigate('/register');
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
window.history.replaceState(null, '', '/register');
}
setInviteCodeFromUrl('');
}, [inviteCodeFromUrl, phase, location, navigate]);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia('(max-width: 1180px)');
const sync = () => setMobileLayout(media.matches);
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemTheme(media.matches ? 'dark' : 'light');
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
const resolvedTheme = themePreference === 'system' ? systemTheme : themePreference;
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.style.colorScheme = resolvedTheme;
}, [resolvedTheme]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
}, [themePreference]);
useEffect(() => {
saveProfileSnapshot(profile);
}, [profile]);
useEffect(() => {
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;
return current === 'dark' ? 'light' : 'dark';
});
}
function setSession(next: SessionState | null) {
sessionRef.current = next;
setSessionState(next);
saveSession(next);
}
function setLockTimeoutMinutes(next: LockTimeoutMinutes) {
setLockTimeoutMinutesState(next);
pushToast('success', t('txt_auto_lock_updated'));
}
const authedFetch = useMemo(
() =>
createAuthedFetch(
() => session,
(next) => {
setSession(next);
if (!next) {
setProfile(null);
setPhase('login');
}
}
),
[session]
);
const importAuthedFetch = useMemo(
() => async (input: string, init?: RequestInit) => {
const headers = new Headers(init?.headers || {});
headers.set('X-NodeWarden-Import', '1');
return authedFetch(input, { ...init, headers });
},
[authedFetch]
);
const backupActions = useBackupActions({
authedFetch,
onImported: () => {
window.setTimeout(() => {
logoutNow();
}, 200);
},
onRestored: () => {
window.setTimeout(() => {
logoutNow();
}, 200);
},
});
useEffect(() => {
let mounted = true;
(async () => {
const boot = await bootstrapAppSession(initialBootstrap);
if (!mounted) return;
setDefaultKdfIterations(boot.defaultKdfIterations);
setJwtWarning(boot.jwtWarning);
setSession(boot.session);
setProfile(boot.profile);
setPhase(boot.phase);
setUnlockPreparing(boot.phase === 'locked' && !boot.session?.email);
})();
return () => {
mounted = false;
};
}, [initialBootstrap]);
useEffect(() => {
if (phase !== 'locked' || !session) return;
let cancelled = false;
void (async () => {
const result = await hydrateLockedSession(session, profile);
if (cancelled) return;
if (!result.session) {
setSession(null);
setProfile(null);
setUnlockPreparing(false);
setPhase('login');
if (location !== '/login') navigate('/login');
return;
}
setSession(result.session);
if (result.profile) {
setProfile(stripProfileSecrets(result.profile));
}
})();
return () => {
cancelled = true;
};
}, [phase, session?.email, location, navigate]);
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', successMessage);
void (async () => {
try {
const hydratedProfile = await login.profilePromise;
if (sessionRef.current?.accessToken !== login.session.accessToken) return;
setProfile(hydratedProfile);
} catch {
// Keep the in-memory transient profile for the current session.
}
})();
}
async function handleLogin() {
if (pendingAuthAction) return;
if (!loginValues.email || !loginValues.password) {
pushToast('error', t('txt_please_input_email_and_password'));
return;
}
setPendingAuthAction('login');
try {
const result = await performPasswordLogin(loginValues.email, loginValues.password, defaultKdfIterations);
if (result.kind === 'success') {
await finalizeLogin(result.login);
return;
}
if (result.kind === 'totp') {
setPendingTotp(result.pendingTotp);
setPendingTotpMode('login');
setTotpCode('');
setRememberDevice(true);
return;
}
pushToast('error', result.message || t('txt_login_failed'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
} finally {
setPendingAuthAction(null);
}
}
async function handleTotpVerify() {
if (totpSubmitting) return;
if (!pendingTotp) return;
if (!totpCode.trim()) {
pushToast('error', t('txt_please_input_totp_code'));
return;
}
setTotpSubmitting(true);
try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
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 {
setTotpSubmitting(false);
}
}
async function handleRecoverTwoFactorSubmit() {
const email = recoverValues.email.trim().toLowerCase();
const password = recoverValues.password;
const recoveryCode = recoverValues.recoveryCode.trim();
if (!email || !password || !recoveryCode) {
pushToast('error', t('txt_email_password_and_recovery_code_are_required'));
return;
}
try {
const recovered = await performRecoverTwoFactorLogin(email, password, recoveryCode, defaultKdfIterations);
if (recovered.login) {
await finalizeLogin(recovered.login);
if (recovered.newRecoveryCode) {
pushToast('success', t('txt_text_2fa_recovered_new_recovery_code_code', { code: recovered.newRecoveryCode }));
} else {
pushToast('success', t('txt_text_2fa_recovered'));
}
return;
}
pushToast('error', t('txt_recovered_but_auto_login_failed_please_sign_in'));
navigate('/login');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_recover_2fa_failed'));
}
}
async function handleRegister() {
if (pendingAuthAction) return;
if (!registerValues.email || !registerValues.password) {
pushToast('error', t('txt_please_input_email_and_password'));
return;
}
if (registerValues.password.length < 12) {
pushToast('error', t('txt_master_password_must_be_at_least_12_chars'));
return;
}
if (registerValues.password !== registerValues.password2) {
pushToast('error', t('txt_passwords_do_not_match'));
return;
}
setPendingAuthAction('register');
try {
const resp = await performRegistration({
email: registerValues.email,
name: registerValues.name,
password: registerValues.password,
masterPasswordHint: registerValues.passwordHint,
inviteCode: registerValues.inviteCode,
fallbackIterations: defaultKdfIterations,
});
if (!resp.ok) {
pushToast('error', resp.message);
return;
}
setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
setPhase('login');
navigate('/login');
pushToast('success', t('txt_registration_succeeded_please_sign_in'));
} finally {
setPendingAuthAction(null);
}
}
function openPasswordHintDialog(hint: string | null) {
setConfirm({
title: t('txt_password_hint'),
message: hint || t('txt_password_hint_not_set'),
showIcon: false,
confirmText: t('txt_close'),
hideCancel: true,
onConfirm: () => setConfirm(null),
});
}
async function handleTogglePasswordHint() {
if (pendingAuthAction) return;
const email = loginValues.email.trim().toLowerCase();
if (!email) return;
if (loginHintState.email === email && !loginHintState.loading) {
openPasswordHintDialog(loginHintState.hint);
return;
}
setLoginHintState({
email,
loading: true,
hint: null,
});
try {
const result = await getPasswordHint(email);
openPasswordHintDialog(result.masterPasswordHint);
setLoginHintState({
email,
loading: false,
hint: result.masterPasswordHint,
});
} catch (error) {
setLoginHintState({
email: '',
loading: false,
hint: null,
});
pushToast('error', error instanceof Error ? error.message : t('txt_password_hint_load_failed'));
}
}
function handleShowLockedPasswordHint() {
if (pendingAuthAction) return;
openPasswordHintDialog(profile?.masterPasswordHint ?? null);
}
async function handleUnlock() {
if (pendingAuthAction) return;
if (!session?.email) return;
if (!unlockPassword) {
pushToast('error', t('txt_please_input_master_password'));
return;
}
setPendingAuthAction('unlock');
try {
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 {
setPendingAuthAction(null);
}
}
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);
setSession(null);
clearProfileSnapshot();
setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null);
setPendingTotpMode(null);
setPhase('login');
navigate('/login');
}
function handleLogout() {
setConfirm({
title: t('txt_log_out'),
message: t('txt_are_you_sure_you_want_to_log_out'),
showIcon: false,
onConfirm: () => {
logoutNow();
},
});
}
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
toasts={toasts}
onCloseToast={removeToast}
confirm={null}
onCancelConfirm={() => {}}
pendingTotpOpen={false}
totpCode=""
rememberDevice={false}
onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}}
onCancelTotp={() => {}}
onUseRecoveryCode={() => {}}
totpSubmitting={false}
disableTotpOpen={false}
disableTotpPassword=""
onDisableTotpPasswordChange={() => {}}
onConfirmDisableTotp={() => {}}
onCancelDisableTotp={() => {}}
disableTotpSubmitting={false}
/>
);
}
const ciphersQuery = useQuery({
queryKey: ['ciphers', session?.accessToken],
queryFn: () => getCiphers(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const foldersQuery = useQuery({
queryKey: ['folders', session?.accessToken],
queryFn: () => getFolders(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const sendsQuery = useQuery({
queryKey: ['sends', session?.accessToken],
queryFn: () => getSends(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const usersQuery = useQuery({
queryKey: ['admin-users', session?.accessToken],
queryFn: () => listAdminUsers(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin',
});
const invitesQuery = useQuery({
queryKey: ['admin-invites', session?.accessToken],
queryFn: () => listAdminInvites(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin',
});
const totpStatusQuery = useQuery({
queryKey: ['totp-status', session?.accessToken],
queryFn: () => getTotpStatus(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
});
const authorizedDevicesQuery = useQuery({
queryKey: ['authorized-devices', session?.accessToken],
queryFn: () => getAuthorizedDevices(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
});
useEffect(() => {
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
if (!profile?.role || profile.role !== 'admin') return;
if (repairAttemptRef.current === session.accessToken) return;
repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile);
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile]);
useEffect(() => {
if (session?.accessToken) return;
repairAttemptRef.current = '';
}, [session?.accessToken]);
useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey) {
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
return;
}
if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return;
let active = true;
(async () => {
try {
const encKey = base64ToBytes(session.symEncKey!);
const macKey = base64ToBytes(session.symMacKey!);
const decryptField = async (
value: string | null | undefined,
fieldEnc: Uint8Array = encKey,
fieldMac: Uint8Array = macKey
): Promise<string> => {
if (!value || typeof value !== 'string') return '';
try {
return await decryptStr(value, fieldEnc, fieldMac);
} catch {
// Backward-compatibility: some records may already be plain text.
return value;
}
};
const folders = await Promise.all(
foldersQuery.data.map(async (folder) => ({
...folder,
decName: await decryptField(folder.name, encKey, macKey),
}))
);
const ciphers = await Promise.all(
ciphersQuery.data.map(async (cipher) => {
let itemEnc = encKey;
let itemMac = macKey;
if (cipher.key) {
try {
const itemKey = await decryptBw(cipher.key, encKey, macKey);
itemEnc = itemKey.slice(0, 32);
itemMac = itemKey.slice(32, 64);
} catch {
// keep user key when item key decrypt fails
}
}
const nextCipher: Cipher = {
...cipher,
decName: await decryptField(cipher.name || '', itemEnc, itemMac),
decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac),
};
if (cipher.login) {
nextCipher.login = {
...cipher.login,
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
uris: await Promise.all(
(cipher.login.uris || []).map(async (u) => ({
...u,
decUri: await decryptField(u.uri || '', itemEnc, itemMac),
}))
),
};
}
if (Array.isArray(cipher.passwordHistory)) {
nextCipher.passwordHistory = await Promise.all(
cipher.passwordHistory.map(async (entry) => ({
...entry,
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac),
}))
);
}
if (cipher.card) {
nextCipher.card = {
...cipher.card,
decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac),
decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac),
decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac),
decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac),
decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac),
decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac),
};
}
if (cipher.identity) {
nextCipher.identity = {
...cipher.identity,
decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac),
decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac),
decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac),
decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac),
decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac),
decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac),
decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac),
decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac),
decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac),
decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac),
decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac),
decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac),
decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac),
decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac),
decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac),
decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac),
decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac),
decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac),
};
}
if (cipher.sshKey) {
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
nextCipher.sshKey = {
...cipher.sshKey,
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
keyFingerprint: encryptedFingerprint || null,
fingerprint: encryptedFingerprint || null,
decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac),
};
}
if (cipher.fields) {
nextCipher.fields = await Promise.all(
cipher.fields.map(async (field) => ({
...field,
decName: await decryptField(field.name || '', itemEnc, itemMac),
decValue: await decryptField(field.value || '', itemEnc, itemMac),
}))
);
}
if (Array.isArray(cipher.attachments)) {
nextCipher.attachments = await Promise.all(
cipher.attachments.map(async (attachment) => ({
...attachment,
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac),
}))
);
}
return nextCipher;
})
);
const sends = await Promise.all(
sendsQuery.data.map(async (send) => {
const nextSend: Send = { ...send };
try {
if (send.key) {
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
const derived = await deriveSendKeyParts(sendKeyRaw);
nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac);
nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac);
nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac);
if (send.file?.fileName) {
const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac);
nextSend.file = {
...(send.file || {}),
fileName: decFileName || send.file.fileName,
};
}
const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!);
nextSend.decShareKey = shareKey;
nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey);
} else {
nextSend.decName = '';
nextSend.decNotes = '';
nextSend.decText = '';
}
} catch {
nextSend.decName = t('txt_decrypt_failed');
}
return nextSend;
})
);
if (!active) return;
setDecryptedFolders(folders);
setDecryptedCiphers(ciphers);
setDecryptedSends(sends);
} catch (error) {
if (!active) return;
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
}
})();
return () => {
active = false;
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]);
useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return;
let cancelled = false;
(async () => {
const pending = foldersQuery.data.filter((folder) => {
if (!folder?.id || !folder?.name) return false;
if (migratedPlainFolderIdsRef.current.has(folder.id)) return false;
return !looksLikeCipherString(String(folder.name));
});
if (!pending.length) return;
for (const folder of pending) {
try {
await updateFolder(authedFetch, session, folder.id, String(folder.name));
migratedPlainFolderIdsRef.current.add(folder.id);
} catch {
// keep silent; web still supports plaintext fallback display
}
}
if (!cancelled) await foldersQuery.refetch();
})();
return () => {
cancelled = true;
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]);
async function refreshVaultSilently() {
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]);
}
silentRefreshVaultRef.current = refreshVaultSilently;
useEffect(() => {
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
let disposed = false;
let socket: WebSocket | null = null;
let reconnectTimer: number | null = null;
let reconnectAttempts = 0;
const clearReconnectTimer = () => {
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const scheduleReconnect = () => {
if (disposed) return;
clearReconnectTimer();
const delay = Math.min(10000, 1000 * Math.max(1, reconnectAttempts + 1));
reconnectAttempts += 1;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
};
const connect = () => {
if (disposed) return;
const accessToken = session.accessToken;
if (!accessToken) return;
try {
const hubUrl = new URL('/notifications/hub', window.location.origin);
hubUrl.searchParams.set('access_token', accessToken);
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(hubUrl.toString());
} catch {
scheduleReconnect();
return;
}
let pingTimer: number | null = null;
const clearPingTimer = () => {
if (pingTimer !== null) {
window.clearInterval(pingTimer);
pingTimer = null;
}
};
socket.addEventListener('open', () => {
reconnectAttempts = 0;
void refreshAuthorizedDevicesRef.current();
try {
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
} catch {
socket?.close();
return;
}
clearPingTimer();
pingTimer = window.setInterval(() => {
try {
socket?.send(`{"type":6}${SIGNALR_RECORD_SEPARATOR}`);
} catch {
// send failure will trigger close event
}
}, 15_000);
});
socket.addEventListener('message', (event) => {
if (disposed) return;
if (typeof event.data !== 'string') return;
const frames = parseSignalRTextFrames(event.data);
for (const frame of frames) {
if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue;
const updateType = Number(frame.arguments?.[0]?.Type || 0);
if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) {
logoutNow();
return;
}
if (updateType === SIGNALR_UPDATE_TYPE_DEVICE_STATUS) {
void refreshAuthorizedDevicesRef.current();
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
const payload = frame.arguments?.[0]?.Payload;
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
continue;
}
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
void silentRefreshVaultRef.current();
}
});
socket.addEventListener('close', () => {
socket = null;
clearPingTimer();
void refreshAuthorizedDevicesRef.current();
scheduleReconnect();
});
socket.addEventListener('error', () => {
try {
socket?.close();
} catch {
// ignore close races
}
});
};
connect();
return () => {
disposed = true;
clearReconnectTimer();
if (socket) {
const s = socket;
socket = null;
try {
s.close();
} catch {
// ignore close races
}
}
};
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey]);
const vaultSendActions = useVaultSendActions({
authedFetch,
importAuthedFetch,
session,
profile,
defaultKdfIterations,
encryptedCiphers: ciphersQuery.data,
encryptedFolders: foldersQuery.data,
refetchCiphers: ciphersQuery.refetch,
refetchFolders: foldersQuery.refetch,
refetchSends: sendsQuery.refetch,
onNotify: pushToast,
});
const accountSecurityActions = useAccountSecurityActions({
authedFetch,
profile,
defaultKdfIterations,
disableTotpPassword,
clearDisableTotpDialog: () => {
setDisableTotpOpen(false);
setDisableTotpPassword('');
},
onLogoutNow: logoutNow,
onNotify: pushToast,
onProfileUpdated: setProfile,
onSetConfirm: setConfirm,
refetchTotpStatus: totpStatusQuery.refetch,
refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
});
const adminActions = useAdminActions({
authedFetch,
onNotify: pushToast,
onSetConfirm: setConfirm,
refetchUsers: usersQuery.refetch,
refetchInvites: invitesQuery.refetch,
});
refreshAuthorizedDevicesRef.current = async () => {
await authorizedDevicesQuery.refetch();
};
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
const normalizedHashPath = trimmedHashPath ? `/${trimmedHashPath}` : '/';
const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath);
const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location;
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
const isPublicSendRoute = !!publicSendMatch;
const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location);
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
const mobilePrimaryRoute =
location === '/sends'
? '/sends'
: location === '/vault/totp'
? '/vault/totp'
: location === '/vault'
? '/vault'
: '/settings';
const currentPageTitle = (() => {
if (location === '/vault/totp') return t('txt_verification_code');
if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel');
if (location === '/security/devices') return t('nav_device_management');
if (location === '/backup') return t('nav_backup_strategy');
if (isImportRoute) return t('nav_import_export');
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
if (location === SETTINGS_HOME_ROUTE) return t('txt_settings');
return t('nav_my_vault');
})();
useEffect(() => {
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
}, [phase, location, isPublicSendRoute, navigate]);
useEffect(() => {
if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) {
navigate(IMPORT_ROUTE);
}
}, [phase, isImportHashRoute, location, navigate]);
useEffect(() => {
if (phase === 'app' && profile?.role !== 'admin' && location === '/backup') {
navigate('/vault');
}
}, [phase, profile?.role, location, navigate]);
useEffect(() => {
if (phase === 'app' && !mobileLayout && location === SETTINGS_HOME_ROUTE) {
navigate(SETTINGS_ACCOUNT_ROUTE);
}
}, [phase, mobileLayout, location, navigate]);
const mainRoutesProps = {
profile,
session,
mobileLayout,
mobileSidebarToggleKey,
importRoute: IMPORT_ROUTE,
settingsHomeRoute: SETTINGS_HOME_ROUTE,
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
decryptedCiphers,
decryptedFolders,
decryptedSends,
ciphersLoading: ciphersQuery.isFetching,
foldersLoading: foldersQuery.isFetching,
sendsLoading: sendsQuery.isFetching,
users: usersQuery.data || [],
invites: invitesQuery.data || [],
totpEnabled: !!totpStatusQuery.data?.enabled,
lockTimeoutMinutes,
authorizedDevices: authorizedDevicesQuery.data || [],
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
onNavigate: navigate,
onLogout: handleLogout,
onNotify: pushToast,
onImport: vaultSendActions.importVault,
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
onExport: vaultSendActions.exportVault,
onCreateVaultItem: vaultSendActions.createVaultItem,
onUpdateVaultItem: vaultSendActions.updateVaultItem,
onDeleteVaultItem: vaultSendActions.deleteVaultItem,
onArchiveVaultItem: vaultSendActions.archiveVaultItem,
onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem,
onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems,
onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems,
onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
onBulkArchiveVaultItems: vaultSendActions.bulkArchiveVaultItems,
onBulkUnarchiveVaultItems: vaultSendActions.bulkUnarchiveVaultItems,
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
onCreateFolder: vaultSendActions.createFolder,
onRenameFolder: vaultSendActions.renameFolder,
onDeleteFolder: vaultSendActions.deleteFolder,
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
downloadingAttachmentKey: vaultSendActions.downloadingAttachmentKey,
attachmentDownloadPercent: vaultSendActions.attachmentDownloadPercent,
uploadingAttachmentName: vaultSendActions.uploadingAttachmentName,
attachmentUploadPercent: vaultSendActions.attachmentUploadPercent,
onRefreshVault: vaultSendActions.refreshVault,
onCreateSend: vaultSendActions.createSend,
onUpdateSend: vaultSendActions.updateSend,
onDeleteSend: vaultSendActions.deleteSend,
onBulkDeleteSends: vaultSendActions.bulkDeleteSends,
uploadingSendFileName: vaultSendActions.uploadingSendFileName,
sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => {
await accountSecurityActions.enableTotp(secret, token);
await totpStatusQuery.refetch();
},
onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
onLockTimeoutChange: setLockTimeoutMinutes,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
onRefreshAdmin: adminActions.refreshAdmin,
onCreateInvite: adminActions.createInvite,
onDeleteAllInvites: adminActions.deleteAllInvites,
onToggleUserStatus: adminActions.toggleUserStatus,
onDeleteUser: adminActions.deleteUser,
onRevokeInvite: adminActions.revokeInvite,
onExportBackup: backupActions.exportBackup,
onImportBackup: backupActions.importBackup,
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
onLoadBackupSettings: backupActions.loadSettings,
onSaveBackupSettings: backupActions.saveSettings,
onRunRemoteBackup: backupActions.runRemoteBackup,
onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
};
if (jwtWarning) {
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
}
if (publicSendMatch) {
return (
<>
<PublicSendPage accessId={decodeURIComponent(publicSendMatch[1])} keyPart={publicSendMatch[2] ? decodeURIComponent(publicSendMatch[2]) : null} />
{renderPassiveOverlays()}
</>
);
}
if (isRecoverTwoFactorRoute && phase !== 'app') {
return (
<>
<RecoverTwoFactorPage
values={recoverValues}
onChange={setRecoverValues}
onSubmit={() => void handleRecoverTwoFactorSubmit()}
onCancel={() => {
setRecoverValues({ email: '', password: '', recoveryCode: '' });
navigate('/login');
}}
/>
{renderPassiveOverlays()}
</>
);
}
if (phase === 'register' || phase === 'login' || phase === 'locked') {
return (
<>
<AuthViews
mode={phase}
pendingAction={pendingAuthAction}
unlockReady={!!session?.email}
unlockPreparing={unlockPreparing}
loginValues={loginValues}
registerValues={registerValues}
unlockPassword={unlockPassword}
emailForLock={profile?.email || session?.email || ''}
loginHintLoading={loginHintState.loading}
onChangeLogin={setLoginValues}
onChangeRegister={setRegisterValues}
onChangeUnlock={setUnlockPassword}
onSubmitLogin={() => void handleLogin()}
onSubmitRegister={() => void handleRegister()}
onSubmitUnlock={() => void handleUnlock()}
onGotoLogin={() => {
setPhase('login');
navigate('/login');
}}
onGotoRegister={() => {
if (inviteCodeFromUrl) {
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
}
setPhase('register');
navigate('/register');
}}
onLogout={logoutNow}
onTogglePasswordHint={() => void handleTogglePasswordHint()}
onShowLockedPasswordHint={handleShowLockedPasswordHint}
/>
<AppGlobalOverlays
toasts={toasts}
onCloseToast={removeToast}
confirm={confirm}
onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp}
totpCode={totpCode}
rememberDevice={rememberDevice}
onTotpCodeChange={setTotpCode}
onRememberDeviceChange={setRememberDevice}
onConfirmTotp={() => void handleTotpVerify()}
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');
}}
totpSubmitting={totpSubmitting}
disableTotpOpen={false}
disableTotpPassword=""
onDisableTotpPasswordChange={() => {}}
onConfirmDisableTotp={() => {}}
onCancelDisableTotp={() => {}}
disableTotpSubmitting={false}
/>
</>
);
}
return (
<>
<AppAuthenticatedShell
profile={profile}
location={location}
mobilePrimaryRoute={mobilePrimaryRoute}
currentPageTitle={currentPageTitle}
showSidebarToggle={showSidebarToggle}
sidebarToggleTitle={sidebarToggleTitle}
settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE}
importRoute={IMPORT_ROUTE}
isImportRoute={isImportRoute}
darkMode={resolvedTheme === 'dark'}
themeToggleTitle={resolvedTheme === 'dark' ? t('txt_switch_to_light_mode') : t('txt_switch_to_dark_mode')}
onLock={handleLock}
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
mainRoutesProps={mainRoutesProps}
/>
<AppGlobalOverlays
toasts={toasts}
onCloseToast={removeToast}
confirm={confirm}
onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={false}
totpCode=""
rememberDevice={false}
onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}}
onCancelTotp={() => {}}
onUseRecoveryCode={() => {}}
totpSubmitting={false}
disableTotpOpen={disableTotpOpen}
disableTotpPassword={disableTotpPassword}
onDisableTotpPasswordChange={setDisableTotpPassword}
onConfirmDisableTotp={() => {
if (disableTotpSubmitting) return;
void (async () => {
setDisableTotpSubmitting(true);
try {
await accountSecurityActions.disableTotp();
} finally {
setDisableTotpSubmitting(false);
}
})();
}}
onCancelDisableTotp={() => {
if (disableTotpSubmitting) return;
setDisableTotpOpen(false);
setDisableTotpPassword('');
}}
disableTotpSubmitting={disableTotpSubmitting}
/>
</>
);
}