diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1289bdf16..f719b02c2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -71,6 +71,16 @@ + + + + + + + + fetch remote-notification + CFBundleURLTypes + + + CFBundleURLSchemes + + com.super-productivity.app + + CFBundleURLName + OAuth Callback + + diff --git a/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.html b/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.html index 14497cfe6..748f99a34 100644 --- a/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.html +++ b/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.html @@ -3,7 +3,13 @@ -

{{ T.F.SYNC.D_AUTH_CODE.FOLLOW_LINK | translate }}

+ @if (!isNativePlatform) { +

{{ T.F.SYNC.D_AUTH_CODE.FOLLOW_LINK | translate }}

+ } + @if (isNativePlatform) { +

{{ T.F.SYNC.D_AUTH_CODE.FOLLOW_LINK_MOBILE | translate }}

+ } + open_in_new {{ T.F.SYNC.D_AUTH_CODE.GET_AUTH_CODE | translate }} -
-
- - {{ T.F.SYNC.D_AUTH_CODE.L_AUTH_CODE | translate }} - vpn_key - - + + @if (!isNativePlatform) { +
+
+ + {{ T.F.SYNC.D_AUTH_CODE.L_AUTH_CODE | translate }} + vpn_key + + + } + + @if (isNativePlatform) { +
+
+
+ + {{ T.F.SYNC.D_AUTH_CODE.WAITING_FOR_AUTH | translate }} +
+ }
@@ -35,14 +53,16 @@ > {{ T.G.CANCEL | translate }} - + @if (!isNativePlatform) { + + } diff --git a/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.scss b/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.scss index e69de29bb..4a15863f3 100644 --- a/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.scss +++ b/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.scss @@ -0,0 +1,5 @@ +.oauth-waiting { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.ts b/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.ts index b32a3c60d..cf407806c 100644 --- a/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.ts +++ b/src/app/imex/sync/dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogActions, @@ -13,6 +13,11 @@ import { MatFormField, MatLabel, MatPrefix } from '@angular/material/form-field' import { MatInput } from '@angular/material/input'; import { FormsModule } from '@angular/forms'; import { TranslatePipe } from '@ngx-translate/core'; +import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform'; +import { OAuthCallbackHandlerService } from '../oauth-callback-handler.service'; +import { Subscription } from 'rxjs'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { SnackService } from '../../../core/snack/snack.service'; @Component({ selector: 'dialog-get-and-enter-auth-code', @@ -32,11 +37,15 @@ import { TranslatePipe } from '@ngx-translate/core'; MatDialogActions, MatButton, TranslatePipe, + MatProgressSpinner, ], }) -export class DialogGetAndEnterAuthCodeComponent { +export class DialogGetAndEnterAuthCodeComponent implements OnDestroy { private _matDialogRef = inject>(MatDialogRef); + private _oauthCallbackHandler = inject(OAuthCallbackHandlerService); + private _snackService = inject(SnackService); + data = inject<{ providerName: string; url: string; @@ -45,8 +54,44 @@ export class DialogGetAndEnterAuthCodeComponent { T: typeof T = T; token?: string; + readonly isNativePlatform = IS_NATIVE_PLATFORM; + private _authCodeSub?: Subscription; + constructor() { this._matDialogRef.disableClose = true; + + // On mobile, listen for OAuth callback + if (this.isNativePlatform) { + this._authCodeSub = this._oauthCallbackHandler.authCodeReceived$.subscribe( + (data) => { + if (data.provider === 'dropbox') { + if (data.error) { + // Handle error from OAuth provider + const errorMsg = data.error_description || data.error; + this._snackService.open({ + type: 'ERROR', + msg: `Authentication failed: ${errorMsg}`, + }); + this.close(); + } else if (data.code) { + this.token = data.code; + this.close(this.token); + } else { + // Unexpected case - no code and no error + this._snackService.open({ + type: 'ERROR', + msg: 'Authentication failed: No authorization code received', + }); + this.close(); + } + } + }, + ); + } + } + + ngOnDestroy(): void { + this._authCodeSub?.unsubscribe(); } close(token?: string): void { diff --git a/src/app/imex/sync/oauth-callback-handler.service.spec.ts b/src/app/imex/sync/oauth-callback-handler.service.spec.ts new file mode 100644 index 000000000..7e3c9ecdf --- /dev/null +++ b/src/app/imex/sync/oauth-callback-handler.service.spec.ts @@ -0,0 +1,63 @@ +import { TestBed } from '@angular/core/testing'; +import { OAuthCallbackHandlerService } from './oauth-callback-handler.service'; + +describe('OAuthCallbackHandlerService', () => { + let service: OAuthCallbackHandlerService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(OAuthCallbackHandlerService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('_parseOAuthCallback', () => { + it('should extract auth code from valid URL', () => { + const url = 'com.super-productivity.app://oauth-callback?code=ABC123'; + const result = service['_parseOAuthCallback'](url); + + expect(result.code).toBe('ABC123'); + expect(result.provider).toBe('dropbox'); + expect(result.error).toBeUndefined(); + }); + + it('should extract error from callback URL', () => { + const url = + 'com.super-productivity.app://oauth-callback?error=access_denied&error_description=User%20denied%20access'; + const result = service['_parseOAuthCallback'](url); + + expect(result.code).toBeUndefined(); + expect(result.error).toBe('access_denied'); + expect(result.error_description).toBe('User denied access'); + expect(result.provider).toBe('dropbox'); + }); + + it('should handle URL without code or error', () => { + const url = 'com.super-productivity.app://oauth-callback'; + const result = service['_parseOAuthCallback'](url); + + expect(result.code).toBeUndefined(); + expect(result.error).toBeUndefined(); + expect(result.provider).toBe('dropbox'); + }); + + it('should handle malformed URL', () => { + const url = 'not-a-valid-url'; + const result = service['_parseOAuthCallback'](url); + + expect(result.error).toBe('parse_error'); + expect(result.error_description).toBe('Failed to parse OAuth callback URL'); + expect(result.provider).toBe('dropbox'); + }); + + it('should decode URL-encoded parameters', () => { + const url = + 'com.super-productivity.app://oauth-callback?error_description=Access%20was%20denied'; + const result = service['_parseOAuthCallback'](url); + + expect(result.error_description).toBe('Access was denied'); + }); + }); +}); diff --git a/src/app/imex/sync/oauth-callback-handler.service.ts b/src/app/imex/sync/oauth-callback-handler.service.ts new file mode 100644 index 000000000..d8f4c1df2 --- /dev/null +++ b/src/app/imex/sync/oauth-callback-handler.service.ts @@ -0,0 +1,84 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Subject } from 'rxjs'; +import { App, URLOpenListenerEvent } from '@capacitor/app'; +import { PluginListenerHandle } from '@capacitor/core'; +import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform'; +import { PFLog } from '../../core/log'; + +export interface OAuthCallbackData { + code?: string; + error?: string; + error_description?: string; + provider: 'dropbox'; +} + +@Injectable({ + providedIn: 'root', +}) +export class OAuthCallbackHandlerService implements OnDestroy { + private _authCodeReceived$ = new Subject(); + private _urlListenerHandle?: PluginListenerHandle; + + readonly authCodeReceived$ = this._authCodeReceived$.asObservable(); + + constructor() { + if (IS_NATIVE_PLATFORM) { + this._setupAppUrlListener(); + } + } + + ngOnDestroy(): void { + this._urlListenerHandle?.remove(); + this._authCodeReceived$.complete(); + } + + private async _setupAppUrlListener(): Promise { + this._urlListenerHandle = await App.addListener( + 'appUrlOpen', + (event: URLOpenListenerEvent) => { + PFLog.log('OAuthCallbackHandler: Received URL', event.url); + + if (event.url.startsWith('com.super-productivity.app://oauth-callback')) { + const callbackData = this._parseOAuthCallback(event.url); + + if (callbackData.code) { + PFLog.log('OAuthCallbackHandler: Extracted auth code'); + } else if (callbackData.error) { + PFLog.warn( + 'OAuthCallbackHandler: OAuth error', + callbackData.error, + callbackData.error_description, + ); + } else { + PFLog.warn('OAuthCallbackHandler: No auth code or error in URL', event.url); + } + + this._authCodeReceived$.next(callbackData); + } + }, + ); + } + + private _parseOAuthCallback(url: string): OAuthCallbackData { + try { + const urlObj = new URL(url); + const code = urlObj.searchParams.get('code'); + const error = urlObj.searchParams.get('error'); + const errorDescription = urlObj.searchParams.get('error_description'); + + return { + code: code || undefined, + error: error || undefined, + error_description: errorDescription || undefined, + provider: 'dropbox', + }; + } catch (e) { + PFLog.err('OAuthCallbackHandler: Failed to parse URL', url, e); + return { + error: 'parse_error', + error_description: 'Failed to parse OAuth callback URL', + provider: 'dropbox', + }; + } + } +} diff --git a/src/app/op-log/sync-providers/file-based/dropbox/dropbox-api.spec.ts b/src/app/op-log/sync-providers/file-based/dropbox/dropbox-api.spec.ts index c42576678..877607c6d 100644 --- a/src/app/op-log/sync-providers/file-based/dropbox/dropbox-api.spec.ts +++ b/src/app/op-log/sync-providers/file-based/dropbox/dropbox-api.spec.ts @@ -509,6 +509,7 @@ describe('DropboxApi', () => { const result = await dropboxApi.getTokensFromAuthCode( 'test-auth-code', 'test-code-verifier', + null, ); expect(result).toBeTruthy(); @@ -539,7 +540,7 @@ describe('DropboxApi', () => { ); await expectAsync( - dropboxApi.getTokensFromAuthCode('test-auth-code', 'test-code-verifier'), + dropboxApi.getTokensFromAuthCode('test-auth-code', 'test-code-verifier', null), ).toBeRejectedWithError('Dropbox: Invalid access token response'); }); }); diff --git a/src/app/op-log/sync-providers/file-based/dropbox/dropbox-api.ts b/src/app/op-log/sync-providers/file-based/dropbox/dropbox-api.ts index fdee837a0..78445aa10 100644 --- a/src/app/op-log/sync-providers/file-based/dropbox/dropbox-api.ts +++ b/src/app/op-log/sync-providers/file-based/dropbox/dropbox-api.ts @@ -323,23 +323,31 @@ export class DropboxApi { async getTokensFromAuthCode( authCode: string, codeVerifier: string, + redirectUri: string | null, ): Promise<{ accessToken: string; refreshToken: string; expiresAt: number; } | null> { try { + const bodyParams: Record = { + code: authCode, + grant_type: 'authorization_code', + client_id: this._appKey, + code_verifier: codeVerifier, + }; + + // Only include redirect_uri for mobile platforms + if (redirectUri) { + bodyParams.redirect_uri = redirectUri; + } + const response = await fetch('https://api.dropboxapi.com/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', }, - body: stringify({ - code: authCode, - grant_type: 'authorization_code', - client_id: this._appKey, - code_verifier: codeVerifier, - }), + body: stringify(bodyParams), }); if (!response.ok) { 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 0742a8a73..a91acbf3a 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 @@ -14,6 +14,7 @@ import { DropboxApi } from './dropbox-api'; import { generatePKCECodes } from './generate-pkce-codes'; import { SyncCredentialStore } from '../../credential-store.service'; import { SyncProviderPrivateCfgBase } from '../../../core/types/sync.types'; +import { IS_NATIVE_PLATFORM } from '../../../../util/is-native-platform'; const DROPBOX_AUTH_URL = 'https://www.dropbox.com/oauth2/authorize' as const; const PATH_NOT_FOUND_ERROR = 'path/not_found' as const; @@ -267,18 +268,30 @@ export class Dropbox implements SyncProviderServiceInterface { const { codeVerifier, codeChallenge } = await generatePKCECodes(128); - const authCodeUrl = + // Determine redirect URI based on platform (only for mobile) + const redirectUri = this._getRedirectUri(); + + let authCodeUrl = `${DROPBOX_AUTH_URL}` + `?response_type=code&client_id=${this._appKey}` + '&code_challenge_method=S256' + '&token_access_type=offline' + `&code_challenge=${codeChallenge}`; + // Only add redirect_uri for mobile platforms + if (redirectUri) { + authCodeUrl += `&redirect_uri=${encodeURIComponent(redirectUri)}`; + } + return { authUrl: authCodeUrl, codeVerifier, verifyCodeChallenge: async (authCode: string) => { - return (await this._api.getTokensFromAuthCode(authCode, codeVerifier)) as T; + return (await this._api.getTokensFromAuthCode( + authCode, + codeVerifier, + redirectUri, + )) as T; }, }; } @@ -292,6 +305,28 @@ export class Dropbox implements SyncProviderServiceInterface