import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useLocation } from 'wouter'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import AppAuthenticatedShell from '@/components/AppAuthenticatedShell'; import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays'; import AuthViews from '@/components/AuthViews'; import NotFoundPage from '@/components/NotFoundPage'; import PublicSendPage from '@/components/PublicSendPage'; import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage'; import JwtWarningPage from '@/components/JwtWarningPage'; import { createAuthedFetch, getAuthorizedDevices, clearProfileSnapshot, getCurrentDeviceIdentifier, getPasswordHint, getProfile, loadProfileSnapshot, saveProfileSnapshot, revokeCurrentSession, getTotpStatus, saveSession, stripProfileSecrets, } from '@/lib/api/auth'; import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getSends } from '@/lib/api/send'; import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { parseSignalRTextFrames, readInviteCodeFromUrl, } from '@/lib/app-support'; import { preloadAuthenticatedWorkspace, preloadDemoExperience } from '@/lib/app-preload'; 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 { clearOfflineUnlockRecord } from '@/lib/offline-auth'; import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt'; import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker'; import { DEMO_CIPHERS, DEMO_ADMIN_INVITES, DEMO_ADMIN_USERS, DEMO_AUTHORIZED_DEVICES, DEMO_FOLDERS, DEMO_SENDS, createDemoBackupSettings, IS_DEMO_MODE, createDemoCompletedLogin, createDemoInitialBootstrapState, createDemoMainRoutesProps, } from '@/lib/demo'; import type { AdminBackupSettings } from '@/lib/api/backup'; import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; import type { VaultCoreSnapshot } from '@/lib/vault-cache'; function isBackupProgressDetail(value: unknown): value is BackupProgressDetail { if (!value || typeof value !== 'object') return false; const detail = value as Record; 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 = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules'; const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const; const APP_ROUTE_PATHS = [ '/', '/vault', '/vault/totp', '/sends', '/admin', '/logs', '/security/devices', '/backup', '/settings', SETTINGS_ACCOUNT_ROUTE, SETTINGS_DOMAIN_RULES_ROUTE, '/help', ...IMPORT_ROUTE_PATHS, ] as const; const AUTH_ROUTES: ReadonlySet = new Set(AUTH_ROUTE_PATHS); const APP_ROUTES: ReadonlySet = new Set(APP_ROUTE_PATHS); function isAdminProfile(profile: Profile | null): profile is Profile { return String(profile?.role || '').toLowerCase() === 'admin'; } function normalizeRoutePath(path: string): string { const pathOnly = String(path || '/').split('?')[0].split('#')[0]; const normalized = pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`; return normalized.length > 1 ? normalized.replace(/\/+$/, '') : '/'; } 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; type SessionTimeoutAction = 'lock' | 'logout'; const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1'; const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1'; const LOCK_TIMEOUT_VALUES = new Set([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 stored = window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY); if (stored === null || stored.trim() === '') return 15; const value = Number(stored); return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15; } function readSessionTimeoutAction(): SessionTimeoutAction { if (typeof window === 'undefined') return 'lock'; const value = String(window.localStorage.getItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY) || '').trim(); return value === 'logout' ? 'logout' : 'lock'; } export default function App() { const initialBootstrap = useMemo( () => (IS_DEMO_MODE ? createDemoInitialBootstrapState() : readInitialAppBootstrapState()), [] ); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); const initialProfileSnapshot = useMemo( () => (IS_DEMO_MODE ? null : loadProfileSnapshot(initialBootstrap.session?.email)), [initialBootstrap] ); const queryClient = useQueryClient(); const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null); const [location, navigate] = useLocation(); const [phase, setPhase] = useState(initialBootstrap.phase); const [session, setSessionState] = useState(initialBootstrap.session); const [profile, setProfile] = useState(initialProfileSnapshot); const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations); const [registrationInviteRequired, setRegistrationInviteRequired] = useState(initialBootstrap.registrationInviteRequired); 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(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(() => readThemePreference()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState(() => readLockTimeoutMinutes()); const [sessionTimeoutAction, setSessionTimeoutActionState] = useState(() => readSessionTimeoutAction()); const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email); const [confirm, setConfirm] = useState(null); const [mobileLayout, setMobileLayout] = useState(false); const [mobileSidebarToggleKey, setMobileSidebarToggleKey] = useState(0); const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); const [demoUsers, setDemoUsers] = useState(() => DEMO_ADMIN_USERS.map((user) => ({ ...user }))); const [demoInvites, setDemoInvites] = useState(() => DEMO_ADMIN_INVITES.map((invite) => ({ ...invite }))); const [demoAuthorizedDevices, setDemoAuthorizedDevices] = useState(() => DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device }))); const [demoBackupSettings, setDemoBackupSettings] = useState(() => createDemoBackupSettings()); const [cachedVaultCore, setCachedVaultCore] = useState(null); const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false); const [vaultDecryptError, setVaultDecryptError] = useState(''); const [sendsDecryptDone, setSendsDecryptDone] = useState(false); const sessionRef = useRef(initialBootstrap.session); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); const repairAttemptRef = useRef(''); const uriChecksumRepairAttemptRef = useRef(''); const pendingVaultCoreQueryRefreshRef = useRef | null>(null); const pendingVaultCoreRefreshRef = useRef | null>(null); const notificationRefreshTimerRef = useRef(null); const domainRulesSaveSeqRef = useRef(0); const loginEmailRef = useRef(loginValues.email); const loginHintRequestSeqRef = useRef(0); const { toasts, pushToast, removeToast } = useToastManager(); useEffect(() => { const handleAppNotify = (event: Event) => { const detail = (event as CustomEvent).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(() => { loginEmailRef.current = loginValues.email; 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(() => { if (IS_DEMO_MODE) return; 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]); useEffect(() => { if (typeof window === 'undefined') return; window.localStorage.setItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY, sessionTimeoutAction); }, [sessionTimeoutAction]); 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_session_timeout_updated')); } function setSessionTimeoutAction(next: SessionTimeoutAction) { setSessionTimeoutActionState(next); pushToast('success', t('txt_session_timeout_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 vaultCacheKey = String(profile?.id || session?.email || '').trim(); const backupActions = useBackupActions({ authedFetch, onImported: () => { window.setTimeout(() => { logoutNow(); }, 200); }, onRestored: () => { window.setTimeout(() => { logoutNow(); }, 200); }, }); useEffect(() => { if (IS_DEMO_MODE) { const currentHashPath = typeof window !== 'undefined' ? (window.location.hash || '').replace(/^#/, '').split('?')[0].split('#')[0] : ''; const normalizedCurrentHashPath = currentHashPath.replace(/^\/+/, '').replace(/\/+$/, ''); const isDemoPublicSendRoute = /^send\/[^/]+(?:\/[^/]+)?$/i.test(normalizedCurrentHashPath); setDefaultKdfIterations(initialBootstrap.defaultKdfIterations); setRegistrationInviteRequired(initialBootstrap.registrationInviteRequired); setJwtWarning(null); setSession(null); setProfile(null); setPhase('login'); setUnlockPreparing(false); if (!isDemoPublicSendRoute && location !== '/login') navigate('/login'); return; } let mounted = true; (async () => { const boot = await bootstrapAppSession(initialBootstrap); if (!mounted) return; setDefaultKdfIterations(boot.defaultKdfIterations); setRegistrationInviteRequired(boot.registrationInviteRequired); 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; if (IS_DEMO_MODE) 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 (IS_DEMO_MODE) { setPendingAuthAction('login'); try { await finalizeLogin(createDemoCompletedLogin(loginValues.email), t('txt_login_success')); } finally { setPendingAuthAction(null); } 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 (IS_DEMO_MODE) { pushToast('warning', t('txt_demo_readonly_message')); setPhase('login'); navigate('/login'); 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; if (IS_DEMO_MODE) { openPasswordHintDialog(t('txt_demo_master_password_hint')); return; } const email = loginValues.email.trim().toLowerCase(); if (!email) return; if (loginHintState.email === email && !loginHintState.loading) { openPasswordHintDialog(loginHintState.hint); return; } const requestSeq = ++loginHintRequestSeqRef.current; setLoginHintState({ email, loading: true, hint: null, }); try { const result = await getPasswordHint(email); if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return; openPasswordHintDialog(result.masterPasswordHint); setLoginHintState({ email, loading: false, hint: result.masterPasswordHint, }); } catch (error) { if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return; 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((IS_DEMO_MODE ? t('txt_demo_master_password_hint') : profile?.masterPasswordHint) ?? null); } async function handleUnlock() { if (pendingAuthAction) return; if (!session?.email) return; if (IS_DEMO_MODE) { setPendingAuthAction('unlock'); try { await finalizeLogin(createDemoCompletedLogin(session.email), t('txt_unlocked')); } finally { setPendingAuthAction(null); } 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() { if (!IS_DEMO_MODE) { void revokeCurrentSession(sessionRef.current); } setConfirm(null); setSession(null); clearProfileSnapshot(); clearOfflineUnlockRecord(); 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 runTimeoutAction = () => { if (sessionTimeoutAction === 'logout') { logoutNow(); return; } if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) { lockCurrentSession(); } }; const scheduleTimeout = () => { clearTimer(); timerId = window.setTimeout(() => { runTimeoutAction(); }, timeoutMs); }; const markActivity = () => { const now = Date.now(); if (now - lastActivityAt < 1000) return; lastActivityAt = now; scheduleTimeout(); }; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') markActivity(); }; scheduleTimeout(); 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, sessionTimeoutAction]); function renderPassiveOverlays() { return ( {}} pendingTotpOpen={false} totpCode="" rememberDevice={false} onTotpCodeChange={() => {}} onRememberDeviceChange={() => {}} onConfirmTotp={() => {}} onCancelTotp={() => {}} onUseRecoveryCode={() => {}} totpSubmitting={false} disableTotpOpen={false} disableTotpPassword="" onDisableTotpPasswordChange={() => {}} onConfirmDisableTotp={() => {}} onCancelDisableTotp={() => {}} disableTotpSubmitting={false} /> ); } useEffect(() => { if (!IS_DEMO_MODE) return; if (phase !== 'app') { setDecryptedFolders([]); setDecryptedCiphers([]); setDecryptedSends([]); setDemoUsers(DEMO_ADMIN_USERS.map((user) => ({ ...user }))); setDemoInvites(DEMO_ADMIN_INVITES.map((invite) => ({ ...invite }))); setDemoAuthorizedDevices(DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device }))); setDemoBackupSettings(createDemoBackupSettings()); setVaultInitialDecryptDone(false); setSendsDecryptDone(false); return; } setDecryptedFolders(DEMO_FOLDERS.map((folder) => ({ ...folder }))); setDecryptedCiphers(DEMO_CIPHERS.map((cipher) => ({ ...cipher }))); setDecryptedSends(DEMO_SENDS.map((send) => ({ ...send }))); setDemoUsers(DEMO_ADMIN_USERS.map((user) => ({ ...user }))); setDemoInvites(DEMO_ADMIN_INVITES.map((invite) => ({ ...invite }))); setDemoAuthorizedDevices(DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device }))); setDemoBackupSettings(createDemoBackupSettings()); setVaultDecryptError(''); setVaultInitialDecryptDone(true); setSendsDecryptDone(true); }, [phase]); useEffect(() => { if (IS_DEMO_MODE) { setCachedVaultCore(null); return; } let cancelled = false; if (phase !== 'app' || !session?.symEncKey || !session?.symMacKey || !vaultCacheKey) { setCachedVaultCore(null); return; } void (async () => { const snapshot = await getCachedVaultCoreSnapshot(vaultCacheKey); if (!cancelled) { setCachedVaultCore(snapshot); } })(); return () => { cancelled = true; }; }, [phase, session?.symEncKey, session?.symMacKey, vaultCacheKey]); async function refetchVaultCoreData() { if (pendingVaultCoreQueryRefreshRef.current) { return pendingVaultCoreQueryRefreshRef.current; } const request = vaultCoreQuery.refetch().finally(() => { if (pendingVaultCoreQueryRefreshRef.current === request) { pendingVaultCoreQueryRefreshRef.current = null; } }); pendingVaultCoreQueryRefreshRef.current = request; return request; } const vaultCoreQuery = useQuery({ queryKey: ['vault-core', vaultCacheKey], queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey, staleTime: 30_000, }); const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore; const encryptedFolders = encryptedVaultCore?.folders; const encryptedCiphers = encryptedVaultCore?.ciphers; const encryptedSendsFromSync = encryptedVaultCore?.sends; const sendsQueryKey = useMemo(() => ['sends', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const sendsQuery = useQuery({ queryKey: sendsQueryKey, queryFn: () => getSends(authedFetch), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync, staleTime: 30_000, }); const encryptedSends = sendsQuery.data || encryptedSendsFromSync; async function refetchSendsFromVaultCore() { const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot }; const sends = Array.isArray(result.data?.sends) ? result.data.sends : []; queryClient.setQueryData(sendsQueryKey, sends); return { data: sends }; } useEffect(() => { if (!Array.isArray(encryptedSendsFromSync)) return; queryClient.setQueryData(sendsQueryKey, encryptedSendsFromSync); }, [queryClient, sendsQueryKey, encryptedSendsFromSync]); const profileQuery = useQuery({ queryKey: ['profile', vaultCacheKey || session?.email], queryFn: () => getProfile(authedFetch), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken, staleTime: 30_000, }); useEffect(() => { if (!profileQuery.data) return; setProfile(profileQuery.data); }, [profileQuery.data]); const isAdmin = isAdminProfile(profile); const usersQuery = useQuery({ queryKey: ['admin-users', vaultCacheKey], queryFn: () => listAdminUsers(authedFetch), enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); const invitesQuery = useQuery({ queryKey: ['admin-invites', vaultCacheKey], queryFn: () => listAdminInvites(authedFetch), enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); const totpStatusQuery = useQuery({ queryKey: ['totp-status', vaultCacheKey || session?.email], queryFn: () => getTotpStatus(authedFetch), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, staleTime: 30_000, }); const authorizedDevicesQuery = useQuery({ queryKey: ['authorized-devices', vaultCacheKey || session?.email], queryFn: () => getAuthorizedDevices(authedFetch), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, staleTime: 30_000, }); const domainRulesQueryKey = useMemo(() => ['domain-rules', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const domainRulesQuery = useQuery({ queryKey: domainRulesQueryKey, queryFn: () => getDomainRules(authedFetch), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, staleTime: 30_000, }); function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise { const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains); const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains); const currentRules = queryClient.getQueryData(domainRulesQueryKey) || domainRulesQuery.data; const optimisticRules: DomainRules = { object: 'domains', equivalentDomains, customEquivalentDomains, globalEquivalentDomains: (currentRules?.globalEquivalentDomains || []).map((rule) => ({ ...rule, excluded: excludedGlobalTypes.has(rule.type), })), }; const saveSeq = ++domainRulesSaveSeqRef.current; queryClient.setQueryData(domainRulesQueryKey, optimisticRules); void saveDomainRules(authedFetch, { customEquivalentDomains, equivalentDomains, excludedGlobalEquivalentDomains, }).then((updated) => { if (domainRulesSaveSeqRef.current !== saveSeq) return; queryClient.setQueryData(domainRulesQueryKey, updated); void queryClient.invalidateQueries({ queryKey: ['vault-core', vaultCacheKey] }); }).catch((error) => { if (domainRulesSaveSeqRef.current !== saveSeq) return; pushToast('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed')); void domainRulesQuery.refetch(); }); return Promise.resolve(); } useQuery({ queryKey: ['admin-backup-settings', vaultCacheKey], queryFn: () => backupActions.loadSettings(), enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); useEffect(() => { if (!IS_DEMO_MODE) return; return preloadDemoExperience(); }, []); useEffect(() => { if (IS_DEMO_MODE) return; if (phase !== 'app' || !vaultInitialDecryptDone) return; void preloadAuthenticatedWorkspace(isAdmin); }, [phase, vaultInitialDecryptDone, isAdmin]); useEffect(() => { if (IS_DEMO_MODE) return; if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; if (!vaultInitialDecryptDone) return; if (!isAdminProfile(profile)) return; if (repairAttemptRef.current === session.accessToken) return; repairAttemptRef.current = session.accessToken; void silentlyRepairBackupSettingsIfNeeded(session, profile); }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]); useEffect(() => { if (session?.accessToken) return; repairAttemptRef.current = ''; uriChecksumRepairAttemptRef.current = ''; }, [session?.accessToken]); useEffect(() => { if (IS_DEMO_MODE) return; if (!session?.symEncKey || !session?.symMacKey) { setDecryptedFolders([]); setDecryptedCiphers([]); setDecryptedSends([]); setVaultInitialDecryptDone(false); setVaultDecryptError(''); setSendsDecryptDone(false); return; } if (!encryptedFolders || !encryptedCiphers) return; let active = true; (async () => { try { setVaultDecryptError(''); let result; try { result = await decryptVaultCoreInWorker({ folders: encryptedFolders, ciphers: encryptedCiphers, symEncKeyB64: session.symEncKey!, symMacKeyB64: session.symMacKey!, }); } catch { result = await decryptVaultCore({ folders: encryptedFolders, ciphers: encryptedCiphers, symEncKeyB64: session.symEncKey!, symMacKeyB64: session.symMacKey!, }); } if (!active) return; setDecryptedFolders(result.folders); setDecryptedCiphers(result.ciphers); setVaultInitialDecryptDone(true); const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`; if (uriChecksumRepairAttemptRef.current !== repairKey) { uriChecksumRepairAttemptRef.current = repairKey; void repairCipherKeyMismatches(authedFetch, session, result.ciphers) .then(async (keyMismatchCount) => { if (keyMismatchCount > 0) { await invalidateVaultCoreSyncSnapshot(vaultCacheKey); void refetchVaultCoreData(); return; } const uriChecksumCount = await repairCipherUriChecksums(authedFetch, session, result.ciphers); if (uriChecksumCount > 0) { await invalidateVaultCoreSyncSnapshot(vaultCacheKey); void refetchVaultCoreData(); } }) .catch(() => { // Best-effort compatibility repair must not interrupt normal vault loading. }); } } catch (error) { if (!active) return; const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2'); setVaultDecryptError(message); setVaultInitialDecryptDone(true); pushToast('error', message); } })(); return () => { active = false; }; }, [session?.symEncKey, session?.symMacKey, vaultCacheKey, encryptedFolders, encryptedCiphers]); useEffect(() => { if (IS_DEMO_MODE) return; if (!session?.symEncKey || !session?.symMacKey) { setDecryptedSends([]); setSendsDecryptDone(false); return; } if (!encryptedSends) { setSendsDecryptDone(false); return; } if (!encryptedSends.length) { setDecryptedSends([]); setSendsDecryptDone(true); return; } let active = true; setSendsDecryptDone(false); (async () => { try { let sends; try { sends = await decryptSendsInWorker({ sends: encryptedSends, symEncKeyB64: session.symEncKey!, symMacKeyB64: session.symMacKey!, origin: window.location.origin, }); } catch { sends = await decryptSends({ sends: encryptedSends, symEncKeyB64: session.symEncKey!, symMacKeyB64: session.symMacKey!, origin: window.location.origin, }); } if (!active) return; setDecryptedSends(sends); setSendsDecryptDone(true); } catch (error) { if (!active) return; setSendsDecryptDone(true); pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2')); } })(); return () => { active = false; }; }, [session?.symEncKey, session?.symMacKey, encryptedSends]); async function refreshVaultSilently() { if (pendingVaultCoreRefreshRef.current) { await pendingVaultCoreRefreshRef.current; return; } const request = refetchVaultCoreData().finally(() => { if (pendingVaultCoreRefreshRef.current === request) { pendingVaultCoreRefreshRef.current = null; } }); pendingVaultCoreRefreshRef.current = request; await request; } silentRefreshVaultRef.current = refreshVaultSilently; useEffect(() => { if (IS_DEMO_MODE) return; if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) 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; if (notificationRefreshTimerRef.current !== null) { window.clearTimeout(notificationRefreshTimerRef.current); } notificationRefreshTimerRef.current = window.setTimeout(() => { notificationRefreshTimerRef.current = null; void silentRefreshVaultRef.current(); }, 250); } }); socket.addEventListener('close', () => { socket = null; clearPingTimer(); void refreshAuthorizedDevicesRef.current(); scheduleReconnect(); }); socket.addEventListener('error', () => { try { socket?.close(); } catch { // ignore close races } }); }; connect(); return () => { disposed = true; if (notificationRefreshTimerRef.current !== null) { window.clearTimeout(notificationRefreshTimerRef.current); notificationRefreshTimerRef.current = null; } clearReconnectTimer(); if (socket) { const s = socket; socket = null; try { s.close(); } catch { // ignore close races } } }; }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, vaultInitialDecryptDone]); const vaultSendActions = useVaultSendActions({ authedFetch, importAuthedFetch, session, profile, defaultKdfIterations, encryptedCiphers, encryptedFolders, refetchCiphers: async () => { const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot }; return { data: result.data?.ciphers }; }, refetchFolders: async () => { const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot }; return { data: result.data?.folders }; }, refetchSends: refetchSendsFromVaultCore, onNotify: pushToast, patchDecryptedCiphers: setDecryptedCiphers, patchDecryptedFolders: setDecryptedFolders, }); 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 () => { if (!vaultInitialDecryptDone) return; 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 normalizedLocation = normalizeRoutePath(location); const routeLocation = hashPath.startsWith('/') ? normalizedHashPath : normalizedLocation; const effectiveLocation = routeLocation; const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa'; const isPublicSendRoute = !!publicSendMatch; const isMalformedSendRoute = /^\/send(?:\/|$)/i.test(effectiveLocation) && !publicSendMatch; const isKnownAuthRoute = AUTH_ROUTES.has(routeLocation) || isPublicSendRoute || isRecoverTwoFactorRoute; const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute; const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation)); const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation); const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends'); const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type'); const demoDomainRules = useMemo(() => ({ equivalentDomains: [ ['nodewarden.example', 'nw.example'], ['staging.nodewarden.example', 'preview.nodewarden.example'], ], customEquivalentDomains: [ { id: 'demo-custom-1', domains: ['nodewarden.example', 'nw.example'], excluded: false }, { id: 'demo-custom-2', domains: ['staging.nodewarden.example', 'preview.nodewarden.example'], excluded: false }, ], globalEquivalentDomains: [ { type: 0, domains: ['youtube.com', 'google.com', 'gmail.com'], excluded: false }, { type: 1, domains: ['apple.com', 'icloud.com'], excluded: false }, { type: 10, domains: ['microsoft.com', 'office.com', 'xbox.com'], excluded: true }, { type: -10001, domains: ['nodewarden.example', 'nw.example'], excluded: false }, ], object: 'domains', }), []); 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 === '/logs') return t('nav_log_center'); if (location === '/security/devices') return t('nav_device_management'); if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules'); 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 === 'register' && (location === '/' || location === '/login') && !isPublicSendRoute) { navigate('/register'); } }, [phase, location, isPublicSendRoute, navigate]); useEffect(() => { if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) { navigate(IMPORT_ROUTE); } }, [phase, isImportHashRoute, location, navigate]); useEffect(() => { if (phase === 'app' && !isAdminProfile(profile) && (location === '/backup' || location === '/logs') && !profileQuery.isFetching) { navigate('/vault'); } }, [phase, profile?.role, profileQuery.isFetching, location, navigate]); useEffect(() => { if (phase === 'app' && !mobileLayout && location === SETTINGS_HOME_ROUTE) { navigate(SETTINGS_ACCOUNT_ROUTE); } }, [phase, mobileLayout, location, navigate]); const mainRoutesProps = { profile, profileLoading: profileQuery.isFetching && !profile, session, mobileLayout, mobileSidebarToggleKey, importRoute: IMPORT_ROUTE, settingsHomeRoute: SETTINGS_HOME_ROUTE, settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE, decryptedCiphers, decryptedFolders, decryptedSends, vaultError: vaultCoreQuery.isError && !encryptedVaultCore ? t('txt_load_vault_failed') : vaultDecryptError, ciphersLoading: !(vaultCoreQuery.isError && !encryptedVaultCore) && !vaultDecryptError && !vaultInitialDecryptDone, foldersLoading: !(vaultCoreQuery.isError && !encryptedVaultCore) && !vaultDecryptError && !vaultInitialDecryptDone, sendsLoading: (sendsQuery.isFetching && !encryptedSends) || (!!encryptedSends && !sendsDecryptDone), users: usersQuery.data || [], invites: invitesQuery.data || [], adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data), adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '', totpEnabled: !!totpStatusQuery.data?.enabled, lockTimeoutMinutes, sessionTimeoutAction, authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevicesLoading: authorizedDevicesQuery.isFetching, authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '', domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null, domainRulesLoading: domainRulesQuery.isFetching && !domainRulesQuery.data, domainRulesError: domainRulesQuery.isError && !domainRulesQuery.data ? t('txt_domain_rules_load_failed') : '', 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, onRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, 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, onSessionTimeoutActionChange: setSessionTimeoutAction, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRefreshDomainRules: () => { void domainRulesQuery.refetch(); }, onSaveDomainRules: handleSaveDomainRules, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently, 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, onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters), onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings), onClearAuditLogs: () => clearAuditLogs(authedFetch), onExportBackup: backupActions.exportBackup, onImportBackup: backupActions.importBackup, onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, onLoadBackupSettings: () => queryClient.ensureQueryData({ queryKey: ['admin-backup-settings', vaultCacheKey], queryFn: () => backupActions.loadSettings(), staleTime: 30_000, }), onSaveBackupSettings: backupActions.saveSettings, onRunRemoteBackup: backupActions.runRemoteBackup, onListRemoteBackups: backupActions.listRemoteBackups, onDownloadRemoteBackup: backupActions.downloadRemoteBackup, onInspectRemoteBackup: backupActions.inspectRemoteBackup, onDeleteRemoteBackup: backupActions.deleteRemoteBackup, onRestoreRemoteBackup: backupActions.restoreRemoteBackup, onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch, }; const effectiveMainRoutesProps = IS_DEMO_MODE ? createDemoMainRoutesProps(mainRoutesProps, pushToast, { ciphers: decryptedCiphers, folders: decryptedFolders, sends: decryptedSends, users: demoUsers, invites: demoInvites, authorizedDevices: demoAuthorizedDevices, backupSettings: demoBackupSettings, setCiphers: setDecryptedCiphers, setFolders: setDecryptedFolders, setSends: setDecryptedSends, setUsers: setDemoUsers, setInvites: setDemoInvites, setAuthorizedDevices: setDemoAuthorizedDevices, setBackupSettings: setDemoBackupSettings, }) : mainRoutesProps; if (jwtWarning) { return ; } if (publicSendMatch) { return ( <> {renderPassiveOverlays()} ); } if (isUnknownRoute) { return ( <> {renderPassiveOverlays()} ); } if (isRecoverTwoFactorRoute && phase !== 'app') { return ( <> void handleRecoverTwoFactorSubmit()} onCancel={() => { setRecoverValues({ email: '', password: '', recoveryCode: '' }); navigate('/login'); }} /> {renderPassiveOverlays()} ); } if (phase === 'register' || phase === 'login' || phase === 'locked') { return ( <> void handleLogin()} onSubmitRegister={() => void handleRegister()} onSubmitUnlock={() => void handleUnlock()} onGotoLogin={() => { setPhase('login'); navigate('/login'); }} onGotoRegister={() => { if (IS_DEMO_MODE) { pushToast('warning', t('txt_demo_readonly_message')); return; } if (inviteCodeFromUrl) { setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl })); } setPhase('register'); navigate('/register'); }} onLogout={logoutNow} onTogglePasswordHint={() => void handleTogglePasswordHint()} onShowLockedPasswordHint={handleShowLockedPasswordHint} /> 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 ( <> setMobileSidebarToggleKey((key) => key + 1)} mainRoutesProps={effectiveMainRoutesProps} /> 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} /> ); }