import { useEffect, useMemo, useState } from 'preact/hooks'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; import { CircleHelp, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } 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 SettingsPage from '@/components/SettingsPage'; import SecurityDevicesPage from '@/components/SecurityDevicesPage'; import AdminPage from '@/components/AdminPage'; import HelpPage from '@/components/HelpPage'; import { changeMasterPassword, createFolder, createCipher, createAuthedFetch, createInvite, createSend, deleteAllInvites, deleteCipher, deleteSend, deleteUser, deriveLoginHash, bulkMoveCiphers, getCiphers, getFolders, getProfile, getAuthorizedDevices, getSetupStatus, getSends, getTotpStatus, getTotpRecoveryCode, getWebConfig, listAdminInvites, listAdminUsers, loadSession, loginWithPassword, registerAccount, recoverTwoFactor, revokeInvite, revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, saveSession, setTotp, setUserStatus, deleteAuthorizedDevice, updateCipher, updateSend, buildSendShareKey, unlockVaultKey, verifyMasterPassword, } from '@/lib/api'; import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto'; import { t } from '@/lib/i18n'; import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; 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'; function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string { return `${origin}/#/send/${accessId}/${keyPart}`; } 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 [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 [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); function setSession(next: SessionState | null) { setSessionState(next); saveSession(next); } 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] ); 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); 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'); 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); setSession({ ...session, ...keys }); 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('/login'); } 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), 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) { nextCipher.sshKey = { ...cipher.sshKey, decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac), decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac), decFingerprint: await decryptField(cipher.sshKey.fingerprint || '', 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), })) ); } 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]); 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()) { pushToast('error', t('txt_secret_and_code_are_required')); return; } 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')); } } 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 refreshAuthorizedDevices() { await authorizedDevicesQuery.refetch(); } 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); await authorizedDevicesQuery.refetch(); pushToast('success', t('txt_device_removed')); } async function createVaultItem(draft: VaultDraft) { if (!session) return; try { await createCipher(authedFetch, session, draft); 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) { if (!session) return; try { await updateCipher(authedFetch, session, cipher, draft); 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 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 { for (const id of ids) { await deleteCipher(authedFetch, id); } 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 { for (const id of ids) { await deleteSend(authedFetch, id); } 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 { await createFolder(authedFetch, 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; } } const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location; const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa'; const isPublicSendRoute = !!publicSendMatch; useEffect(() => { if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); }, [phase, location, isPublicSendRoute, 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')} onGotoRegister={() => setPhase('register')} onLogout={logoutNow} /> setToasts((prev) => prev.filter((x) => x.id !== id))} /> void handleTotpVerify()} onCancel={() => { setPendingTotp(null); setTotpCode(''); setRememberDevice(true); }} afterActions={(
)} > ); } return ( <>
NodeWarden logo NodeWarden
{profile?.email}
{profile && ( { await enableTotpAction(secret, token); await totpStatusQuery.refetch(); }} onOpenDisableTotp={() => setDisableTotpOpen(true)} onGetRecoveryCode={getRecoveryCodeAction} onNotify={pushToast} /> )} 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_name_and_clear_its_2fa_trust', { 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(); }, }); }} /> { 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')); }} />
confirm?.onConfirm()} onCancel={() => setConfirm(null)} /> void disableTotpAction()} onCancel={() => { setDisableTotpOpen(false); setDisableTotpPassword(''); }} > setToasts((prev) => prev.filter((x) => x.id !== id))} /> ); }