diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 2af4674..0b9e078 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -434,6 +434,7 @@ export default function App() { (async () => { const boot = await bootstrapAppSession(initialBootstrap); if (!mounted) return; + if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) return; setDefaultKdfIterations(boot.defaultKdfIterations); setRegistrationInviteRequired(boot.registrationInviteRequired); setJwtWarning(boot.jwtWarning); @@ -912,7 +913,7 @@ export default function App() { const vaultCoreQuery = useQuery({ queryKey: ['vault-core', vaultCacheKey], queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey), - enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey, + enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey, staleTime: 30_000, }); const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore; @@ -923,7 +924,7 @@ export default function App() { const sendsQuery = useQuery({ queryKey: sendsQueryKey, queryFn: () => getSends(authedFetch), - enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync, + enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync, staleTime: 30_000, }); const encryptedSends = sendsQuery.data || encryptedSendsFromSync; @@ -952,13 +953,13 @@ export default function App() { const usersQuery = useQuery({ queryKey: ['admin-users', vaultCacheKey], queryFn: () => listAdminUsers(authedFetch), - enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, + enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); const invitesQuery = useQuery({ queryKey: ['admin-invites', vaultCacheKey], queryFn: () => listAdminInvites(authedFetch), - enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, + enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); const totpStatusQuery = useQuery({ @@ -1015,7 +1016,7 @@ export default function App() { useQuery({ queryKey: ['admin-backup-settings', vaultCacheKey], queryFn: () => backupActions.loadSettings(), - enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, + enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); @@ -1085,6 +1086,7 @@ export default function App() { setDecryptedFolders(result.folders); setDecryptedCiphers(result.ciphers); setVaultInitialDecryptDone(true); + if (!session.accessToken) return; const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`; if (uriChecksumRepairAttemptRef.current !== repairKey) { uriChecksumRepairAttemptRef.current = repairKey; diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx index f07aee3..0d4d077 100644 --- a/webapp/src/components/AppAuthenticatedShell.tsx +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; import { Link } from 'wouter'; import AppMainRoutes from '@/components/AppMainRoutes'; +import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import ThemeSwitch from '@/components/ThemeSwitch'; import type { AppMainRoutesProps } from '@/components/AppMainRoutes'; import { t } from '@/lib/i18n'; @@ -237,6 +238,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {props.currentPageTitle}
+
{props.profile?.email} diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index 66b2c26..e53bd04 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -1,5 +1,6 @@ import { useState } from 'preact/hooks'; import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact'; +import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import StandalonePageFrame from '@/components/StandalonePageFrame'; import { t } from '@/lib/i18n'; @@ -83,7 +84,7 @@ export default function AuthViews(props: AuthViewsProps) { if (props.mode === 'locked') { return (
- + }>
{ e.preventDefault(); @@ -132,7 +133,7 @@ export default function AuthViews(props: AuthViewsProps) { if (props.mode === 'register') { return (
- + }> { e.preventDefault(); @@ -216,7 +217,7 @@ export default function AuthViews(props: AuthViewsProps) { return (
- + }> { e.preventDefault(); diff --git a/webapp/src/components/NetworkStatusBadge.tsx b/webapp/src/components/NetworkStatusBadge.tsx new file mode 100644 index 0000000..4f22cb2 --- /dev/null +++ b/webapp/src/components/NetworkStatusBadge.tsx @@ -0,0 +1,86 @@ +import { Wifi, WifiOff } from 'lucide-preact'; +import { useEffect, useState } from 'preact/hooks'; +import { t } from '@/lib/i18n'; +import { + browserReportsOffline, + getCurrentNetworkStatus, + probeNodeWardenService, + setCurrentNetworkStatus, + subscribeNetworkStatus, + type NetworkStatus, +} from '@/lib/network-status'; + +const STATUS_CHECK_INTERVAL_MS = 30_000; + +function statusLabel(status: NetworkStatus): string { + if (status === 'online') return t('txt_online'); + return t('txt_offline'); +} + +export default function NetworkStatusBadge() { + const [status, setStatus] = useState(getCurrentNetworkStatus); + const label = statusLabel(status); + const Icon = status === 'online' ? Wifi : WifiOff; + + useEffect(() => { + let cancelled = false; + let timer = 0; + + const checkService = async () => { + if (browserReportsOffline()) { + setCurrentNetworkStatus('offline'); + return; + } + const reachable = await probeNodeWardenService(); + if (!cancelled) { + setCurrentNetworkStatus(reachable ? 'online' : 'offline'); + } + }; + + const scheduleNextCheck = () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => { + void checkService().finally(scheduleNextCheck); + }, STATUS_CHECK_INTERVAL_MS); + }; + + const handleOnline = () => { + void checkService(); + }; + const handleOffline = () => { + setCurrentNetworkStatus('offline'); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') void checkService(); + }; + + const unsubscribe = subscribeNetworkStatus(setStatus); + void checkService().finally(scheduleNextCheck); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + window.addEventListener('focus', handleOnline); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + cancelled = true; + unsubscribe(); + window.clearTimeout(timer); + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + window.removeEventListener('focus', handleOnline); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + return ( + + + ); +} diff --git a/webapp/src/components/StandalonePageFrame.tsx b/webapp/src/components/StandalonePageFrame.tsx index 0704752..936767a 100644 --- a/webapp/src/components/StandalonePageFrame.tsx +++ b/webapp/src/components/StandalonePageFrame.tsx @@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version'; interface StandalonePageFrameProps { title: string; eyebrow?: ComponentChildren; + titleAccessory?: ComponentChildren; children: ComponentChildren; } @@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
{props.eyebrow &&
{props.eyebrow}
} -

{props.title}

+
+

{props.title}

+ {props.titleAccessory} +
{props.children}
diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 7259d2f..4d3f591 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -716,6 +716,8 @@ const folderName = useCallback((id: string | null | undefined): string => { setAttachmentQueue([]); setRemovedAttachmentIds({}); if (isMobileLayout) setMobilePanel('detail'); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -729,6 +731,8 @@ const folderName = useCallback((id: string | null | undefined): string => { setPendingDelete(null); cancelEdit(); if (isMobileLayout) setMobilePanel('list'); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -741,6 +745,8 @@ const folderName = useCallback((id: string | null | undefined): string => { if (isMobileLayout && selectedCipherId === cipher.id) { setMobilePanel('list'); } + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -760,6 +766,8 @@ const folderName = useCallback((id: string | null | undefined): string => { } setSelectedMap({}); setBulkDeleteOpen(false); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -776,6 +784,8 @@ const folderName = useCallback((id: string | null | undefined): string => { await props.onBulkMove(ids, folderId); setSelectedMap({}); setMoveOpen(false); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -785,6 +795,8 @@ const folderName = useCallback((id: string | null | undefined): string => { setBusy(true); try { await props.onRefresh(); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -819,6 +831,8 @@ const folderName = useCallback((id: string | null | undefined): string => { await props.onCreateFolder(newFolderName); setCreateFolderOpen(false); setNewFolderName(''); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -833,6 +847,8 @@ const folderName = useCallback((id: string | null | undefined): string => { setSidebarFilter({ kind: 'all' }); } setPendingDeleteFolder(null); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -850,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => { await props.onRenameFolder(pendingRenameFolder.id, nextName); setPendingRenameFolder(null); setRenameFolderName(''); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -864,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => { try { await props.onBulkRestore(ids); setSelectedMap({}); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -878,6 +898,8 @@ const folderName = useCallback((id: string | null | undefined): string => { if (isMobileLayout && selectedCipherId === pendingArchive.id) { setMobilePanel('list'); } + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -892,6 +914,8 @@ const folderName = useCallback((id: string | null | undefined): string => { delete next[cipher.id]; return next; }); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -907,6 +931,8 @@ const folderName = useCallback((id: string | null | undefined): string => { await props.onBulkArchive(ids); setSelectedMap({}); setBulkArchiveOpen(false); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -921,6 +947,8 @@ const folderName = useCallback((id: string | null | undefined): string => { try { await props.onBulkUnarchive(ids); setSelectedMap({}); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } @@ -935,6 +963,8 @@ const folderName = useCallback((id: string | null | undefined): string => { setSidebarFilter({ kind: 'all' }); } setDeleteAllFoldersOpen(false); + } catch { + // The action layer already shows the user-facing error toast. } finally { setBusy(false); } diff --git a/webapp/src/components/vault/WebsiteIcon.tsx b/webapp/src/components/vault/WebsiteIcon.tsx index 094e800..f14d811 100644 --- a/webapp/src/components/vault/WebsiteIcon.tsx +++ b/webapp/src/components/vault/WebsiteIcon.tsx @@ -9,6 +9,7 @@ import { subscribeWebsiteIconStatus, } from '@/lib/website-icon-cache'; import { demoBrandIconUrl } from '@/lib/demo-brand-icons'; +import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status'; import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils'; const ICON_LOAD_ROOT_MARGIN = '180px 0px'; @@ -26,8 +27,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) { const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true)); const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : '')); + const [networkStatus, setNetworkStatus] = useState(getCurrentNetworkStatus); const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : ''; + useEffect(() => subscribeNetworkStatus(setNetworkStatus), []); + useEffect(() => { if (!host) { setShouldLoad(true); @@ -77,9 +81,10 @@ export default function WebsiteIcon(props: WebsiteIconProps) { useEffect(() => { if (SHOULD_LOAD_DEMO_BRAND_ICONS) return; if (demoIconUrl) return; + if (networkStatus !== 'online') return; if (!host || !src || !shouldLoad || status !== 'idle') return; beginWebsiteIconLoad(host, src); - }, [demoIconUrl, host, src, shouldLoad, status]); + }, [demoIconUrl, host, networkStatus, src, shouldLoad, status]); if (demoIconUrl) { return ( diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index 2b8f21f..7d64805 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -302,6 +302,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]); }; + const requireOnlineWrite = () => { + if (session?.accessToken) return; + throw new Error(t('txt_offline_vault_readonly')); + }; + const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => { const tasks: Promise[] = [Promise.resolve(refetchCiphers())]; if (options?.includeFolders) { @@ -447,6 +452,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async createVaultItem(draft: VaultDraft, attachments: File[] = []) { if (!session) return; + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } const optimistic = optimisticCipherFromDraft(draft, null); patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]); try { @@ -471,6 +482,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) { if (!session) return; + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } if (hasUnresolvedCipherData(cipher)) { throw new Error(t('txt_decrypt_failed_2')); } @@ -543,6 +560,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async deleteVaultItem(cipher: Cipher) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } const previousCipher = { ...cipher }; if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) { try { @@ -571,6 +594,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async archiveVaultItem(cipher: Cipher) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } const previousCipher = { ...cipher }; const archivedDate = new Date().toISOString(); patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate })); @@ -587,6 +616,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async unarchiveVaultItem(cipher: Cipher) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } const previousCipher = { ...cipher }; const revisionDate = new Date().toISOString(); patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate })); @@ -603,6 +638,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async bulkDeleteVaultItems(ids: string[]) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await bulkDeleteCiphers(authedFetch, ids); const deletedDate = new Date().toISOString(); @@ -616,6 +657,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async bulkArchiveVaultItems(ids: string[]) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await bulkArchiveCiphers(authedFetch, ids); const archivedDate = new Date().toISOString(); @@ -629,6 +676,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async bulkUnarchiveVaultItems(ids: string[]) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await bulkUnarchiveCiphers(authedFetch, ids); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null })); @@ -641,6 +694,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async bulkMoveVaultItems(ids: string[], folderId: string | null) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await bulkMoveCiphers(authedFetch, ids, folderId); patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId })); @@ -658,6 +717,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) onNotify('error', t('txt_folder_name_is_required')); return; } + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { if (!session) throw new Error(t('txt_vault_key_unavailable')); const created = await createFolder(authedFetch, session, folderName); @@ -683,6 +748,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) onNotify('error', t('txt_folder_not_found')); return; } + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await deleteFolder(authedFetch, id); patchFolderBatch([id], () => null); @@ -706,6 +777,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) onNotify('error', t('txt_folder_name_is_required')); return; } + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { if (!session) throw new Error(t('txt_vault_key_unavailable')); await updateFolder(authedFetch, session, id, nextName); @@ -719,6 +796,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async bulkRestoreVaultItems(ids: string[]) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await bulkRestoreCiphers(authedFetch, ids); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null })); @@ -731,6 +814,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async bulkPermanentDeleteVaultItems(ids: string[]) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await bulkPermanentDeleteCiphers(authedFetch, ids); patchCipherBatch(ids, () => null); @@ -745,6 +834,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async bulkDeleteFolders(folderIds: string[]) { const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean))); if (!ids.length) return; + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await bulkDeleteFolders(authedFetch, ids); const removedIds = new Set(ids); @@ -765,6 +860,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async createSend(draft: SendDraft, autoCopyLink: boolean) { if (!session) return; + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : ''; if (fileName) { @@ -790,6 +891,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) { if (!session) return; + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { const updated = await updateSend(authedFetch, session, send, draft); await refetchSends(); @@ -806,6 +913,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async deleteSend(send: Send) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await deleteSend(authedFetch, send.id); await refetchSends(); @@ -817,6 +930,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }, async bulkDeleteSends(ids: string[]) { + try { + requireOnlineWrite(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly')); + throw error; + } try { await bulkDeleteSends(authedFetch, ids); await refetchSends(); @@ -833,6 +952,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) attachments: ImportAttachmentFile[] = [] ): Promise { if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); + requireOnlineWrite(); const mode = options.folderMode || 'original'; const targetFolderId = (options.targetFolderId || '').trim() || null; diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 832d07f..fb0629f 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -458,7 +458,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess }; const session = getSession(); - if (!session?.accessToken) throw new Error('Unauthorized'); + if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly')); const headers = new Headers(init.headers || {}); headers.set('Authorization', `Bearer ${session.accessToken}`); headers.set('X-NodeWarden-Web', '1'); diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index 8d91f3d..826ee3d 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -20,12 +20,14 @@ import { saveOfflineUnlockRecord, unlockOfflineVaultWithMasterKey, } from '@/lib/offline-auth'; +import { probeNodeWardenService } from '@/lib/network-status'; import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types'; export interface PendingTotp { email: string; passwordHash: string; masterKey: Uint8Array; + kdfIterations: number; } export type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; @@ -268,6 +270,16 @@ export async function hydrateLockedSession( session: SessionState, fallbackProfile: Profile | null = null ): Promise<{ session: SessionState | null; profile: Profile | null }> { + if (hasOfflineUnlockRecord(session.email)) { + const serviceReachable = await probeNodeWardenService(); + if (!serviceReachable) { + return { + session, + profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), + }; + } + } + const refreshedSession = await maybeRefreshSession(session); if (!refreshedSession?.accessToken) { if (hasOfflineUnlockRecord(session.email)) { @@ -357,6 +369,7 @@ export async function performPasswordLogin( email: normalizedEmail, passwordHash: derived.hash, masterKey: derived.masterKey, + kdfIterations: derived.kdfIterations, }, }; } @@ -377,7 +390,7 @@ export async function performTotpLogin( rememberDevice, }); if ('access_token' in token && token.access_token) { - return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, kdfIterationsFromLogin(token, 600000)); + return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations); } const tokenError = token as { error_description?: string; error?: string }; throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed'))); @@ -455,8 +468,14 @@ export async function performUnlock( } }; - if (hasOfflineUnlock && browserReportsOffline()) { - return unlockOffline(); + if (hasOfflineUnlock) { + if (browserReportsOffline()) { + return unlockOffline(); + } + const serviceReachable = await probeNodeWardenService(); + if (!serviceReachable) { + return unlockOffline(); + } } let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string }; @@ -493,6 +512,7 @@ export async function performUnlock( email: normalizedEmail, passwordHash: derived.hash, masterKey: derived.masterKey, + kdfIterations: derived.kdfIterations, }, }; } diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index c989093..b23abbb 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -735,6 +735,7 @@ const en: Record = { "txt_status": "Status", "txt_online": "Online", "txt_offline": "Offline", + "txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.", "txt_submit": "Submit", "txt_sync": "Sync", "txt_sync_vault": "Sync Vault", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index bf46baf..b9aa63e 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -735,6 +735,7 @@ const es: Record = { "txt_status": "Estado", "txt_online": "En línea", "txt_offline": "Sin conexión", + "txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.", "txt_submit": "Enviar", "txt_sync": "Sincronizar", "txt_sync_vault": "Sincronizar bóveda", diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index 3fc25bf..4558c7a 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -735,6 +735,7 @@ const ru: Record = { "txt_status": "Статус", "txt_online": "Онлайн", "txt_offline": "Офлайн", + "txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.", "txt_submit": "Отправить", "txt_sync": "Синхронизировать", "txt_sync_vault": "Синхронизировать хранилище", diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index d3485eb..d9f7c0c 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -735,6 +735,7 @@ const zhCN: Record = { "txt_status": "状态", "txt_online": "在线", "txt_offline": "离线", + "txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。", "txt_submit": "提交", "txt_sync": "同步", "txt_sync_vault": "同步", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index f9cc3ed..b1230fb 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -735,6 +735,7 @@ const zhTW: Record = { "txt_status": "狀態", "txt_online": "在線", "txt_offline": "離線", + "txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。", "txt_submit": "提交", "txt_sync": "同步", "txt_sync_vault": "同步", diff --git a/webapp/src/lib/network-status.ts b/webapp/src/lib/network-status.ts new file mode 100644 index 0000000..c132cad --- /dev/null +++ b/webapp/src/lib/network-status.ts @@ -0,0 +1,79 @@ +export type NetworkStatus = 'online' | 'offline'; + +const STATUS_PROBE_TIMEOUT_MS = 3500; +const STATUS_PROBE_CACHE_MS = 5000; +const listeners = new Set<(status: NetworkStatus) => void>(); +let currentStatus: NetworkStatus = getInitialNetworkStatus(); +let pendingProbe: Promise | null = null; +let lastProbeAt = 0; +let lastProbeResult = false; + +export function browserReportsOffline(): boolean { + return typeof navigator !== 'undefined' && navigator.onLine === false; +} + +export function getInitialNetworkStatus(): NetworkStatus { + return 'offline'; +} + +export function getCurrentNetworkStatus(): NetworkStatus { + return currentStatus; +} + +export function setCurrentNetworkStatus(status: NetworkStatus): void { + if (currentStatus === status) return; + currentStatus = status; + for (const listener of Array.from(listeners)) { + listener(status); + } +} + +export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export async function probeNodeWardenService(): Promise { + if (browserReportsOffline()) { + setCurrentNetworkStatus('offline'); + return false; + } + + const now = Date.now(); + if (pendingProbe) return pendingProbe; + if (now - lastProbeAt < STATUS_PROBE_CACHE_MS) return lastProbeResult; + + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timer = controller + ? window.setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS) + : 0; + + pendingProbe = (async () => { + const response = await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, { + method: 'GET', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + }, + signal: controller?.signal, + }); + return response.ok; + })() + .catch(() => false) + .then((result) => { + lastProbeAt = Date.now(); + lastProbeResult = result; + setCurrentNetworkStatus(result ? 'online' : 'offline'); + return result; + }) + .finally(() => { + if (timer) window.clearTimeout(timer); + pendingProbe = null; + }); + + return pendingProbe; +} diff --git a/webapp/src/lib/offline-auth.ts b/webapp/src/lib/offline-auth.ts index f579624..9a83bc7 100644 --- a/webapp/src/lib/offline-auth.ts +++ b/webapp/src/lib/offline-auth.ts @@ -141,9 +141,10 @@ export async function unlockOfflineVaultWithMasterKey( throw new Error('Offline unlock is not available on this device.'); } const keys = await unlockVaultKey(record.profileKey, masterKey); + const { accessToken: _accessToken, refreshToken: _refreshToken, ...offlineSession } = session; return { session: { - ...session, + ...offlineSession, email: record.email, ...keys, }, diff --git a/webapp/src/lib/pwa.ts b/webapp/src/lib/pwa.ts index 8675be8..2fb857f 100644 --- a/webapp/src/lib/pwa.ts +++ b/webapp/src/lib/pwa.ts @@ -3,9 +3,16 @@ export function registerNodeWardenServiceWorker(): void { if (!('serviceWorker' in navigator)) return; if (import.meta.env.DEV) return; - window.addEventListener('load', () => { + const register = () => { void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => { // PWA support is progressive enhancement; the vault still works without it. }); - }); + }; + + if (document.readyState === 'complete') { + register(); + return; + } + + window.addEventListener('load', register, { once: true }); } diff --git a/webapp/src/lib/vault-worker.ts b/webapp/src/lib/vault-worker.ts index eba458e..581c7e5 100644 --- a/webapp/src/lib/vault-worker.ts +++ b/webapp/src/lib/vault-worker.ts @@ -1,4 +1,5 @@ import type { Send } from './types'; +import { getCurrentNetworkStatus } from './network-status'; import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt'; type WorkerSuccess = { id: number; ok: true; result: T }; @@ -12,6 +13,7 @@ const pending = new Map void; reject: (error: function getWorker(): Worker | null { if (typeof Worker === 'undefined') return null; if (worker) return worker; + if (getCurrentNetworkStatus() === 'offline') return null; worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' }); worker.addEventListener('message', (event: MessageEvent>) => { const message = event.data; diff --git a/webapp/src/styles/auth.css b/webapp/src/styles/auth.css index 03b739c..c58e6ba 100644 --- a/webapp/src/styles/auth.css +++ b/webapp/src/styles/auth.css @@ -451,7 +451,7 @@ } .auth-card h1 { - @apply m-0 mb-1 text-center; + @apply m-0; } .standalone-eyebrow { @@ -486,7 +486,17 @@ } .standalone-title { - @apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal; + @apply m-0 text-center text-3xl font-bold leading-tight tracking-normal; +} + +.standalone-title-row { + @apply relative mb-1 flex min-h-9 items-center justify-center; + padding-inline: 84px; +} + +.standalone-title-row > .network-status-badge { + @apply absolute right-0 top-1/2; + transform: translateY(-50%); } .standalone-muted { diff --git a/webapp/src/styles/responsive.css b/webapp/src/styles/responsive.css index fa2d0f7..83eda38 100644 --- a/webapp/src/styles/responsive.css +++ b/webapp/src/styles/responsive.css @@ -54,6 +54,10 @@ @apply text-2xl; } + .standalone-title-row { + padding-inline: 68px; + } + .standalone-footer { @apply text-xs leading-[1.4]; } @@ -128,6 +132,14 @@ @apply hidden; } + .topbar-actions > .network-status-badge { + @apply h-8 px-2 text-[0]; + } + + .topbar-actions > .network-status-badge svg { + @apply m-0; + } + .mobile-sidebar-toggle, .mobile-lock-btn { @apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0]; diff --git a/webapp/src/styles/shell.css b/webapp/src/styles/shell.css index a3ac84f..27f1963 100644 --- a/webapp/src/styles/shell.css +++ b/webapp/src/styles/shell.css @@ -44,6 +44,31 @@ @apply flex items-center gap-2.5; } +.network-status-badge { + @apply inline-flex h-[30px] shrink-0 select-none items-center gap-1.5 rounded-full border px-2.5 text-xs font-bold leading-none; + letter-spacing: 0; +} + +.network-status-badge svg { + @apply shrink-0; +} + +.network-status-badge.online { + background: color-mix(in srgb, var(--success) 10%, var(--panel)); + border-color: color-mix(in srgb, var(--success) 36%, var(--line)); + color: color-mix(in srgb, var(--success) 78%, var(--text)); +} + +.network-status-badge.offline { + background: color-mix(in srgb, var(--warning) 13%, var(--panel)); + border-color: color-mix(in srgb, var(--warning) 42%, var(--line)); + color: color-mix(in srgb, var(--warning) 82%, var(--text)); +} + +.topbar-actions > .network-status-badge { + @apply h-[34px] px-3; +} + .mobile-tabbar { @apply hidden; } diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index dff0e3d..3c85cd2 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -12,7 +12,7 @@ const APP_SHELL_CACHE = \`\${CACHE_VERSION}-shell\`; const RUNTIME_CACHE = 'nodewarden-pwa-runtime-v1'; const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)}; -const CRITICAL_SHELL_URLS = ['/', '/index.html', '/vault']; +const CRITICAL_SHELL_URLS = ['/', '/index.html']; const STATIC_PATH_RE = /^\\/(?:assets\\/|payment-logos\\/|icon-|logo-|favicon|apple-touch-icon|nodewarden-|manifest\\.webmanifest$)/; const NEVER_CACHE_PATH_RE = /^\\/(?:api|identity|setup|config|notifications|icons|\\.well-known|cdn-cgi)(?:\\/|$)/; const OFFLINE_FALLBACK_HTML = 'NodeWarden
NodeWarden
Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.
'; @@ -196,7 +196,7 @@ function pwaServiceWorkerPlugin(isDemo: boolean): Plugin { buildUrls.add(`/${fileName}`); } - const sortedUrls = Array.from(urls).sort(); + const sortedUrls = Array.from(buildUrls).sort(); const version = buildCacheVersion(isDemo, Array.from(buildUrls).sort()); this.emitFile({ type: 'asset',