fix(sync): clear stale auth credentials on auth errors to enable re-auth

When Dropbox tokens become invalid (e.g., after enabling 2FA), isReady()
still returns true because tokens are non-empty, preventing the re-auth
dialog from opening. Add clearAuthCredentials() to each sync provider
that clears auth fields while preserving non-auth config (encryptKey),
and call it in the auth error handler so the Configure button works.

Closes #6397
This commit is contained in:
Johannes Millan 2026-02-07 13:40:06 +01:00
parent 2cea4af601
commit 2e2dad8eb8
7 changed files with 128 additions and 2 deletions

View file

@ -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()),

View file

@ -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',

View file

@ -71,6 +71,13 @@ export class Dropbox implements SyncProviderServiceInterface<SyncProviderId.Drop
await this.privateCfg.setComplete(privateCfg);
}
async clearAuthCredentials(): Promise<void> {
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

View file

@ -56,6 +56,17 @@ export abstract class WebdavBaseProvider<
await this.privateCfg.setComplete(privateCfg as PrivateCfgByProviderId<T>);
}
async clearAuthCredentials(): Promise<void> {
const cfg = await this.privateCfg.load();
if (cfg?.userName || cfg?.password) {
await this.privateCfg.setComplete({
...cfg,
userName: '',
password: '',
} as PrivateCfgByProviderId<T>);
}
}
async getFileRev(
targetPath: string,
localRev: string | null,

View file

@ -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<void> {
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
*/

View file

@ -106,6 +106,12 @@ export interface SyncProviderServiceInterface<PID extends SyncProviderId> {
* @param privateCfg New configuration to store
*/
setPrivateCfg(privateCfg: PrivateCfgByProviderId<PID>): Promise<void>;
/**
* 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<void>;
}
/**

View file

@ -90,6 +90,18 @@ export class SuperSyncProvider
await this.privateCfg.setComplete(cfg);
}
async clearAuthCredentials(): Promise<void> {
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(