diff --git a/packages/sync-providers/src/file-based/dropbox/dropbox-api.ts b/packages/sync-providers/src/file-based/dropbox/dropbox-api.ts index 2bde2b5779..57715e8ec0 100644 --- a/packages/sync-providers/src/file-based/dropbox/dropbox-api.ts +++ b/packages/sync-providers/src/file-based/dropbox/dropbox-api.ts @@ -528,10 +528,7 @@ export class DropboxApi { if (!response.ok) { const bodyStr = await response.text(); - throw new HttpNotOkAPIError( - new Response(bodyStr, { status: response.status }), - bodyStr, - ); + throw new HttpNotOkAPIError(response, bodyStr); } data = (await response.json()) as TokenResponse; diff --git a/packages/sync-providers/tests/file-based/dropbox/dropbox-api.spec.ts b/packages/sync-providers/tests/file-based/dropbox/dropbox-api.spec.ts index 82243aa5bb..5f41b23101 100644 --- a/packages/sync-providers/tests/file-based/dropbox/dropbox-api.spec.ts +++ b/packages/sync-providers/tests/file-based/dropbox/dropbox-api.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthFailSPError, + HttpNotOkAPIError, RemoteFileNotFoundAPIError, TooManyRequestsAPIError, UploadRevToMatchMismatchAPIError, @@ -571,6 +572,37 @@ describe('DropboxApi', () => { expect(body).toContain('grant_type=authorization_code'); }); + it('preserves response metadata when the token exchange fails', async () => { + const headers = new Headers(); + headers.set('X-Dropbox-Request-Id', 'dbx-request-123'); + const response = new Response('gateway timeout', { + status: 504, + statusText: 'Gateway Timeout', + headers, + }); + fetchSpy.mockResolvedValue(response); + + let thrown: HttpNotOkAPIError | undefined; + try { + await dropboxApi.getTokensFromAuthCode( + 'test-auth-code', + 'test-code-verifier', + null, + ); + } catch (error) { + if (error instanceof HttpNotOkAPIError) { + thrown = error; + } + } + + expect(thrown).toBeDefined(); + expect(thrown?.response).toBe(response); + expect(thrown?.message).toBe('HTTP 504 Gateway Timeout'); + expect(thrown?.response.headers.get('X-Dropbox-Request-Id')).toBe( + 'dbx-request-123', + ); + }); + it('should throw error for invalid token response', async () => { fetchSpy.mockResolvedValue({ ok: true, diff --git a/src/app/imex/sync/sync-wrapper.service.spec.ts b/src/app/imex/sync/sync-wrapper.service.spec.ts index 823757b1b8..fac9800aee 100644 --- a/src/app/imex/sync/sync-wrapper.service.spec.ts +++ b/src/app/imex/sync/sync-wrapper.service.spec.ts @@ -40,10 +40,14 @@ import { EncryptNoPasswordError, ForceUploadFailedError, ForceUploadPendingOpsError, + HttpNotOkAPIError, IncompleteRemoteOperationsError, } from '../../op-log/core/errors/sync-errors'; import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component'; import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const'; +import type { SyncProviderBase } from '../../op-log/sync-providers/provider.interface'; +import type { MatDialogRef } from '@angular/material/dialog'; +import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component'; describe('SyncWrapperService', () => { let service: SyncWrapperService; @@ -426,6 +430,79 @@ describe('SyncWrapperService', () => { }); }); + describe('configuredAuthForSyncProviderIfNecessary()', () => { + it('shows a temporary service error with the Dropbox request ID for HTTP 504', async () => { + const headers = new Headers(); + headers.set('X-Dropbox-Request-Id', 'dbx-request-123&"'); + const response = new Response('gateway timeout', { + status: 504, + statusText: 'Gateway Timeout', + headers, + }); + const provider = { + id: SyncProviderId.Dropbox, + isReady: jasmine.createSpy('isReady').and.resolveTo(false), + getAuthHelper: jasmine.createSpy('getAuthHelper').and.resolveTo({ + authUrl: 'https://www.dropbox.com/oauth2/authorize', + codeVerifier: 'code-verifier', + verifyCodeChallenge: jasmine + .createSpy('verifyCodeChallenge') + .and.rejectWith(new HttpNotOkAPIError(response, 'gateway timeout')), + }), + } as unknown as SyncProviderBase; + mockProviderManager.getProviderById.and.resolveTo(provider); + mockMatDialog.open.and.returnValue({ + afterClosed: () => of('auth-code'), + } as unknown as MatDialogRef); + + const result = await service.configuredAuthForSyncProviderIfNecessary( + SyncProviderId.Dropbox, + ); + + expect(result).toEqual({ wasConfigured: false }); + expect(mockSnackService.open).toHaveBeenCalledWith({ + msg: T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE, + translateParams: { + status: '504', + reference: '<b>dbx-request-123</b>&"', + }, + type: 'ERROR', + config: { duration: 0 }, + }); + }); + + it('shows a temporary service error without a reference when none is returned', async () => { + const response = new Response('internal server error', { + status: 500, + statusText: 'Internal Server Error', + }); + const provider = { + id: SyncProviderId.Dropbox, + isReady: jasmine.createSpy('isReady').and.resolveTo(false), + getAuthHelper: jasmine.createSpy('getAuthHelper').and.resolveTo({ + authUrl: 'https://www.dropbox.com/oauth2/authorize', + codeVerifier: 'code-verifier', + verifyCodeChallenge: jasmine + .createSpy('verifyCodeChallenge') + .and.rejectWith(new HttpNotOkAPIError(response, 'internal server error')), + }), + } as unknown as SyncProviderBase; + mockProviderManager.getProviderById.and.resolveTo(provider); + mockMatDialog.open.and.returnValue({ + afterClosed: () => of('auth-code'), + } as unknown as MatDialogRef); + + await service.configuredAuthForSyncProviderIfNecessary(SyncProviderId.Dropbox); + + expect(mockSnackService.open).toHaveBeenCalledWith({ + msg: T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE, + translateParams: { status: '500' }, + type: 'ERROR', + config: { duration: 0 }, + }); + }); + }); + describe('websocket integration', () => { it('should disconnect websocket when provider changes away from SuperSync', async () => { configSubject.next(createMockSyncConfig(SyncProviderId.WebDAV)); diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts index 514f87b3c9..34205dbb76 100644 --- a/src/app/imex/sync/sync-wrapper.service.ts +++ b/src/app/imex/sync/sync-wrapper.service.ts @@ -53,6 +53,7 @@ import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service'; import { T } from '../../t.const'; import { getSyncErrorStr } from './get-sync-error-str'; import { getErrorTxt } from '../../util/get-error-text'; +import { escapeHtml } from '../../util/escape-html'; import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component'; import { DialogConflictResolutionResult } from './sync.model'; import { DialogSyncConflictComponent } from './dialog-sync-conflict/dialog-sync-conflict.component'; @@ -1151,7 +1152,16 @@ export class SyncWrapperService { } catch (error) { SyncLog.err(`Failed to configure auth for provider ${providerId}:`, error); const httpErr = error instanceof HttpNotOkAPIError ? error : null; - const isTokenExchangeError = httpErr?.response?.status === 400; + const httpStatus = httpErr?.response.status; + const isTokenExchangeError = httpStatus === 400; + const isAuthServiceUnavailable = + httpStatus !== undefined && httpStatus >= 500 && httpStatus < 600; + // Dropbox recommends retaining this response ID for support investigations. + // https://developers.dropbox.com/error-handling-guide#logging + const providerRequestId = + providerId === SyncProviderId.Dropbox + ? httpErr?.response.headers.get('X-Dropbox-Request-Id') + : null; // A OneDrive token-exchange 400 is almost always a misconfigured // Microsoft Entra app registration (typically "Allow public client // flows" disabled), not a mistyped/expired code — the authorize step @@ -1165,6 +1175,14 @@ export class SyncWrapperService { translateParams = { error: httpErr?.detail || getErrorTxt(error) }; } else if (isTokenExchangeError) { msg = T.F.SYNC.S.INVALID_AUTH_CODE; + } else if (isAuthServiceUnavailable) { + translateParams = { status: String(httpStatus) }; + if (providerRequestId) { + msg = T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE; + translateParams.reference = escapeHtml(providerRequestId); + } else { + msg = T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE; + } } else { msg = T.F.SYNC.S.AUTH_SETUP_FAILED; } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 5226110a50..833ac64e03 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1636,6 +1636,9 @@ const T = { S: { ALREADY_IN_SYNC: 'F.SYNC.S.ALREADY_IN_SYNC', ARCHIVE_OPERATION_FAILED: 'F.SYNC.S.ARCHIVE_OPERATION_FAILED', + AUTH_SERVICE_UNAVAILABLE: 'F.SYNC.S.AUTH_SERVICE_UNAVAILABLE', + AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE: + 'F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE', AUTH_SETUP_FAILED: 'F.SYNC.S.AUTH_SETUP_FAILED', AUTH_TOKEN_REJECTED: 'F.SYNC.S.AUTH_TOKEN_REJECTED', BTN_CONFIGURE: 'F.SYNC.S.BTN_CONFIGURE', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 49b2b03fe2..5402dd9acd 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1589,7 +1589,9 @@ "S": { "ALREADY_IN_SYNC": "Already in sync", "ARCHIVE_OPERATION_FAILED": "Archive operation failed. Some cleanup may not have completed.", - "AUTH_SETUP_FAILED": "Failed to set up authentication. Please try again. If you're using the web app, make sure you're accessing it over HTTPS. Otherwise, please report this as a bug if it keeps happening.", + "AUTH_SERVICE_UNAVAILABLE": "The authentication service is temporarily unavailable (HTTP {{status}}). Please try again.", + "AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE": "The authentication service is temporarily unavailable (HTTP {{status}}). Please try again. Reference: {{reference}}.", + "AUTH_SETUP_FAILED": "Failed to set up authentication. Please try again. If it keeps happening, check your connection and report the error details.", "AUTH_TOKEN_REJECTED": "Sync token was rejected by the server: {{reason}}. Please get a new token.", "BTN_CONFIGURE": "Configure", "BTN_FORCE_OVERWRITE": "Force Overwrite",