import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { lazy, Suspense } from 'preact/compat'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import AuthViews from '@/components/AuthViews'; import ConfirmDialog from '@/components/ConfirmDialog'; import ToastHost from '@/components/ToastHost'; import VaultPage from '@/components/VaultPage'; import SendsPage from '@/components/SendsPage'; import PublicSendPage from '@/components/PublicSendPage'; import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage'; import JwtWarningPage from '@/components/JwtWarningPage'; import TotpCodesPage from '@/components/TotpCodesPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import { buildCipherImportPayload, bulkDeleteFolders, changeMasterPassword, createFolder, updateFolder, deleteCipherAttachment, deleteFolder, deleteRemoteBackup, bulkDeleteCiphers, bulkPermanentDeleteCiphers, bulkRestoreCiphers, bulkDeleteSends, createCipher, createAuthedFetch, createInvite, downloadCipherAttachmentDecrypted, encryptFolderImportName, exportAdminBackup, getAdminBackupSettingsRepairState, getAdminBackupSettings, downloadRemoteBackup, importAdminBackup, importCiphers, createSend, deleteAllInvites, deleteCipher, deleteSend, deleteUser, deriveLoginHash, getAttachmentDownloadInfo, bulkMoveCiphers, getCiphers, getFolders, getPreloginKdfConfig, getProfile, getAuthorizedDevices, getCurrentDeviceIdentifier, getSetupStatus, getSends, getTotpStatus, getTotpRecoveryCode, getWebConfig, listAdminInvites, listAdminUsers, loadSession, loginWithPassword, registerAccount, recoverTwoFactor, repairAdminBackupSettings, revokeInvite, revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, restoreRemoteBackup, runAdminBackupNow, saveSession, saveAdminBackupSettings, setTotp, setUserStatus, deleteAllAuthorizedDevices, deleteAuthorizedDevice, listRemoteBackups, uploadCipherAttachment, updateCipher, updateSend, buildSendShareKey, unlockVaultKey, verifyMasterPassword, type ImportedCipherMapEntry, } from '@/lib/api'; import { decryptPortableBackupSettings } from '@/lib/admin-backup-portable'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto'; import { attachNodeWardenEncryptedAttachmentPayload, buildAccountEncryptedBitwardenJsonString, buildBitwardenZipBytes, buildExportFileName, buildNodeWardenAttachmentRecords, buildNodeWardenPlainJsonDocument, buildPasswordProtectedBitwardenJsonString, buildPlainBitwardenJsonString, encryptZipBytesWithPassword, type ExportRequest, type ZipAttachmentEntry, } from '@/lib/export-formats'; import { t } from '@/lib/i18n'; import type { CiphersImportPayload } from '@/lib/api'; import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const AdminPage = lazy(() => import('@/components/AdminPage')); const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage')); const ImportPage = lazy(() => import('@/components/ImportPage')); interface PendingTotp { email: string; passwordHash: string; masterKey: Uint8Array; } type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; const SEND_KEY_SALT = 'bitwarden-send'; const SEND_KEY_PURPOSE = 'send'; const IMPORT_ROUTE = '/help/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 = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; function looksLikeCipherString(value: string): boolean { return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); } function asText(value: unknown): string { if (value === null || value === undefined) return ''; return String(value); } function readInviteCodeFromUrl(): string { if (typeof window === 'undefined') return ''; const searchInvite = new URLSearchParams(window.location.search || '').get('invite'); if (searchInvite && searchInvite.trim()) return searchInvite.trim(); const rawHash = String(window.location.hash || ''); const queryIndex = rawHash.indexOf('?'); if (queryIndex >= 0) { const hashInvite = new URLSearchParams(rawHash.slice(queryIndex + 1)).get('invite'); if (hashInvite && hashInvite.trim()) return hashInvite.trim(); } return ''; } function RouteContentFallback() { return
{t('txt_loading_nodewarden')}
; } function summarizeImportResult( ciphers: Array>, folderCount: number, attachmentSummary?: { total: number; imported: number; failed: Array<{ fileName: string; reason: string }>; } ): ImportResultSummary { const typeLabel = (type: number): string => { if (type === 1) return t('txt_login'); if (type === 2) return t('txt_secure_note'); if (type === 3) return t('txt_card'); if (type === 4) return t('txt_identity'); if (type === 5) return t('txt_ssh_key'); return t('txt_other'); }; const counter = new Map(); for (const raw of ciphers) { const cipherType = Number(raw?.type || 1) || 1; counter.set(cipherType, (counter.get(cipherType) || 0) + 1); } const order = [1, 2, 3, 4, 5]; const seen = new Set(order); const typeCounts = order .filter((type) => (counter.get(type) || 0) > 0) .map((type) => ({ label: typeLabel(type), count: counter.get(type) || 0 })); for (const [type, count] of counter.entries()) { if (!seen.has(type) && count > 0) typeCounts.push({ label: typeLabel(type), count }); } return { totalItems: ciphers.length, folderCount: Math.max(0, folderCount), typeCounts, attachmentCount: Math.max(0, attachmentSummary?.total || 0), importedAttachmentCount: Math.max(0, attachmentSummary?.imported || 0), failedAttachments: attachmentSummary?.failed || [], }; } function buildEmptyImportDraft(type: number): VaultDraft { return { type, favorite: false, name: '', folderId: '', notes: '', reprompt: false, loginUsername: '', loginPassword: '', loginTotp: '', loginUris: [''], loginFido2Credentials: [], cardholderName: '', cardNumber: '', cardBrand: '', cardExpMonth: '', cardExpYear: '', cardCode: '', identTitle: '', identFirstName: '', identMiddleName: '', identLastName: '', identUsername: '', identCompany: '', identSsn: '', identPassportNumber: '', identLicenseNumber: '', identEmail: '', identPhone: '', identAddress1: '', identAddress2: '', identAddress3: '', identCity: '', identState: '', identPostalCode: '', identCountry: '', sshPrivateKey: '', sshPublicKey: '', sshFingerprint: '', customFields: [], }; } function importCipherToDraft(cipher: Record, folderId: string | null): VaultDraft { const type = Number(cipher.type || 1) || 1; const draft = buildEmptyImportDraft(type); draft.name = asText(cipher.name).trim() || 'Untitled'; draft.notes = asText(cipher.notes); draft.favorite = !!cipher.favorite; draft.reprompt = Number(cipher.reprompt || 0) === 1; draft.folderId = folderId || ''; const customFieldsRaw = Array.isArray(cipher.fields) ? cipher.fields : []; draft.customFields = customFieldsRaw .map((raw) => { const field = (raw || {}) as Record; const label = asText(field.name).trim(); if (!label) return null; const parsedType = Number(field.type ?? 0); const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0; return { type: fieldType, label, value: asText(field.value), }; }) .filter((x): x is VaultDraft['customFields'][number] => !!x); if (type === 1) { const login = (cipher.login || {}) as Record; draft.loginUsername = asText(login.username); draft.loginPassword = asText(login.password); draft.loginTotp = asText(login.totp); draft.loginFido2Credentials = Array.isArray(login.fido2Credentials) ? login.fido2Credentials .filter((credential): credential is Record => !!credential && typeof credential === 'object') .map((credential) => ({ ...credential })) : []; const urisRaw = Array.isArray(login.uris) ? login.uris : []; const uris = urisRaw .map((u) => asText((u as Record)?.uri).trim()) .filter((u) => !!u); draft.loginUris = uris.length ? uris : ['']; } else if (type === 3) { const card = (cipher.card || {}) as Record; draft.cardholderName = asText(card.cardholderName); draft.cardNumber = asText(card.number); draft.cardBrand = asText(card.brand); draft.cardExpMonth = asText(card.expMonth); draft.cardExpYear = asText(card.expYear); draft.cardCode = asText(card.code); } else if (type === 4) { const identity = (cipher.identity || {}) as Record; draft.identTitle = asText(identity.title); draft.identFirstName = asText(identity.firstName); draft.identMiddleName = asText(identity.middleName); draft.identLastName = asText(identity.lastName); draft.identUsername = asText(identity.username); draft.identCompany = asText(identity.company); draft.identSsn = asText(identity.ssn); draft.identPassportNumber = asText(identity.passportNumber); draft.identLicenseNumber = asText(identity.licenseNumber); draft.identEmail = asText(identity.email); draft.identPhone = asText(identity.phone); draft.identAddress1 = asText(identity.address1); draft.identAddress2 = asText(identity.address2); draft.identAddress3 = asText(identity.address3); draft.identCity = asText(identity.city); draft.identState = asText(identity.state); draft.identPostalCode = asText(identity.postalCode); draft.identCountry = asText(identity.country); } else if (type === 5) { const sshKey = (cipher.sshKey || {}) as Record; draft.sshPrivateKey = asText(sshKey.privateKey); draft.sshPublicKey = asText(sshKey.publicKey); draft.sshFingerprint = asText(sshKey.keyFingerprint ?? sshKey.fingerprint); } return draft; } function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string { return `${origin}/#/send/${accessId}/${keyPart}`; } 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; interface WebVaultSignalRInvocation { type?: number; target?: string; arguments?: Array<{ ContextId?: string | null; Type?: number; Payload?: { UserId?: string; Date?: string; RevisionDate?: string; }; }>; } function parseSignalRTextFrames(raw: string): WebVaultSignalRInvocation[] { return raw .split(SIGNALR_RECORD_SEPARATOR) .map((frame) => frame.trim()) .filter(Boolean) .map((frame) => { try { return JSON.parse(frame) as WebVaultSignalRInvocation; } catch { return null; } }) .filter((frame): frame is WebVaultSignalRInvocation => !!frame); } async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> { if (sendKeyMaterial.length >= 64) { return { enc: sendKeyMaterial.slice(0, 32), mac: sendKeyMaterial.slice(32, 64) }; } const derived = await hkdf(sendKeyMaterial, SEND_KEY_SALT, SEND_KEY_PURPOSE, 64); return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) }; } export default function App() { const [location, navigate] = useLocation(); const [phase, setPhase] = useState('loading'); const [session, setSessionState] = useState(null); const [profile, setProfile] = useState(null); const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000); const [setupRegistered, setSetupRegistered] = useState(true); const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(null); const [loginValues, setLoginValues] = useState({ email: '', password: '' }); const [registerValues, setRegisterValues] = useState({ name: '', email: '', password: '', password2: '', inviteCode: '', }); const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(''); const [unlockPassword, setUnlockPassword] = useState(''); const [pendingTotp, setPendingTotp] = useState(null); const [totpCode, setTotpCode] = useState(''); const [rememberDevice, setRememberDevice] = useState(true); const [disableTotpOpen, setDisableTotpOpen] = useState(false); const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [confirm, setConfirm] = useState<{ title: string; message: string; danger?: boolean; showIcon?: boolean; onConfirm: () => void; } | null>(null); const [toasts, setToasts] = useState([]); const [mobileLayout, setMobileLayout] = useState(false); const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); const migratedPlainFolderIdsRef = useRef>(new Set()); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); 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(() => { if (!inviteCodeFromUrl) return; if (phase === 'loading' || 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: 900px)'); 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); }, []); function setSession(next: SessionState | null) { setSessionState(next); saveSession(next); } async function silentlyRepairBackupSettingsIfNeeded(activeSession: SessionState, activeProfile: Profile): Promise { if (activeProfile.role !== 'admin') return; if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return; const tempFetch = createAuthedFetch(() => activeSession, () => {}); try { const state = await getAdminBackupSettingsRepairState(tempFetch); if (!state.needsRepair || !state.portable) return; const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession); await repairAdminBackupSettings(tempFetch, repairedSettings); } catch (error) { console.error('Backup settings auto-repair failed:', error); } } function pushToast(type: ToastMessage['type'], text: string) { const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; setToasts((prev) => [...prev.slice(-3), { id, type, text }]); window.setTimeout(() => { setToasts((prev) => prev.filter((x) => x.id !== id)); }, 4500); } const authedFetch = useMemo( () => createAuthedFetch( () => session, (next) => { setSession(next); if (!next) { setProfile(null); setPhase(setupRegistered ? 'login' : 'register'); } } ), [session, setupRegistered] ); 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] ); useEffect(() => { let mounted = true; (async () => { const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]); if (!mounted) return; setSetupRegistered(setup.registered); setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000)); const jwtUnsafeReason = config.jwtUnsafeReason || null; if (jwtUnsafeReason) { setJwtWarning({ reason: jwtUnsafeReason, minLength: Number(config.jwtSecretMinLength || 32), }); setSession(null); setProfile(null); setPhase('login'); return; } setJwtWarning(null); const loaded = loadSession(); if (!loaded) { setPhase(setup.registered ? 'login' : 'register'); return; } setSession(loaded); try { const profileResp = await getProfile( createAuthedFetch( () => loaded, (next) => { if (!next) return; setSession(next); } ) ); if (!mounted) return; setProfile(profileResp); setPhase('locked'); } catch { setSession(null); setPhase(setup.registered ? 'login' : 'register'); } })(); return () => { mounted = false; }; }, []); async function finalizeLogin(tokenAccess: string, tokenRefresh: string, email: string, masterKey: Uint8Array) { const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email }; const tempFetch = createAuthedFetch( () => baseSession, () => {} ); const profileResp = await getProfile(tempFetch); const keys = await unlockVaultKey(profileResp.key, masterKey); const nextSession = { ...baseSession, ...keys }; setSession(nextSession); setProfile(profileResp); await silentlyRepairBackupSettingsIfNeeded(nextSession, profileResp); setPendingTotp(null); setTotpCode(''); setPhase('app'); if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { navigate('/vault'); } pushToast('success', t('txt_login_success')); } async function handleLogin() { if (!loginValues.email || !loginValues.password) { pushToast('error', t('txt_please_input_email_and_password')); return; } try { const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations); const token = await loginWithPassword(loginValues.email, derived.hash, { useRememberToken: true }); if ('access_token' in token && token.access_token) { await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey); return; } const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string }; if (tokenError.TwoFactorProviders) { setPendingTotp({ email: loginValues.email.toLowerCase(), passwordHash: derived.hash, masterKey: derived.masterKey, }); setTotpCode(''); setRememberDevice(true); return; } pushToast('error', tokenError.error_description || tokenError.error || t('txt_login_failed')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_login_failed')); } } async function handleTotpVerify() { if (!pendingTotp) return; if (!totpCode.trim()) { pushToast('error', t('txt_please_input_totp_code')); return; } const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, { totpCode: totpCode.trim(), rememberDevice, }); if ('access_token' in token && token.access_token) { await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey); return; } const tokenError = token as { error_description?: string; error?: string }; pushToast('error', tokenError.error_description || tokenError.error || t('txt_totp_verify_failed')); } 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 derived = await deriveLoginHash(email, password, defaultKdfIterations); const recovered = await recoverTwoFactor(email, derived.hash, recoveryCode); const token = await loginWithPassword(email, derived.hash, { useRememberToken: false }); if ('access_token' in token && token.access_token) { await finalizeLogin(token.access_token, token.refresh_token, email, derived.masterKey); 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 (!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; } const resp = await registerAccount({ email: registerValues.email.toLowerCase(), name: registerValues.name.trim(), password: registerValues.password, inviteCode: registerValues.inviteCode.trim(), 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')); } async function handleUnlock() { if (!session || !profile) return; if (!unlockPassword) { pushToast('error', t('txt_please_input_master_password')); return; } try { const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations); const keys = await unlockVaultKey(profile.key, derived.masterKey); const nextSession = { ...session, ...keys }; setSession(nextSession); await silentlyRepairBackupSettingsIfNeeded(nextSession, profile); setUnlockPassword(''); setPhase('app'); if (location === '/' || location === '/lock') navigate('/vault'); pushToast('success', t('txt_unlocked')); } catch { pushToast('error', t('txt_unlock_failed_master_password_is_incorrect')); } } function handleLock() { if (!session) return; const nextSession = { ...session }; delete nextSession.symEncKey; delete nextSession.symMacKey; setSession(nextSession); setPhase('locked'); navigate('/lock'); } function logoutNow() { setConfirm(null); setSession(null); setProfile(null); setPendingTotp(null); setPhase(setupRegistered ? 'login' : 'register'); navigate(setupRegistered ? '/login' : '/register'); } function handleLogout() { setConfirm({ title: t('txt_log_out'), message: t('txt_are_you_sure_you_want_to_log_out'), showIcon: false, onConfirm: () => { logoutNow(); }, }); } const ciphersQuery = useQuery({ queryKey: ['ciphers', session?.accessToken], queryFn: () => getCiphers(authedFetch), enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, }); const foldersQuery = useQuery({ queryKey: ['folders', session?.accessToken], queryFn: () => getFolders(authedFetch), enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, }); const sendsQuery = useQuery({ queryKey: ['sends', session?.accessToken], queryFn: () => getSends(authedFetch), enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, }); const usersQuery = useQuery({ queryKey: ['admin-users', session?.accessToken], queryFn: () => listAdminUsers(authedFetch), enabled: phase === 'app' && profile?.role === 'admin', }); const invitesQuery = useQuery({ queryKey: ['admin-invites', session?.accessToken], queryFn: () => listAdminInvites(authedFetch), enabled: phase === 'app' && profile?.role === 'admin', }); const totpStatusQuery = useQuery({ queryKey: ['totp-status', session?.accessToken], queryFn: () => getTotpStatus(authedFetch), enabled: phase === 'app' && !!session?.accessToken, }); const authorizedDevicesQuery = useQuery({ queryKey: ['authorized-devices', session?.accessToken], queryFn: () => getAuthorizedDevices(authedFetch), enabled: phase === 'app' && !!session?.accessToken, }); useEffect(() => { if (!session?.symEncKey || !session?.symMacKey) { setDecryptedFolders([]); setDecryptedCiphers([]); setDecryptedSends([]); return; } if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return; let active = true; (async () => { try { const encKey = base64ToBytes(session.symEncKey!); const macKey = base64ToBytes(session.symMacKey!); const decryptField = async ( value: string | null | undefined, fieldEnc: Uint8Array = encKey, fieldMac: Uint8Array = macKey ): Promise => { if (!value || typeof value !== 'string') return ''; try { return await decryptStr(value, fieldEnc, fieldMac); } catch { // Backward-compatibility: some records may already be plain text. return value; } }; const folders = await Promise.all( foldersQuery.data.map(async (folder) => ({ ...folder, decName: await decryptField(folder.name, encKey, macKey), })) ); const ciphers = await Promise.all( ciphersQuery.data.map(async (cipher) => { let itemEnc = encKey; let itemMac = macKey; if (cipher.key) { try { const itemKey = await decryptBw(cipher.key, encKey, macKey); itemEnc = itemKey.slice(0, 32); itemMac = itemKey.slice(32, 64); } catch { // keep user key when item key decrypt fails } } const nextCipher: Cipher = { ...cipher, decName: await decryptField(cipher.name || '', itemEnc, itemMac), decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac), }; if (cipher.login) { nextCipher.login = { ...cipher.login, decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac), decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac), decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac), fido2Credentials: Array.isArray(cipher.login.fido2Credentials) ? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) : null, uris: await Promise.all( (cipher.login.uris || []).map(async (u) => ({ ...u, decUri: await decryptField(u.uri || '', itemEnc, itemMac), })) ), }; } if (cipher.card) { nextCipher.card = { ...cipher.card, decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac), decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac), decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac), decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac), decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac), decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac), }; } if (cipher.identity) { nextCipher.identity = { ...cipher.identity, decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac), decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac), decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac), decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac), decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac), decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac), decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac), decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac), decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac), decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac), decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac), decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac), decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac), decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac), decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac), decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac), decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac), decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac), }; } if (cipher.sshKey) { const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || ''; nextCipher.sshKey = { ...cipher.sshKey, decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac), decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac), keyFingerprint: encryptedFingerprint || null, fingerprint: encryptedFingerprint || null, decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac), }; } if (cipher.fields) { nextCipher.fields = await Promise.all( cipher.fields.map(async (field) => ({ ...field, decName: await decryptField(field.name || '', itemEnc, itemMac), decValue: await decryptField(field.value || '', itemEnc, itemMac), })) ); } if (Array.isArray(cipher.attachments)) { nextCipher.attachments = await Promise.all( cipher.attachments.map(async (attachment) => ({ ...attachment, decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac), })) ); } return nextCipher; }) ); const sends = await Promise.all( sendsQuery.data.map(async (send) => { const nextSend: Send = { ...send }; try { if (send.key) { const sendKeyRaw = await decryptBw(send.key, encKey, macKey); const derived = await deriveSendKeyParts(sendKeyRaw); nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); if (send.file?.fileName) { const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); nextSend.file = { ...(send.file || {}), fileName: decFileName || send.file.fileName, }; } const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); nextSend.decShareKey = shareKey; nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey); } else { nextSend.decName = ''; nextSend.decNotes = ''; nextSend.decText = ''; } } catch { nextSend.decName = t('txt_decrypt_failed'); } return nextSend; }) ); if (!active) return; setDecryptedFolders(folders); setDecryptedCiphers(ciphers); setDecryptedSends(sends); } catch (error) { if (!active) return; pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2')); } })(); return () => { active = false; }; }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]); useEffect(() => { if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return; let cancelled = false; (async () => { const pending = foldersQuery.data.filter((folder) => { if (!folder?.id || !folder?.name) return false; if (migratedPlainFolderIdsRef.current.has(folder.id)) return false; return !looksLikeCipherString(String(folder.name)); }); if (!pending.length) return; for (const folder of pending) { try { await updateFolder(authedFetch, session, folder.id, String(folder.name)); migratedPlainFolderIdsRef.current.add(folder.id); } catch { // keep silent; web still supports plaintext fallback display } } if (!cancelled) await foldersQuery.refetch(); })(); return () => { cancelled = true; }; }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]); async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) { if (!profile) return; if (!currentPassword || !nextPassword) { pushToast('error', t('txt_current_new_password_is_required')); return; } if (nextPassword.length < 12) { pushToast('error', t('txt_new_password_must_be_at_least_12_chars')); return; } if (nextPassword !== nextPassword2) { pushToast('error', t('txt_new_passwords_do_not_match')); return; } try { await changeMasterPassword(authedFetch, { email: profile.email, currentPassword, newPassword: nextPassword, currentIterations: defaultKdfIterations, profileKey: profile.key, }); handleLogout(); pushToast('success', t('txt_master_password_changed_please_login_again')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_change_password_failed')); } } async function enableTotpAction(secret: string, token: string) { if (!secret.trim() || !token.trim()) { const error = new Error(t('txt_secret_and_code_are_required')); pushToast('error', error.message); throw error; } try { await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); pushToast('success', t('txt_totp_enabled')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); throw error; } } async function disableTotpAction() { if (!profile) return; if (!disableTotpPassword) { pushToast('error', t('txt_please_input_master_password')); return; } try { const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); if (profile?.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`); setDisableTotpOpen(false); setDisableTotpPassword(''); await totpStatusQuery.refetch(); pushToast('success', t('txt_totp_disabled')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_disable_totp_failed')); } } async function refreshVault() { await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]); pushToast('success', t('txt_vault_synced')); } async function refreshVaultSilently() { await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]); } silentRefreshVaultRef.current = refreshVaultSilently; useEffect(() => { if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; let disposed = false; let socket: WebSocket | null = null; let reconnectTimer: number | null = null; let reconnectAttempts = 0; const clearReconnectTimer = () => { if (reconnectTimer !== null) { window.clearTimeout(reconnectTimer); reconnectTimer = null; } }; const scheduleReconnect = () => { if (disposed) return; clearReconnectTimer(); const delay = Math.min(10000, 1000 * Math.max(1, reconnectAttempts + 1)); reconnectAttempts += 1; reconnectTimer = window.setTimeout(() => { reconnectTimer = null; connect(); }, delay); }; const connect = () => { if (disposed) return; try { const hubUrl = new URL('/notifications/hub', window.location.origin); hubUrl.searchParams.set('access_token', session.accessToken); hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:'; socket = new WebSocket(hubUrl.toString()); } catch { scheduleReconnect(); return; } socket.addEventListener('open', () => { reconnectAttempts = 0; void refreshAuthorizedDevicesRef.current(); try { socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`); } catch { socket?.close(); } }); 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_SYNC_VAULT) continue; const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); if (contextId && contextId === getCurrentDeviceIdentifier()) continue; void silentRefreshVaultRef.current(); } }); socket.addEventListener('close', () => { socket = null; void refreshAuthorizedDevicesRef.current(); scheduleReconnect(); }); socket.addEventListener('error', () => { try { socket?.close(); } catch { // ignore close races } }); }; connect(); return () => { disposed = true; clearReconnectTimer(); if (socket && socket.readyState === WebSocket.OPEN) { try { socket.close(); } catch { // ignore close races } } }; }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey]); async function refreshAuthorizedDevices() { await authorizedDevicesQuery.refetch(); } refreshAuthorizedDevicesRef.current = refreshAuthorizedDevices; async function revokeDeviceTrustAction(device: AuthorizedDevice) { await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); await authorizedDevicesQuery.refetch(); pushToast('success', t('txt_device_authorization_revoked')); } async function revokeAllDeviceTrustAction() { await revokeAllAuthorizedDeviceTrust(authedFetch); await authorizedDevicesQuery.refetch(); pushToast('success', t('txt_all_device_authorizations_revoked')); } async function removeDeviceAction(device: AuthorizedDevice) { await deleteAuthorizedDevice(authedFetch, device.identifier); if (device.identifier === getCurrentDeviceIdentifier()) { pushToast('success', t('txt_device_removed')); logoutNow(); return; } await authorizedDevicesQuery.refetch(); pushToast('success', t('txt_device_removed')); } async function removeAllDevicesAction() { await deleteAllAuthorizedDevices(authedFetch); pushToast('success', t('txt_all_devices_removed')); logoutNow(); } async function createVaultItem(draft: VaultDraft, attachments: File[] = []) { if (!session) return; try { const created = await createCipher(authedFetch, session, draft); for (const file of attachments) { await uploadCipherAttachment(authedFetch, session, created.id, file); } await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_item_created')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_create_item_failed')); throw error; } } async function updateVaultItem( cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] } ) { if (!session) return; const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : []; const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : []; try { await updateCipher(authedFetch, session, cipher, draft); for (const attachmentId of removeAttachmentIds) { const id = String(attachmentId || '').trim(); if (!id) continue; await deleteCipherAttachment(authedFetch, cipher.id, id); } for (const file of addFiles) { await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher); } await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_item_updated')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_update_item_failed')); throw error; } } async function downloadVaultAttachment(cipher: Cipher, attachmentId: string) { if (!session) return; try { const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId); const fileName = String(file.fileName || '').trim() || 'attachment.bin'; const payload = new ArrayBuffer(file.bytes.byteLength); new Uint8Array(payload).set(file.bytes); const blob = new Blob([payload], { type: 'application/octet-stream' }); const href = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = href; anchor.download = fileName; anchor.rel = 'noopener'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(href); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_download_failed')); throw error; } } async function deleteVaultItem(cipher: Cipher) { try { await deleteCipher(authedFetch, cipher.id); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_item_deleted')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); throw error; } } async function bulkDeleteVaultItems(ids: string[]) { try { await bulkDeleteCiphers(authedFetch, ids); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_deleted_selected_items')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed')); throw error; } } async function bulkMoveVaultItems(ids: string[], folderId: string | null) { try { await bulkMoveCiphers(authedFetch, ids, folderId); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_moved_selected_items')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_bulk_move_failed')); throw error; } } async function getRecoveryCodeAction(masterPassword: string): Promise { if (!profile) throw new Error(t('txt_profile_unavailable')); const normalized = String(masterPassword || ''); if (!normalized) throw new Error(t('txt_master_password_is_required')); const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); const code = await getTotpRecoveryCode(authedFetch, derived.hash); if (!code) throw new Error(t('txt_recovery_code_is_empty')); return code; } async function createSendItem(draft: SendDraft, autoCopyLink: boolean) { if (!session) return; try { const created = await createSend(authedFetch, session, draft); await sendsQuery.refetch(); if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey); const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart); await navigator.clipboard.writeText(shareUrl); } pushToast('success', t('txt_send_created')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_create_send_failed')); throw error; } } async function updateSendItem(send: Send, draft: SendDraft, autoCopyLink: boolean) { if (!session) return; try { const updated = await updateSend(authedFetch, session, send, draft); await sendsQuery.refetch(); if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey); const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart); await navigator.clipboard.writeText(shareUrl); } pushToast('success', t('txt_send_updated')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_update_send_failed')); throw error; } } async function deleteSendItem(send: Send) { try { await deleteSend(authedFetch, send.id); await sendsQuery.refetch(); pushToast('success', t('txt_send_deleted')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_delete_send_failed')); throw error; } } async function bulkDeleteSendItems(ids: string[]) { try { await bulkDeleteSends(authedFetch, ids); await sendsQuery.refetch(); pushToast('success', t('txt_deleted_selected_sends')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed')); throw error; } } async function verifyMasterPasswordAction(email: string, password: string) { const derived = await deriveLoginHash(email, password, defaultKdfIterations); await verifyMasterPassword(authedFetch, derived.hash); } async function createFolderAction(name: string) { const folderName = name.trim(); if (!folderName) { pushToast('error', t('txt_folder_name_is_required')); return; } try { if (!session) throw new Error(t('txt_vault_key_unavailable')); await createFolder(authedFetch, session, folderName); await foldersQuery.refetch(); pushToast('success', t('txt_folder_created')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_create_folder_failed')); throw error; } } async function deleteFolderAction(folderId: string) { const id = String(folderId || '').trim(); if (!id) { pushToast('error', t('txt_folder_not_found')); return; } try { await deleteFolder(authedFetch, id); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_folder_deleted')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_delete_folder_failed')); throw error; } } async function bulkRestoreVaultItems(ids: string[]) { try { await bulkRestoreCiphers(authedFetch, ids); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_restored_selected_items')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed')); throw error; } } async function bulkPermanentDeleteVaultItems(ids: string[]) { try { await bulkPermanentDeleteCiphers(authedFetch, ids); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_deleted_selected_items_permanently')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed')); throw error; } } async function bulkDeleteFoldersAction(ids: string[]) { const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); if (!folderIds.length) return; try { await bulkDeleteFolders(authedFetch, folderIds); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_folders_deleted')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed')); throw error; } } async function uploadImportedAttachments( attachments: ImportAttachmentFile[], idMaps: { byIndex: Map; bySourceId: Map } ): Promise<{ total: number; imported: number; failed: Array<{ fileName: string; reason: string }> }> { if (!attachments.length) { return { total: 0, imported: 0, failed: [] }; } if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); const initialCiphers = (await ciphersQuery.refetch()).data || []; const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher])); const failed: Array<{ fileName: string; reason: string }> = []; let imported = 0; for (const attachment of attachments) { const sourceId = String(attachment.sourceCipherId || '').trim(); const sourceIndex = Number(attachment.sourceCipherIndex); const byId = sourceId ? idMaps.bySourceId.get(sourceId) : null; const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null; const targetCipherId = byId || byIndex || null; if (!targetCipherId) { failed.push({ fileName: String(attachment.fileName || '').trim() || 'attachment.bin', reason: t('txt_import_attachment_target_not_found'), }); continue; } const name = String(attachment.fileName || '').trim() || 'attachment.bin'; const fileBytes = Uint8Array.from(attachment.bytes); const file = new File([fileBytes], name, { type: 'application/octet-stream' }); const cipher = cipherById.get(targetCipherId) || null; try { await uploadCipherAttachment(importAuthedFetch, session, targetCipherId, file, cipher); imported += 1; } catch (error) { failed.push({ fileName: name, reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'), }); } } await ciphersQuery.refetch(); return { total: attachments.length, imported, failed, }; } function toImportedCipherMapsFromResponse( cipherMap: ImportedCipherMapEntry[] | null ): { byIndex: Map; bySourceId: Map } { const byIndex = new Map(); const bySourceId = new Map(); for (const row of cipherMap || []) { const idx = Number(row?.index); const id = String(row?.id || '').trim(); if (!Number.isFinite(idx) || !id) continue; byIndex.set(idx, id); const sourceId = String(row?.sourceId || '').trim(); if (sourceId) bySourceId.set(sourceId, id); } return { byIndex, bySourceId }; } async function handleImportAction( payload: CiphersImportPayload, options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, attachments: ImportAttachmentFile[] = [] ): Promise { if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); const mode = options.folderMode || 'original'; const targetFolderId = (options.targetFolderId || '').trim() || null; const nextPayload: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [], }; if (mode === 'original') { const folderIndexByLegacyId = new Map(); const folderIndexByName = new Map(); for (let i = 0; i < payload.folders.length; i++) { const folderRaw = (payload.folders[i] || {}) as Record; const name = String(folderRaw.name || '').trim(); if (!name) continue; let folderIndex = folderIndexByName.get(name); if (folderIndex == null) { folderIndex = nextPayload.folders.length; nextPayload.folders.push({ name: await encryptFolderImportName(session, name) }); folderIndexByName.set(name, folderIndex); } const legacyId = String(folderRaw.id || '').trim(); if (legacyId) folderIndexByLegacyId.set(legacyId, folderIndex); } for (let i = 0; i < payload.ciphers.length; i++) { const raw = (payload.ciphers[i] || {}) as Record; let folderIndex: number | undefined; for (const relation of payload.folderRelationships || []) { const cipherIndex = Number(relation?.key); const relFolderIndex = Number(relation?.value); if (cipherIndex !== i || !Number.isFinite(relFolderIndex)) continue; const importedFolder = payload.folders[relFolderIndex] as Record | undefined; const importedName = String(importedFolder?.name || '').trim(); if (importedName) folderIndex = folderIndexByName.get(importedName); if (folderIndex != null) break; } if (folderIndex == null) { const rawFolderId = String(raw.folderId || '').trim(); if (rawFolderId) folderIndex = folderIndexByLegacyId.get(rawFolderId); } if (folderIndex == null) { const rawFolderName = String(raw.folder || '').trim(); if (rawFolderName) folderIndex = folderIndexByName.get(rawFolderName); } if (folderIndex != null) { nextPayload.folderRelationships.push({ key: i, value: folderIndex }); } } } for (let i = 0; i < payload.ciphers.length; i++) { const raw = (payload.ciphers[i] || {}) as Record; const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null); nextPayload.ciphers.push(await buildCipherImportPayload(session, draft)); } const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, { returnCipherMap: attachments.length > 0, }); await Promise.all([foldersQuery.refetch(), ciphersQuery.refetch()]); const attachmentSummary = attachments.length ? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap)) : undefined; return summarizeImportResult(payload.ciphers, mode === 'original' ? nextPayload.folders.length : 0, attachmentSummary); } async function handleImportEncryptedRawAction( payload: CiphersImportPayload, options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, attachments: ImportAttachmentFile[] = [] ): Promise { const mode = options.folderMode || 'original'; const targetFolderId = (options.targetFolderId || '').trim() || null; const nextPayload: CiphersImportPayload = { ciphers: payload.ciphers.map((raw) => ({ ...(raw as Record) })), folders: mode === 'original' ? payload.folders : [], folderRelationships: mode === 'original' ? payload.folderRelationships : [], }; if (mode === 'none') { for (const raw of nextPayload.ciphers) (raw as Record).folderId = null; } else if (mode === 'target' && targetFolderId) { for (const raw of nextPayload.ciphers) (raw as Record).folderId = targetFolderId; } const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, { returnCipherMap: attachments.length > 0, }); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); const attachmentSummary = attachments.length ? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap)) : undefined; return summarizeImportResult( nextPayload.ciphers, mode === 'original' ? nextPayload.folders.length : 0, attachmentSummary ); } async function handleExportAction(request: ExportRequest) { if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); const masterPassword = String(request.masterPassword || '').trim(); if (!masterPassword) throw new Error(t('txt_master_password_is_required')); const email = String(profile?.email || session.email || '').trim().toLowerCase(); if (!email) throw new Error(t('txt_profile_unavailable')); const verifyDerived = await deriveLoginHash(email, masterPassword, defaultKdfIterations); await verifyMasterPassword(authedFetch, verifyDerived.hash); const rawFolders = foldersQuery.data || []; const rawCiphers = ciphersQuery.data || []; if (!rawFolders || !rawCiphers) throw new Error(t('txt_vault_not_ready')); let plainJsonCache: string | null = null; let plainJsonDocCache: Record | null = null; let encryptedJsonCache: string | null = null; let nodeWardenAttachmentsCache: ReturnType | null = null; const getPlainJson = async () => { if (!plainJsonCache) { plainJsonCache = await buildPlainBitwardenJsonString({ folders: rawFolders, ciphers: rawCiphers, userEncB64: session.symEncKey!, userMacB64: session.symMacKey!, }); } return plainJsonCache; }; const getPlainJsonDoc = async () => { if (!plainJsonDocCache) { plainJsonDocCache = JSON.parse(await getPlainJson()) as Record; } return plainJsonDocCache; }; const getEncryptedJson = async () => { if (!encryptedJsonCache) { encryptedJsonCache = await buildAccountEncryptedBitwardenJsonString({ folders: rawFolders, ciphers: rawCiphers, userEncB64: session.symEncKey!, userMacB64: session.symMacKey!, }); } return encryptedJsonCache; }; const zipAttachments = async (): Promise => { const userEnc = base64ToBytes(session.symEncKey!); const userMac = base64ToBytes(session.symMacKey!); const out: ZipAttachmentEntry[] = []; const activeCiphers = rawCiphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId); for (const cipher of activeCiphers) { const cipherId = String(cipher.id || '').trim(); if (!cipherId) continue; const attachments = Array.isArray(cipher.attachments) ? cipher.attachments : []; if (!attachments.length) continue; let itemEnc = userEnc; let itemMac = userMac; const itemKey = String(cipher.key || '').trim(); if (itemKey && looksLikeCipherString(itemKey)) { try { const rawItemKey = await decryptBw(itemKey, userEnc, userMac); if (rawItemKey.length >= 64) { itemEnc = rawItemKey.slice(0, 32); itemMac = rawItemKey.slice(32, 64); } } catch { // fallback to user key } } for (const attachment of attachments) { const attachmentId = String(attachment?.id || '').trim(); if (!attachmentId) continue; const info = await getAttachmentDownloadInfo(authedFetch, cipherId, attachmentId); const fileResp = await fetch(info.url, { cache: 'no-store' }); if (!fileResp.ok) throw new Error(`Failed to download attachment ${attachmentId}`); const encryptedBytes = new Uint8Array(await fileResp.arrayBuffer()); let fileEnc = itemEnc; let fileMac = itemMac; const attachmentKeyCipher = String(info.key || attachment?.key || '').trim(); if (attachmentKeyCipher && looksLikeCipherString(attachmentKeyCipher)) { try { const rawAttachmentKey = await decryptBw(attachmentKeyCipher, itemEnc, itemMac); if (rawAttachmentKey.length >= 64) { fileEnc = rawAttachmentKey.slice(0, 32); fileMac = rawAttachmentKey.slice(32, 64); } } catch { // fallback to item key } } const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac); const fileNameRaw = String(info.fileName || attachment?.fileName || '').trim(); let fileName = fileNameRaw || `attachment-${attachmentId}`; if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { try { fileName = (await decryptStr(fileNameRaw, itemEnc, itemMac)) || fileName; } catch { // fallback to raw encrypted name } } out.push({ cipherId, fileName, bytes: plainBytes, }); } } return out; }; const getNodeWardenAttachmentRecords = async () => { if (nodeWardenAttachmentsCache) return nodeWardenAttachmentsCache; const [doc, attachments] = await Promise.all([getPlainJsonDoc(), zipAttachments()]); const cipherIndexById = new Map(); const items = Array.isArray(doc.items) ? (doc.items as Array>) : []; for (let i = 0; i < items.length; i++) { const id = String(items[i]?.id || '').trim(); if (id) cipherIndexById.set(id, i); } nodeWardenAttachmentsCache = buildNodeWardenAttachmentRecords(attachments, cipherIndexById); return nodeWardenAttachmentsCache; }; const format = request.format; if (format === 'bitwarden_json') { const bytes = new TextEncoder().encode(await getPlainJson()); return { fileName: buildExportFileName(format), mimeType: 'application/json', bytes, }; } if (format === 'bitwarden_encrypted_json') { if (request.encryptedJsonMode === 'password') { const plainJson = await getPlainJson(); const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); const encrypted = await buildPasswordProtectedBitwardenJsonString({ plaintextJson: plainJson, password: String(request.filePassword || ''), kdf, }); return { fileName: buildExportFileName(format), mimeType: 'application/json', bytes: new TextEncoder().encode(encrypted), }; } const bytes = new TextEncoder().encode(await getEncryptedJson()); return { fileName: buildExportFileName(format), mimeType: 'application/json', bytes, }; } if (format === 'nodewarden_json') { const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]); const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments); return { fileName: buildExportFileName(format), mimeType: 'application/json', bytes: new TextEncoder().encode(JSON.stringify(nodeWardenDoc, null, 2)), }; } if (format === 'nodewarden_encrypted_json') { if (request.encryptedJsonMode === 'password') { const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]); const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments); const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); const encrypted = await buildPasswordProtectedBitwardenJsonString({ plaintextJson: JSON.stringify(nodeWardenDoc, null, 2), password: String(request.filePassword || ''), kdf, }); return { fileName: buildExportFileName(format), mimeType: 'application/json', bytes: new TextEncoder().encode(encrypted), }; } const [encryptedJson, attachments] = await Promise.all([getEncryptedJson(), getNodeWardenAttachmentRecords()]); const withAttachments = await attachNodeWardenEncryptedAttachmentPayload( encryptedJson, attachments, session.symEncKey!, session.symMacKey! ); return { fileName: buildExportFileName(format), mimeType: 'application/json', bytes: new TextEncoder().encode(withAttachments), }; } if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') { let dataJson = await getPlainJson(); if (format === 'bitwarden_encrypted_json_zip') { if (request.encryptedJsonMode === 'password') { const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); dataJson = await buildPasswordProtectedBitwardenJsonString({ plaintextJson: await getPlainJson(), password: String(request.filePassword || ''), kdf, }); } else { dataJson = await getEncryptedJson(); } } const attachments = await zipAttachments(); const zipBytes = buildBitwardenZipBytes(dataJson, attachments); const encryptedZip = await encryptZipBytesWithPassword(zipBytes, String(request.zipPassword || '')); return { fileName: buildExportFileName(format, encryptedZip.encrypted), mimeType: 'application/zip', bytes: encryptedZip.bytes, }; } throw new Error(t('txt_unsupported_export_format')); } function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) { const payload = bytes.slice(); const blob = new Blob([payload], { type: mimeType || 'application/octet-stream' }); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; anchor.download = fileName || 'download.bin'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); } async function handleBackupExportAction() { const payload = await exportAdminBackup(authedFetch); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); } async function handleBackupImportAction(file: File, replaceExisting: boolean = false) { await importAdminBackup(authedFetch, file, replaceExisting); window.setTimeout(() => { logoutNow(); }, 200); } async function handleLoadBackupSettingsAction() { return getAdminBackupSettings(authedFetch); } async function handleSaveBackupSettingsAction(settings: any) { return saveAdminBackupSettings(authedFetch, settings); } async function handleRunRemoteBackupAction(destinationId?: string | null) { return runAdminBackupNow(authedFetch, destinationId); } async function handleListRemoteBackupsAction(destinationId: string, path: string) { return listRemoteBackups(authedFetch, destinationId, path); } async function handleDownloadRemoteBackupAction(destinationId: string, path: string) { const payload = await downloadRemoteBackup(authedFetch, destinationId, path); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); } async function handleDeleteRemoteBackupAction(destinationId: string, path: string) { await deleteRemoteBackup(authedFetch, destinationId, path); } async function handleRestoreRemoteBackupAction(destinationId: string, path: string, replaceExisting: boolean = false) { await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting); window.setTimeout(() => { logoutNow(); }, 200); } const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0]; const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, ''); const normalizedHashPath = trimmedHashPath ? `/${trimmedHashPath}` : '/'; const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath); const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location; const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa'; const isPublicSendRoute = !!publicSendMatch; const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location); const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends'); const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type'); const mobilePrimaryRoute = location === '/sends' ? '/sends' : location === '/vault/totp' ? '/vault/totp' : location === '/vault' ? '/vault' : '/settings'; const currentPageTitle = (() => { if (location === '/vault/totp') return t('txt_verification_code'); if (location === '/sends') return t('nav_sends'); if (location === '/admin') return t('nav_admin_panel'); if (location === '/security/devices') return t('nav_device_management'); if (location === '/help') 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'); })(); const importPageContent = ( }> ); const renderImportPageRoute = () => (
{mobileLayout && (
)} {importPageContent}
); useEffect(() => { if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); }, [phase, location, isPublicSendRoute, navigate]); useEffect(() => { if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) { navigate(IMPORT_ROUTE); } }, [phase, isImportHashRoute, location, navigate]); useEffect(() => { if (phase === 'app' && profile?.role !== 'admin' && location === '/help') { navigate('/vault'); } }, [phase, profile?.role, location, navigate]); useEffect(() => { if (phase === 'app' && !mobileLayout && location === SETTINGS_HOME_ROUTE) { navigate(SETTINGS_ACCOUNT_ROUTE); } }, [phase, mobileLayout, location, navigate]); if (jwtWarning) { return ; } if (publicSendMatch) { return ( <> setToasts((prev) => prev.filter((x) => x.id !== id))} /> ); } if (isRecoverTwoFactorRoute && phase !== 'app') { return ( <> void handleRecoverTwoFactorSubmit()} onCancel={() => { setRecoverValues({ email: '', password: '', recoveryCode: '' }); navigate('/login'); }} /> setToasts((prev) => prev.filter((x) => x.id !== id))} /> ); } if (phase === 'loading') { return ( <>
{t('txt_loading_nodewarden')}
setToasts((prev) => prev.filter((x) => x.id !== id))} /> ); } if (phase === 'register' || phase === 'login' || phase === 'locked') { return ( <> void handleLogin()} onSubmitRegister={() => void handleRegister()} onSubmitUnlock={() => void handleUnlock()} onGotoLogin={() => { setPhase('login'); navigate('/login'); }} onGotoRegister={() => { if (inviteCodeFromUrl) { setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl })); } setPhase('register'); navigate('/register'); }} onLogout={logoutNow} /> setToasts((prev) => prev.filter((x) => x.id !== id))} /> void handleTotpVerify()} onCancel={() => { setPendingTotp(null); setTotpCode(''); setRememberDevice(true); }} afterActions={(
)} > ); } return ( <>
NodeWarden logo NodeWarden {currentPageTitle}
{profile?.email}
{showSidebarToggle && ( )}
{profile && (
{mobileLayout && (
)} }> { await enableTotpAction(secret, token); await totpStatusQuery.refetch(); }} onOpenDisableTotp={() => setDisableTotpOpen(true)} onGetRecoveryCode={getRecoveryCodeAction} onNotify={pushToast} />
)}
{profile && (
{t('nav_account_settings')} {t('nav_device_management')} {t('nav_import_export')} {profile.role === 'admin' && ( {t('nav_admin_panel')} )} {profile.role === 'admin' && ( {t('nav_backup_strategy')} )}
)}
{mobileLayout && (
)} }> void refreshAuthorizedDevices()} onRevokeTrust={(device) => { setConfirm({ title: t('txt_revoke_device_authorization'), message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }), danger: true, onConfirm: () => { setConfirm(null); void revokeDeviceTrustAction(device); }, }); }} onRemoveDevice={(device) => { setConfirm({ title: t('txt_remove_device'), message: t('txt_remove_device_and_sign_out_name', { name: device.name }), danger: true, onConfirm: () => { setConfirm(null); void removeDeviceAction(device); }, }); }} onRevokeAll={() => { setConfirm({ title: t('txt_revoke_all_trusted_devices'), message: t('txt_revoke_30_day_totp_trust_from_all_devices'), danger: true, onConfirm: () => { setConfirm(null); void revokeAllDeviceTrustAction(); }, }); }} onRemoveAll={() => { setConfirm({ title: t('txt_remove_all_devices'), message: t('txt_remove_all_devices_and_sign_out_all_sessions'), danger: true, onConfirm: () => { setConfirm(null); void removeAllDevicesAction(); }, }); }} />
{mobileLayout && (
)} }> { void usersQuery.refetch(); void invitesQuery.refetch(); }} onCreateInvite={async (hours) => { await createInvite(authedFetch, hours); await invitesQuery.refetch(); pushToast('success', t('txt_invite_created')); }} onDeleteAllInvites={async () => { setConfirm({ title: t('txt_delete_all_invites'), message: t('txt_delete_all_invite_codes_active_inactive'), danger: true, onConfirm: () => { setConfirm(null); void (async () => { await deleteAllInvites(authedFetch); await invitesQuery.refetch(); pushToast('success', t('txt_all_invites_deleted')); })(); }, }); }} onToggleUserStatus={async (userId, status) => { await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); await usersQuery.refetch(); pushToast('success', t('txt_user_status_updated')); }} onDeleteUser={async (userId) => { setConfirm({ title: t('txt_delete_user'), message: t('txt_delete_this_user_and_all_user_data'), danger: true, onConfirm: () => { setConfirm(null); void (async () => { await deleteUser(authedFetch, userId); await usersQuery.refetch(); pushToast('success', t('txt_user_deleted')); })(); }, }); }} onRevokeInvite={async (code) => { await revokeInvite(authedFetch, code); await invitesQuery.refetch(); pushToast('success', t('txt_invite_revoked')); }} />
{IMPORT_ROUTE_PATHS.map((path) => ( {renderImportPageRoute()} ))} {profile?.role === 'admin' ? (
{mobileLayout && (
)} }>
) : null}
confirm?.onConfirm()} onCancel={() => setConfirm(null)} /> void disableTotpAction()} onCancel={() => { setDisableTotpOpen(false); setDisableTotpPassword(''); }} > setToasts((prev) => prev.filter((x) => x.id !== id))} /> ); }