diff --git a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts index e4f50f6bd..fb269e6f7 100644 --- a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts @@ -14,6 +14,7 @@ import { getItemWithFallback } from '@/utils/StorageUtility'; import { ApiAuthError } from '@/utils/types/errors/ApiAuthError'; import { AppErrorCode, formatErrorWithCode } from '@/utils/types/errors/AppErrorCodes'; import { NetworkError } from '@/utils/types/errors/NetworkError'; +import { PayloadTooLargeError } from '@/utils/types/errors/PayloadTooLargeError'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse'; import type { DuplicateCheckResponse } from '@/utils/types/messaging/DuplicateCheckResponse'; @@ -682,13 +683,22 @@ export async function handleUploadVault( } catch (error) { console.error('Failed to upload vault:', error); - // Check if error is UPLOAD_OUTDATED (E-802) - server has newer vault const errorMessage = error instanceof Error ? error.message : ''; + + // Check if error is UPLOAD_OUTDATED (E-802) - server has newer vault if (errorMessage.includes('E-802')) { // Return status 2 (Outdated) so caller can handle merge return { success: false, status: 2, error: errorMessage }; } + /* + * Pass through any error already tagged with an E-XXX code (e.g. E-804 for HTTP 413). + * Stripping the targeted code and replacing it with E-801 would lose the actionable message. + */ + if (/E-\d{3}/.test(errorMessage)) { + return { success: false, error: errorMessage }; + } + // E-801: Upload failed for other reasons return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.UPLOAD_FAILED) }; } @@ -810,7 +820,15 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise('Vault', newVault); + let response: VaultPostResponse; + try { + response = await webApi.post('Vault', newVault); + } catch (err) { + if (err instanceof PayloadTooLargeError) { + throw new Error(formatErrorWithCode(await t('common.errors.vaultTooLarge'), AppErrorCode.UPLOAD_TOO_LARGE)); + } + throw err; + } // Check if response is successful (.status === 0) if (response.status === 0) { @@ -1111,11 +1129,47 @@ export async function handleCheckSyncStatus(): Promise { } } +/** + * Persists a sync error message to local storage so the popup can surface it + * even when the failing sync was triggered from the background (e.g. follow-up + * syncs after pending mutations). Cleared on the next successful sync. + * + * Skips errors that already have dedicated UX: + * - requiresLogout: handled by the forced re-login flow + * - wasOffline: handled by the offline indicator + */ +async function persistSyncErrorState(result: FullVaultSyncResult): Promise { + if (result.requiresLogout || result.wasOffline) { + return; + } + + const errorMessage = result.errorKey + ? await t('common.errors.' + result.errorKey) + : result.error; + + if (errorMessage) { + await storage.setItem('local:lastSyncError', errorMessage); + } else if (result.success) { + await storage.removeItem('local:lastSyncError'); + } +} + /** * Full vault sync orchestration that runs entirely in background context. - * This ensures sync completes even if popup closes mid-operation. + * Wraps the internal implementation with sync-error persistence so the popup + * can show a targeted alert for failures even if it wasn't open at the time. */ export async function handleFullVaultSync(): Promise { + const result = await handleFullVaultSyncInternal(); + await persistSyncErrorState(result); + return result; +} + +/** + * Internal implementation of the full vault sync. Wrapped by handleFullVaultSync + * so the result can be persisted to local storage for the popup to surface. + */ +async function handleFullVaultSyncInternal(): Promise { // Check if sync is already in progress if (isSyncInProgress) { // Mark that we need to sync again after current sync completes diff --git a/apps/browser-extension/src/entrypoints/popup/components/Layout/DefaultLayout.tsx b/apps/browser-extension/src/entrypoints/popup/components/Layout/DefaultLayout.tsx index 70f59dfe8..cc276f9d0 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Layout/DefaultLayout.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Layout/DefaultLayout.tsx @@ -1,9 +1,12 @@ import React, { useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { Routes, Route, useLocation } from 'react-router-dom'; import { ClipboardCountdownBar } from '@/entrypoints/popup/components/ClipboardCountdownBar'; +import Modal from '@/entrypoints/popup/components/Dialogs/Modal'; import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav'; import Header from '@/entrypoints/popup/components/Layout/Header'; +import { useDb } from '@/entrypoints/popup/context/DbContext'; /** * Route configuration type. @@ -32,6 +35,8 @@ type DefaultLayoutProps = { const DefaultLayout: React.FC = ({ routes, headerButtons, message, children }) => { const mainRef = useRef(null); const location = useLocation(); + const { t } = useTranslation(); + const { syncError, clearSyncError } = useDb(); // Reset scroll position when route changes useEffect(() => { @@ -76,6 +81,16 @@ const DefaultLayout: React.FC = ({ routes, headerButtons, me + + ); }; diff --git a/apps/browser-extension/src/entrypoints/popup/context/DbContext.tsx b/apps/browser-extension/src/entrypoints/popup/context/DbContext.tsx index 53c1235c3..1aebfcacc 100644 --- a/apps/browser-extension/src/entrypoints/popup/context/DbContext.tsx +++ b/apps/browser-extension/src/entrypoints/popup/context/DbContext.tsx @@ -76,6 +76,16 @@ type DbContextType = { */ refreshSyncState: () => Promise; hasPendingMigrations: () => Promise; + /** + * Last sync error message persisted by the background sync. Surfaced as a popup + * alert. Null when no error is pending. Updated reactively via storage.watch so + * background-initiated sync failures show up immediately while popup is open. + */ + syncError: string | null; + /** + * Dismiss the current sync error (clears both React state and persisted storage). + */ + clearSyncError: () => Promise; } const DbContext = createContext(undefined); @@ -126,6 +136,12 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } */ const [serverRevision, setServerRevision] = useState(0); + /** + * Last sync error written by the background sync. Driven by storage so background-only + * syncs (e.g. follow-up syncs after pending mutations) reach the user. + */ + const [syncError, setSyncError] = useState(null); + /** * Check if email errors should be suppressed. * Errors are suppressed when vault has local changes not yet synced, @@ -153,19 +169,42 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } * Load the offline mode and sync state from local storage. */ const loadSyncState = async (): Promise => { - const [offlineMode, dirty, revision] = await Promise.all([ + const [offlineMode, dirty, revision, lastError] = await Promise.all([ storage.getItem('local:isOfflineMode') as Promise, storage.getItem('local:isDirty') as Promise, - storage.getItem('local:serverRevision') as Promise + storage.getItem('local:serverRevision') as Promise, + storage.getItem('local:lastSyncError') as Promise ]); isOfflineRef.current = offlineMode ?? false; setIsOfflineState(offlineMode ?? false); setIsDirty(dirty ?? false); setServerRevision(revision ?? 0); + setSyncError(lastError ?? null); }; loadSyncState(); }, []); + /** + * Subscribe to background-driven sync error updates so a popup alert appears + * even when the failing sync wasn't triggered by anything in the popup itself. + */ + useEffect(() => { + const unwatch = storage.watch('local:lastSyncError', (newValue) => { + setSyncError(newValue ?? null); + }); + return (): void => { + unwatch(); + }; + }, []); + + /** + * Dismiss the current sync error from both React state and persisted storage. + */ + const clearSyncError = useCallback(async (): Promise => { + setSyncError(null); + await storage.removeItem('local:lastSyncError'); + }, []); + /** * Load a decrypted vault into memory (SQLite client). */ @@ -325,7 +364,9 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } getVaultMetadata, refreshSyncState, hasPendingMigrations, - }), [sqliteClient, dbInitialized, dbAvailable, isOffline, getIsOffline, isDirty, isSyncing, isUploading, serverRevision, setIsOffline, shouldSuppressEmailErrors, loadDatabase, loadStoredDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams, clearDatabase, getVaultMetadata, refreshSyncState, hasPendingMigrations]); + syncError, + clearSyncError, + }), [sqliteClient, dbInitialized, dbAvailable, isOffline, getIsOffline, isDirty, isSyncing, isUploading, serverRevision, setIsOffline, shouldSuppressEmailErrors, loadDatabase, loadStoredDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams, clearDatabase, getVaultMetadata, refreshSyncState, hasPendingMigrations, syncError, clearSyncError]); return ( diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts index 86966a1a1..104b1a2f5 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts @@ -127,6 +127,18 @@ export function useVaultMutate(): { */ void sendMessage('FULL_VAULT_SYNC', {}, 'background').then(async (result) => { const syncResult = result as FullVaultSyncResult; + if (!syncResult.success && (syncResult.error || syncResult.errorKey)) { + /* + * Permanent failure (e.g. HTTP 413 vault too large). Stop polling and clear the upload + * spinner. + */ + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + dbContext.setIsUploading(false); + return; + } if (syncResult.hasNewVault) { await dbContext.loadStoredDatabase(); } diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts index 0f9ea6f51..381970364 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts @@ -122,8 +122,11 @@ export const useVaultSync = (): { syncVault: (options?: VaultSyncOptions) => Pro onError?.(errorMessage); return false; } finally { - // Always clear syncing/uploading states when done + /* + * Always clear syncing/uploading states when done including on failure. + */ dbContext.setIsSyncing(false); + dbContext.setIsUploading(false); } }, [app, dbContext, t]); diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index c9180cced..66d152881 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -108,6 +108,7 @@ "passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons.", "syncConflictMaxRetries": "Could not sync vault after multiple attempts. Please try again later.", "mergeFailed": "Failed to merge vault changes. Please try again.", + "vaultTooLarge": "The vault is too large for the server to accept. Try to remove some items or attachments to reduce the size and try again.", "invalidCode": "Please enter a valid 6-digit authentication code.", "serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.", "wrongPassword": "Incorrect password. Please try again.", @@ -546,6 +547,8 @@ }, "sync": { "offline": "Offline", - "tapToRetry": "Tap to retry sync" + "tapToRetry": "Tap to retry sync", + "syncErrorTitle": "Sync Failed", + "syncErrorDismiss": "Dismiss" } } \ No newline at end of file diff --git a/apps/browser-extension/src/utils/WebApiService.ts b/apps/browser-extension/src/utils/WebApiService.ts index abc059ec6..0e7ce7a6c 100644 --- a/apps/browser-extension/src/utils/WebApiService.ts +++ b/apps/browser-extension/src/utils/WebApiService.ts @@ -5,6 +5,7 @@ import { logoutEventEmitter } from '@/events/LogoutEventEmitter'; import { AppInfo } from "./AppInfo"; import { ApiAuthError } from './types/errors/ApiAuthError'; import { NetworkError } from './types/errors/NetworkError'; +import { PayloadTooLargeError } from './types/errors/PayloadTooLargeError'; import { storage } from '#imports'; @@ -89,6 +90,10 @@ export class WebApiService { } } + if (response.status === 413 && throwOnError) { + throw new PayloadTooLargeError(`Request rejected with HTTP 413: payload exceeds server limit`); + } + if (!response.ok && throwOnError) { throw new Error(`HTTP error! status: ${response.status}`); } diff --git a/apps/browser-extension/src/utils/types/errors/AppErrorCodes.ts b/apps/browser-extension/src/utils/types/errors/AppErrorCodes.ts index 61024b770..807366ed7 100644 --- a/apps/browser-extension/src/utils/types/errors/AppErrorCodes.ts +++ b/apps/browser-extension/src/utils/types/errors/AppErrorCodes.ts @@ -66,6 +66,7 @@ export enum AppErrorCode { UPLOAD_FAILED = 'E-801', UPLOAD_OUTDATED = 'E-802', // Server has newer version UPLOAD_ENCRYPT_FAILED = 'E-803', + UPLOAD_TOO_LARGE = 'E-804', // Server rejected upload with HTTP 413 (vault exceeds MAX_UPLOAD_SIZE_MB) // Migration/version errors (E-9xx) MIGRATION_CHECK_FAILED = 'E-901', @@ -159,6 +160,7 @@ export function getErrorTranslationKey(code: AppErrorCode): string { [AppErrorCode.UPLOAD_FAILED]: 'common.errors.unknownErrorTryAgain', [AppErrorCode.UPLOAD_OUTDATED]: 'common.errors.unknownErrorTryAgain', [AppErrorCode.UPLOAD_ENCRYPT_FAILED]: 'common.errors.unknownErrorTryAgain', + [AppErrorCode.UPLOAD_TOO_LARGE]: 'common.errors.vaultTooLarge', // Migration/version errors (E-9xx) [AppErrorCode.MIGRATION_CHECK_FAILED]: 'common.errors.unknownErrorTryAgain', diff --git a/apps/browser-extension/src/utils/types/errors/PayloadTooLargeError.ts b/apps/browser-extension/src/utils/types/errors/PayloadTooLargeError.ts new file mode 100644 index 000000000..c2418c0e3 --- /dev/null +++ b/apps/browser-extension/src/utils/types/errors/PayloadTooLargeError.ts @@ -0,0 +1,15 @@ +/** + * Thrown when the server rejects a request with HTTP 413 (Request Entity Too Large). + * For vault uploads this signals the encrypted vault exceeds the server's MAX_UPLOAD_SIZE_MB limit. + */ +export class PayloadTooLargeError extends Error { + /** + * Creates a new instance of PayloadTooLargeError. + * + * @param message - The error message. + */ + public constructor(message: string) { + super(message); + this.name = 'PayloadTooLargeError'; + } +}