mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
1967 lines
74 KiB
TypeScript
1967 lines
74 KiB
TypeScript
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 AuthRequestApprovalDialog from '@/components/AuthRequestApprovalDialog';
|
|
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 {
|
|
encryptSessionUserKeyForAuthRequest,
|
|
isPendingAuthRequest,
|
|
listPendingAuthRequests,
|
|
respondToAuthRequest,
|
|
} from '@/lib/api/auth-requests';
|
|
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,
|
|
completePasskeyPasswordLogin,
|
|
performPasswordLogin,
|
|
performPasskeyLogin,
|
|
performRecoverTwoFactorLogin,
|
|
performRegistration,
|
|
performTotpLogin,
|
|
hydrateLockedSession,
|
|
performUnlock,
|
|
type JwtUnsafeReason,
|
|
type PendingPasskeyPassword,
|
|
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, AuthRequest, 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<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 SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
|
|
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
|
|
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
|
|
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
|
const APP_ROUTE_PATHS = [
|
|
'/',
|
|
'/vault',
|
|
'/vault/totp',
|
|
'/sends',
|
|
'/admin',
|
|
'/logs',
|
|
LEGACY_DEVICE_MANAGEMENT_ROUTE,
|
|
DEVICE_MANAGEMENT_ROUTE,
|
|
'/backup',
|
|
'/settings',
|
|
SETTINGS_ACCOUNT_ROUTE,
|
|
SETTINGS_DOMAIN_RULES_ROUTE,
|
|
'/help',
|
|
...IMPORT_ROUTE_PATHS,
|
|
] as const;
|
|
const AUTH_ROUTES: ReadonlySet<string> = new Set(AUTH_ROUTE_PATHS);
|
|
const APP_ROUTES: ReadonlySet<string> = 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<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 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' | 'passkey' | '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 [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<PendingTotp | null>(null);
|
|
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
|
const [pendingPasskeyPassword, setPendingPasskeyPassword] = useState<PendingPasskeyPassword | null>(null);
|
|
const [passkeyPassword, setPasskeyPassword] = useState('');
|
|
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 [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
|
|
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
|
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 [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
|
|
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 [demoUsers, setDemoUsers] = useState<AdminUser[]>(() => DEMO_ADMIN_USERS.map((user) => ({ ...user })));
|
|
const [demoInvites, setDemoInvites] = useState<AdminInvite[]>(() => DEMO_ADMIN_INVITES.map((invite) => ({ ...invite })));
|
|
const [demoAuthorizedDevices, setDemoAuthorizedDevices] = useState<AuthorizedDevice[]>(() => DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device })));
|
|
const [demoBackupSettings, setDemoBackupSettings] = useState<AdminBackupSettings>(() => createDemoBackupSettings());
|
|
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
|
|
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
|
const [vaultDecryptError, setVaultDecryptError] = useState('');
|
|
const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
|
|
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
|
const repairAttemptRef = useRef<string>('');
|
|
const uriChecksumRepairAttemptRef = useRef<string>('');
|
|
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
|
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
|
const notificationRefreshTimerRef = useRef<number | null>(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<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(() => {
|
|
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;
|
|
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) 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);
|
|
setPendingPasskeyPassword(null);
|
|
setTotpCode('');
|
|
setPasskeyPassword('');
|
|
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 handlePasskeyLogin() {
|
|
if (pendingAuthAction) return;
|
|
if (IS_DEMO_MODE) {
|
|
pushToast('warning', t('txt_demo_readonly_message'));
|
|
return;
|
|
}
|
|
setPendingAuthAction('passkey');
|
|
try {
|
|
const result = await performPasskeyLogin(defaultKdfIterations);
|
|
if (result.kind === 'success') {
|
|
await finalizeLogin(result.login);
|
|
return;
|
|
}
|
|
if (result.kind === 'password') {
|
|
setPendingPasskeyPassword(result.pendingPasskeyPassword);
|
|
setLoginValues({ email: result.pendingPasskeyPassword.email, password: '' });
|
|
setPasskeyPassword('');
|
|
pushToast('warning', t('txt_passkey_requires_master_password'));
|
|
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 handlePasskeyUnlock() {
|
|
if (pendingAuthAction) return;
|
|
const expectedEmail = (profile?.email || session?.email || '').trim().toLowerCase();
|
|
if (!expectedEmail) return;
|
|
if (IS_DEMO_MODE) {
|
|
pushToast('warning', t('txt_demo_readonly_message'));
|
|
return;
|
|
}
|
|
setPendingAuthAction('passkey');
|
|
try {
|
|
const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail);
|
|
if (result.kind === 'success') {
|
|
await finalizeLogin(result.login, t('txt_unlocked'));
|
|
return;
|
|
}
|
|
if (result.kind === 'password') {
|
|
pushToast('error', t('txt_account_passkey_direct_unlock_unavailable_error'));
|
|
return;
|
|
}
|
|
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect'));
|
|
} finally {
|
|
setPendingAuthAction(null);
|
|
}
|
|
}
|
|
|
|
async function handlePasskeyPasswordLogin() {
|
|
if (pendingAuthAction || !pendingPasskeyPassword) return;
|
|
if (!passkeyPassword) {
|
|
pushToast('error', t('txt_please_input_master_password'));
|
|
return;
|
|
}
|
|
setPendingAuthAction('login');
|
|
try {
|
|
const login = await completePasskeyPasswordLogin(pendingPasskeyPassword, passkeyPassword);
|
|
await finalizeLogin(login);
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect'));
|
|
} 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 (
|
|
<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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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?.accessToken && !!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?.accessToken && !!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' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
|
staleTime: 30_000,
|
|
});
|
|
const invitesQuery = useQuery({
|
|
queryKey: ['admin-invites', vaultCacheKey],
|
|
queryFn: () => listAdminInvites(authedFetch),
|
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && 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,
|
|
});
|
|
const pendingAuthRequestsQuery = useQuery({
|
|
queryKey: ['auth-requests-pending', vaultCacheKey || session?.email],
|
|
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
|
staleTime: 5_000,
|
|
refetchInterval: 15_000,
|
|
refetchIntervalInBackground: true,
|
|
});
|
|
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
|
|
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
|
|
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId;
|
|
|
|
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
|
setAuthRequestSubmittingId(authRequest.id);
|
|
try {
|
|
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
|
await respondToAuthRequest(authedFetch, authRequest.id, {
|
|
key,
|
|
masterPasswordHash: null,
|
|
deviceIdentifier: getCurrentDeviceIdentifier(),
|
|
requestApproved: true,
|
|
});
|
|
setAuthRequestDialogDismissedId(null);
|
|
pushToast('success', t('txt_auth_request_approved'));
|
|
await pendingAuthRequestsQuery.refetch();
|
|
} finally {
|
|
setAuthRequestSubmittingId(null);
|
|
}
|
|
}
|
|
|
|
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
|
|
setAuthRequestSubmittingId(authRequest.id);
|
|
try {
|
|
await respondToAuthRequest(authedFetch, authRequest.id, {
|
|
deviceIdentifier: getCurrentDeviceIdentifier(),
|
|
requestApproved: false,
|
|
});
|
|
setAuthRequestDialogDismissedId(null);
|
|
pushToast('success', t('txt_auth_request_denied'));
|
|
await pendingAuthRequestsQuery.refetch();
|
|
} finally {
|
|
setAuthRequestSubmittingId(null);
|
|
}
|
|
}
|
|
|
|
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
|
|
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
|
|
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
|
|
const currentRules = queryClient.getQueryData<DomainRules>(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' && !!session?.accessToken && 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);
|
|
if (!session.accessToken) return;
|
|
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,
|
|
session,
|
|
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 === '/sends';
|
|
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
|
const demoDomainRules = useMemo<DomainRules>(() => ({
|
|
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 === LEGACY_DEVICE_MANAGEMENT_ROUTE || location === DEVICE_MANAGEMENT_ROUTE) 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') return;
|
|
if (!hashPath.startsWith('/')) return;
|
|
if (normalizedHashPath !== DEVICE_MANAGEMENT_ROUTE && normalizedHashPath !== LEGACY_DEVICE_MANAGEMENT_ROUTE) return;
|
|
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
|
|
window.history.replaceState(null, '', DEVICE_MANAGEMENT_ROUTE);
|
|
}
|
|
if (location !== DEVICE_MANAGEMENT_ROUTE) navigate(DEVICE_MANAGEMENT_ROUTE);
|
|
}, [phase, hashPath, normalizedHashPath, location, navigate]);
|
|
|
|
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,
|
|
onListAccountPasskeys: accountSecurityActions.listAccountPasskeys,
|
|
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
|
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
|
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
|
pendingAuthRequests,
|
|
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
|
|
onRefreshPendingAuthRequests: async () => {
|
|
await pendingAuthRequestsQuery.refetch();
|
|
},
|
|
onApproveAuthRequest: approveAuthRequest,
|
|
onDenyAuthRequest: denyAuthRequest,
|
|
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 <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
|
}
|
|
|
|
if (publicSendMatch) {
|
|
return (
|
|
<>
|
|
<PublicSendPage accessId={decodeURIComponent(publicSendMatch[1])} keyPart={publicSendMatch[2] ? decodeURIComponent(publicSendMatch[2]) : null} />
|
|
{renderPassiveOverlays()}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (isUnknownRoute) {
|
|
return (
|
|
<>
|
|
<NotFoundPage />
|
|
{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}
|
|
relaxedLoginInput={IS_DEMO_MODE}
|
|
authPlaceholder={IS_DEMO_MODE ? t('txt_demo_auth_placeholder') : undefined}
|
|
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
|
|
unlockReady={!!session?.email}
|
|
unlockPreparing={unlockPreparing}
|
|
loginValues={loginValues}
|
|
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
|
|
passkeyPassword={passkeyPassword}
|
|
registerValues={registerValues}
|
|
registrationInviteRequired={registrationInviteRequired}
|
|
unlockPassword={unlockPassword}
|
|
emailForLock={profile?.email || session?.email || ''}
|
|
loginHintLoading={loginHintState.loading}
|
|
onChangeLogin={setLoginValues}
|
|
onChangePasskeyPassword={setPasskeyPassword}
|
|
onChangeRegister={setRegisterValues}
|
|
onChangeUnlock={setUnlockPassword}
|
|
onSubmitLogin={() => void handleLogin()}
|
|
onSubmitPasskey={() => void handlePasskeyLogin()}
|
|
onSubmitPasskeyUnlock={() => void handlePasskeyUnlock()}
|
|
onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()}
|
|
onSubmitRegister={() => void handleRegister()}
|
|
onSubmitUnlock={() => void handleUnlock()}
|
|
onGotoLogin={() => {
|
|
setPendingPasskeyPassword(null);
|
|
setPasskeyPassword('');
|
|
setPhase('login');
|
|
navigate('/login');
|
|
}}
|
|
onGotoRegister={() => {
|
|
if (IS_DEMO_MODE) {
|
|
pushToast('warning', t('txt_demo_readonly_message'));
|
|
return;
|
|
}
|
|
if (inviteCodeFromUrl) {
|
|
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
|
|
}
|
|
setPendingPasskeyPassword(null);
|
|
setPasskeyPassword('');
|
|
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={effectiveMainRoutesProps}
|
|
/>
|
|
|
|
<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}
|
|
/>
|
|
<AuthRequestApprovalDialog
|
|
open={authRequestDialogOpen}
|
|
authRequest={latestPendingAuthRequest}
|
|
submitting={!!authRequestSubmittingId}
|
|
onApprove={() => {
|
|
if (!latestPendingAuthRequest) return;
|
|
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
|
});
|
|
}}
|
|
onDeny={() => {
|
|
if (!latestPendingAuthRequest) return;
|
|
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
|
});
|
|
}}
|
|
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|