diff --git a/src/app/imex/sync/sync-wrapper.service.spec.ts b/src/app/imex/sync/sync-wrapper.service.spec.ts index 61abc26756..c1dbeaa96d 100644 --- a/src/app/imex/sync/sync-wrapper.service.spec.ts +++ b/src/app/imex/sync/sync-wrapper.service.spec.ts @@ -68,13 +68,20 @@ describe('SyncWrapperService', () => { mockProviderManager = jasmine.createSpyObj( 'SyncProviderManager', - ['getActiveProvider', 'setSyncStatus', 'setProviderConfig', 'getProviderById'], + [ + 'getActiveProvider', + 'setSyncStatus', + 'setProviderConfig', + 'getProviderById', + 'clearAuthCredentials', + ], { syncStatus$: of('SYNCED'), isProviderReady$: of(true), isSyncInProgress: false, }, ); + mockProviderManager.clearAuthCredentials.and.returnValue(Promise.resolve()); mockProviderManager.getActiveProvider.and.returnValue({ id: SyncProviderId.SuperSync, } as any); @@ -578,6 +585,61 @@ describe('SyncWrapperService', () => { ); }); + it('should call clearAuthCredentials on AuthFailSPError', async () => { + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.reject(new AuthFailSPError()), + ); + + await service.sync(); + + expect(mockProviderManager.clearAuthCredentials).toHaveBeenCalledWith( + SyncProviderId.SuperSync, + ); + }); + + it('should call clearAuthCredentials on MissingCredentialsSPError', async () => { + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.reject(new MissingCredentialsSPError('no token')), + ); + + await service.sync(); + + expect(mockProviderManager.clearAuthCredentials).toHaveBeenCalledWith( + SyncProviderId.SuperSync, + ); + }); + + it('should call clearAuthCredentials on MissingRefreshTokenAPIError', async () => { + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.reject(new MissingRefreshTokenAPIError()), + ); + + await service.sync(); + + expect(mockProviderManager.clearAuthCredentials).toHaveBeenCalledWith( + SyncProviderId.SuperSync, + ); + }); + + it('should still show snack when clearAuthCredentials throws', async () => { + mockProviderManager.clearAuthCredentials.and.returnValue( + Promise.reject(new Error('IndexedDB error')), + ); + mockSyncService.downloadRemoteOps.and.returnValue( + Promise.reject(new AuthFailSPError()), + ); + + const result = await service.sync(); + + expect(result).toBe('HANDLED_ERROR'); + expect(mockSnackService.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'ERROR', + actionFn: jasmine.any(Function), + }), + ); + }); + it('should handle SyncAlreadyInProgressError silently', async () => { mockSyncService.downloadRemoteOps.and.returnValue( Promise.reject(new SyncAlreadyInProgressError()), diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts index abf7d8fc1d..51bce0cc55 100644 --- a/src/app/imex/sync/sync-wrapper.service.ts +++ b/src/app/imex/sync/sync-wrapper.service.ts @@ -388,7 +388,15 @@ export class SyncWrapperService { error instanceof MissingRefreshTokenAPIError || error instanceof MissingCredentialsSPError ) { - // Credentials missing, invalid, or refresh token expired - user needs to (re-)configure + // Clear stale auth credentials so isReady() returns false and re-auth dialog opens + if (providerId) { + try { + await this._providerManager.clearAuthCredentials(providerId); + } catch (clearError) { + SyncLog.err('Failed to clear stale auth credentials:', clearError); + } + } + this._snackService.open({ msg: T.F.SYNC.S.INCOMPLETE_CFG, type: 'ERROR', diff --git a/src/app/op-log/sync-providers/file-based/dropbox/dropbox.ts b/src/app/op-log/sync-providers/file-based/dropbox/dropbox.ts index 1fb825f667..81290f3d0a 100644 --- a/src/app/op-log/sync-providers/file-based/dropbox/dropbox.ts +++ b/src/app/op-log/sync-providers/file-based/dropbox/dropbox.ts @@ -71,6 +71,13 @@ export class Dropbox implements SyncProviderServiceInterface { + const cfg = await this.privateCfg.load(); + if (cfg?.accessToken || cfg?.refreshToken) { + await this.privateCfg.setComplete({ ...cfg, accessToken: '', refreshToken: '' }); + } + } + /** * Gets the revision information for a file from Dropbox * @param targetPath Path to the target file diff --git a/src/app/op-log/sync-providers/file-based/webdav/webdav-base-provider.ts b/src/app/op-log/sync-providers/file-based/webdav/webdav-base-provider.ts index 181469f031..7e2487b7c7 100644 --- a/src/app/op-log/sync-providers/file-based/webdav/webdav-base-provider.ts +++ b/src/app/op-log/sync-providers/file-based/webdav/webdav-base-provider.ts @@ -56,6 +56,17 @@ export abstract class WebdavBaseProvider< await this.privateCfg.setComplete(privateCfg as PrivateCfgByProviderId); } + async clearAuthCredentials(): Promise { + const cfg = await this.privateCfg.load(); + if (cfg?.userName || cfg?.password) { + await this.privateCfg.setComplete({ + ...cfg, + userName: '', + password: '', + } as PrivateCfgByProviderId); + } + } + async getFileRev( targetPath: string, localRev: string | null, diff --git a/src/app/op-log/sync-providers/provider-manager.service.ts b/src/app/op-log/sync-providers/provider-manager.service.ts index 8a8a94bb3a..fdebc64db0 100644 --- a/src/app/op-log/sync-providers/provider-manager.service.ts +++ b/src/app/op-log/sync-providers/provider-manager.service.ts @@ -270,6 +270,26 @@ export class SyncProviderManager { } } + /** + * Clears authentication credentials for a provider while preserving non-auth config. + * Updates readiness state and config observable after clearing. + */ + async clearAuthCredentials(providerId: SyncProviderId): Promise { + const provider = this.getProviderById(providerId); + if (!provider?.clearAuthCredentials) { + return; + } + await provider.clearAuthCredentials(); + + if (this._activeProvider?.id === providerId) { + const ready = await provider.isReady(); + this._isProviderReady$.next(ready); + + const privateCfg = await provider.privateCfg.load(); + this._currentProviderPrivateCfg$.next({ providerId, privateCfg }); + } + } + /** * Sets the active sync provider */ diff --git a/src/app/op-log/sync-providers/provider.interface.ts b/src/app/op-log/sync-providers/provider.interface.ts index b3e796e199..86b3f37f72 100644 --- a/src/app/op-log/sync-providers/provider.interface.ts +++ b/src/app/op-log/sync-providers/provider.interface.ts @@ -106,6 +106,12 @@ export interface SyncProviderServiceInterface { * @param privateCfg New configuration to store */ setPrivateCfg(privateCfg: PrivateCfgByProviderId): Promise; + + /** + * Clears authentication credentials while preserving non-auth config (e.g., encryptKey). + * Called when auth errors occur to ensure re-auth flow can proceed. + */ + clearAuthCredentials?(): Promise; } /** diff --git a/src/app/op-log/sync-providers/super-sync/super-sync.ts b/src/app/op-log/sync-providers/super-sync/super-sync.ts index 658b39a047..3a17d59b8b 100644 --- a/src/app/op-log/sync-providers/super-sync/super-sync.ts +++ b/src/app/op-log/sync-providers/super-sync/super-sync.ts @@ -90,6 +90,18 @@ export class SuperSyncProvider await this.privateCfg.setComplete(cfg); } + async clearAuthCredentials(): Promise { + const cfg = await this.privateCfg.load(); + if (cfg?.accessToken) { + await this.privateCfg.setComplete({ + ...cfg, + accessToken: '', + refreshToken: undefined, + expiresAt: undefined, + }); + } + } + // === File Operations (Not supported - use operation sync instead) === async getFileRev(