mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-28 10:13:52 +00:00
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:
parent
2cea4af601
commit
2e2dad8eb8
7 changed files with 128 additions and 2 deletions
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue