diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index 0ca0b649a..e2b1288a0 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -204,7 +204,7 @@ export class DashboardComponent implements OnInit { closable: true, }) .onClose.pipe( - filter((result) => result.project.id), + filter((result) => result?.project.id), tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)), takeUntilDestroyed(this.destroyRef) ) diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 63ee108d7..3f392c093 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -338,7 +338,7 @@ export class MyProjectsComponent implements OnInit { closable: true, }) .onClose.pipe( - filter((result) => result.project.id), + filter((result) => result?.project.id), tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)), takeUntilDestroyed(this.destroyRef) ) diff --git a/src/app/features/project/addons/addons.component.html b/src/app/features/project/addons/addons.component.html index 8ebdf9cfb..78a7e2085 100644 --- a/src/app/features/project/addons/addons.component.html +++ b/src/app/features/project/addons/addons.component.html @@ -2,10 +2,10 @@
diff --git a/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html index 0a296e7e6..b24357152 100644 --- a/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html +++ b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html @@ -7,11 +7,13 @@ severity="info" (click)="dialogRef.close()" [disabled]="isSubmitting()" + data-test-addon-cancel-button /> diff --git a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html index fcf990484..3ad785625 100644 --- a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html @@ -31,6 +31,7 @@

severity="info" class="w-10rem btn-full-width" [routerLink]="[baseUrl() + '/addons']" + data-test-addon-cancel-button >

@@ -57,6 +59,7 @@

{{ loginOrChooseAccountText() }}

severity="info" class="w-7rem btn-full-width" (click)="activateCallback(AddonStepperValue.TERMS)" + data-test-addon-back-button > @@ -64,12 +67,14 @@

{{ loginOrChooseAccountText() }}

@@ -99,12 +104,14 @@

{{ 'settings.addons.connectAddon.chooseExistingAccount' | trans [label]="'settings.addons.form.buttons.back' | translate" severity="info" (onClick)="activateCallback(AddonStepperValue.CHOOSE_CONNECTION)" + data-test-addon-back-button > @@ -166,6 +173,7 @@

severity="info" class="w-7rem btn-full-width" [routerLink]="[baseUrl() + '/addons']" + data-test-addon-back-button > diff --git a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts index ad3b7ecfe..9de274a63 100644 --- a/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts @@ -9,7 +9,7 @@ import { RadioButtonModule } from 'primeng/radiobutton'; import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; import { TableModule } from 'primeng/table'; -import { Component, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, computed, DestroyRef, inject, signal, viewChild } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -26,7 +26,13 @@ import { } from '@shared/components/addons'; import { AddonServiceNames } from '@shared/enums'; import { AddonModel, AddonTerm, AuthorizedAddonRequestJsonApi } from '@shared/models'; -import { AddonDialogService, AddonFormService, AddonOperationInvocationService, ToastService } from '@shared/services'; +import { + AddonDialogService, + AddonFormService, + AddonOAuthService, + AddonOperationInvocationService, + ToastService, +} from '@shared/services'; import { AddonsSelectors, CreateAddonOperationInvocation, @@ -71,6 +77,8 @@ export class ConnectConfiguredAddonComponent { private addonDialogService = inject(AddonDialogService); private addonFormService = inject(AddonFormService); private operationInvocationService = inject(AddonOperationInvocationService); + private oauthService = inject(AddonOAuthService); + private destroyRef = inject(DestroyRef); private router = inject(Router); private route = inject(ActivatedRoute); private selectedAccount = signal({} as AuthorizedAccountModel); @@ -157,6 +165,10 @@ export class ConnectConfiguredAddonComponent { this.router.navigate([`${this.baseUrl()}/addons`]); } this.addon.set(addon); + + this.destroyRef.onDestroy(() => { + this.oauthService.stopOAuthTracking(); + }); } handleCreateConfiguredAddon() { @@ -197,16 +209,11 @@ export class ConnectConfiguredAddonComponent { this.actions.createAuthorizedAddon(payload, this.addonTypeString()).subscribe({ complete: () => { - const addon = this.createdAuthorizedAddon(); - if (addon?.authUrl) { - this.addonAuthUrl.set(addon.authUrl); - window.open(addon.authUrl, '_blank'); - this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + const createdAddon = this.createdAuthorizedAddon(); + if (createdAddon?.authUrl) { + this.startOauthFlow(createdAddon); } else { - this.router.navigate([`${this.baseUrl()}/addons`]); - this.toastService.showSuccess('settings.addons.toast.createSuccess', { - addonName: AddonServiceNames[addon?.externalServiceName as keyof typeof AddonServiceNames], - }); + this.refreshAccountsForOAuth(); } }, }); @@ -331,4 +338,36 @@ export class ConnectConfiguredAddonComponent { this.selectedStorageItemUrl.set(''); this.selectedResourceType.set(''); } + + private startOauthFlow(createdAddon: AuthorizedAccountModel): void { + this.addonAuthUrl.set(createdAddon.authUrl!); + window.open(createdAddon.authUrl!, '_blank'); + this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + + this.oauthService.startOAuthTracking(createdAddon, this.addonTypeString(), { + onSuccess: () => { + this.refreshAccountsForOAuth(); + }, + }); + } + + private refreshAccountsForOAuth(): void { + const requiredData = this.getDataForAccountCheck(); + if (!requiredData) return; + + const { addonType, referenceId, currentAddon } = requiredData; + const addonConfig = this.getAddonConfig(addonType, referenceId); + + if (!addonConfig) return; + + addonConfig.getAddons().subscribe({ + complete: () => { + const authorizedAddons = addonConfig.getAuthorizedAddons(); + const matchingAddons = this.findMatchingAddons(authorizedAddons, currentAddon); + this.currentAuthorizedAddonAccounts.set(matchingAddons); + + this.stepper()?.value.set(ProjectAddonsStepperValue.CHOOSE_ACCOUNT); + }, + }); + } } diff --git a/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html index e4447ba44..7fdcbf053 100644 --- a/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html +++ b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html @@ -14,6 +14,7 @@ severity="info" (click)="dialogRef.close()" [disabled]="isSubmitting()" + data-test-addon-cancel-button /> diff --git a/src/app/features/settings/addons/addons.component.html b/src/app/features/settings/addons/addons.component.html index d6ea70db0..35f135ad3 100644 --- a/src/app/features/settings/addons/addons.component.html +++ b/src/app/features/settings/addons/addons.component.html @@ -2,11 +2,11 @@
diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html index 84b2feff5..4d23698e6 100644 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html @@ -31,11 +31,13 @@

severity="info" class="w-10rem btn-full-width" routerLink="/settings/addons" + data-test-addon-cancel-button >

diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts index 21ecad24b..d30efac78 100644 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts @@ -6,7 +6,7 @@ import { Button } from 'primeng/button'; import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; import { TableModule } from 'primeng/table'; -import { Component, computed, effect, inject, signal, viewChild } from '@angular/core'; +import { Component, computed, DestroyRef, effect, inject, signal, viewChild } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; @@ -15,7 +15,7 @@ import { AddonServiceNames, AddonType, ProjectAddonsStepperValue } from '@osf/sh import { getAddonTypeString, isAuthorizedAddon } from '@osf/shared/helpers'; import { AddonSetupAccountFormComponent, AddonTermsComponent } from '@shared/components/addons'; import { AddonModel, AddonTerm, AuthorizedAccountModel, AuthorizedAddonRequestJsonApi } from '@shared/models'; -import { ToastService } from '@shared/services'; +import { AddonOAuthService, ToastService } from '@shared/services'; import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@shared/stores/addons'; @Component({ @@ -40,6 +40,8 @@ import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@ export class ConnectAddonComponent { private readonly router = inject(Router); private readonly toastService = inject(ToastService); + private readonly oauthService = inject(AddonOAuthService); + private readonly destroyRef = inject(DestroyRef); readonly stepper = viewChild(Stepper); readonly AddonType = AddonType; @@ -52,26 +54,17 @@ export class ConnectAddonComponent { addonsUserReference = select(AddonsSelectors.getAddonsUserReference); createdAddon = select(AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon); isCreatingAuthorizedAddon = select(AddonsSelectors.getCreatedOrUpdatedStorageAddonSubmitting); - isAuthorized = computed(() => { - return isAuthorizedAddon(this.addon()); - }); - addonTypeString = computed(() => { - return getAddonTypeString(this.addon()); - }); + + isAuthorized = computed(() => isAuthorizedAddon(this.addon())); + addonTypeString = computed(() => getAddonTypeString(this.addon())); + userReferenceId = computed(() => this.addonsUserReference()[0]?.id); + baseUrl = computed(() => this.router.url.split('/addons')[0]); actions = createDispatchMap({ createAuthorizedAddon: CreateAuthorizedAddon, updateAuthorizedAddon: UpdateAuthorizedAddon, }); - readonly userReferenceId = computed(() => { - return this.addonsUserReference()[0]?.id; - }); - readonly baseUrl = computed(() => { - const currentUrl = this.router.url; - return currentUrl.split('/addons')[0]; - }); - constructor() { const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAccountModel; if (!addon) { @@ -84,28 +77,47 @@ export class ConnectAddonComponent { this.stepper()?.value.set(ProjectAddonsStepperValue.SETUP_NEW_ACCOUNT); } }); + + this.destroyRef.onDestroy(() => { + this.oauthService.stopOAuthTracking(); + }); } handleConnectAuthorizedAddon(payload: AuthorizedAddonRequestJsonApi): void { if (!this.addon()) return; - (!this.isAuthorized() - ? this.actions.createAuthorizedAddon(payload, this.addonTypeString()) - : this.actions.updateAuthorizedAddon(payload, this.addonTypeString(), this.addon()!.id) - ).subscribe({ + const action = this.isAuthorized() + ? this.actions.updateAuthorizedAddon(payload, this.addonTypeString(), this.addon()!.id) + : this.actions.createAuthorizedAddon(payload, this.addonTypeString()); + + action.subscribe({ complete: () => { const createdAddon = this.createdAddon(); if (createdAddon?.authUrl) { - this.addonAuthUrl.set(createdAddon.authUrl); - window.open(createdAddon.authUrl, '_blank'); - this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + this.startOauthFlow(createdAddon); } else { - this.router.navigate([`${this.baseUrl()}/addons`]); - this.toastService.showSuccess('settings.addons.toast.createSuccess', { - addonName: AddonServiceNames[createdAddon?.externalServiceName as keyof typeof AddonServiceNames], - }); + this.showSuccessAndRedirect(createdAddon); } }, }); } + + private startOauthFlow(createdAddon: AuthorizedAccountModel): void { + this.addonAuthUrl.set(createdAddon.authUrl!); + window.open(createdAddon.authUrl!, '_blank'); + this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + + this.oauthService.startOAuthTracking(createdAddon, this.addonTypeString(), { + onSuccess: (updatedAddon) => { + this.showSuccessAndRedirect(updatedAddon); + }, + }); + } + + private showSuccessAndRedirect(createdAddon: AuthorizedAccountModel | null): void { + this.router.navigate([`${this.baseUrl()}/addons`]); + this.toastService.showSuccess('settings.addons.toast.createSuccess', { + addonName: AddonServiceNames[createdAddon?.externalServiceName as keyof typeof AddonServiceNames], + }); + } } diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.html b/src/app/shared/components/addons/addon-card/addon-card.component.html index c1cea7502..189987acb 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.html +++ b/src/app/shared/components/addons/addon-card/addon-card.component.html @@ -5,12 +5,13 @@ class="addon-image" alt="Addon card image" [src]="'assets/icons/addons/' + card()!.externalServiceName + '.svg'" + data-test-addon-card-logo /> }
-

{{ card()?.displayName }}

+

{{ card()?.displayName }}

@if (showDangerButton()) { @@ -18,6 +19,7 @@

{{ card()?.displayName }}

[label]="'settings.addons.form.buttons.disable' | translate" severity="danger" (onClick)="showDisableDialog()" + data-test-addon-card-disconnect > } diff --git a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html index dbcb2445c..9f27832d6 100644 --- a/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html +++ b/src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html @@ -13,9 +13,10 @@

{{ 'settings.addons.form.fields.secretKey' | translate }}

- @@ -32,9 +33,10 @@

{{ 'settings.addons.form.fields.apiToken' | translate }}

- @@ -79,9 +81,10 @@

{{ 'settings.addons.form.fields.personalAccessToken' | translate }}

- @@ -104,6 +107,7 @@

class="w-10rem btn-full-width" (onClick)="handleBack()" [disabled]="isSubmitting()" + data-test-addon-back-button > type="submit" [disabled]="!isFormValid || isSubmitting()" [loading]="isSubmitting()" + data-test-addon-authorize-button > } @else { class="w-10rem btn-full-width" routerLink="/settings/addons" [disabled]="isSubmitting()" + data-test-addon-cancel-button > [label]="'settings.addons.form.buttons.reconnect' | translate" type="submit" [disabled]="!isFormValid || isSubmitting()" + data-test-addon-reconnect-button > }

diff --git a/src/app/shared/models/addons/index.ts b/src/app/shared/models/addons/index.ts index 5d227d4cf..e0ae0ab42 100644 --- a/src/app/shared/models/addons/index.ts +++ b/src/app/shared/models/addons/index.ts @@ -3,6 +3,7 @@ export * from './addon-terms.model'; export * from './addons.models'; export * from './authorized-account.model'; export * from './configured-addon.model'; +export * from './oauth-callbacks.model'; export * from './operation-invocation.models'; export * from './operation-invoke-data.model'; export * from './strorage-item.model'; diff --git a/src/app/shared/models/addons/oauth-callbacks.model.ts b/src/app/shared/models/addons/oauth-callbacks.model.ts new file mode 100644 index 000000000..2d72e41ad --- /dev/null +++ b/src/app/shared/models/addons/oauth-callbacks.model.ts @@ -0,0 +1,7 @@ +import { AuthorizedAccountModel } from '@shared/models'; + +export interface OAuthCallbacks { + onSuccess: (addon: AuthorizedAccountModel) => void; + onError?: () => void; + onCleanup?: () => void; +} diff --git a/src/app/shared/services/addons/addon-form.service.ts b/src/app/shared/services/addons/addon-form.service.ts index a2e0c79da..33a534f1c 100644 --- a/src/app/shared/services/addons/addon-form.service.ts +++ b/src/app/shared/services/addons/addon-form.service.ts @@ -86,7 +86,7 @@ export class AddonFormService { credentials, initiate_oauth: initiateOAuth, auth_url: null, - credentials_available: false, + credentials_available: ('credentialsAvailable' in addon && addon.credentialsAvailable) || false, }, relationships: { account_owner: { diff --git a/src/app/shared/services/addons/addon-oauth.service.ts b/src/app/shared/services/addons/addon-oauth.service.ts new file mode 100644 index 000000000..2d341e032 --- /dev/null +++ b/src/app/shared/services/addons/addon-oauth.service.ts @@ -0,0 +1,105 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { AuthorizedAccountModel, OAuthCallbacks } from '@shared/models'; +import { AddonsSelectors, DeleteAuthorizedAddon, GetAuthorizedStorageOauthToken } from '@shared/stores/addons'; + +@Injectable({ + providedIn: 'root', +}) +export class AddonOAuthService { + private destroyRef = inject(DestroyRef); + + private pendingOauth = signal(false); + private createdAddon = signal(null); + private addonTypeString = signal(''); + private callbacks = signal(null); + + private authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); + + private actions = createDispatchMap({ + getAuthorizedStorageOauthToken: GetAuthorizedStorageOauthToken, + deleteAuthorizedAddon: DeleteAuthorizedAddon, + }); + + private boundOnVisibilityChange = this.onVisibilityChange.bind(this); + + startOAuthTracking(createdAddon: AuthorizedAccountModel, addonTypeString: string, callbacks: OAuthCallbacks): void { + this.pendingOauth.set(true); + this.createdAddon.set(createdAddon); + this.addonTypeString.set(addonTypeString); + this.callbacks.set(callbacks); + + document.addEventListener('visibilitychange', this.boundOnVisibilityChange); + } + + stopOAuthTracking(): void { + this.cleanupService(); + } + + private onVisibilityChange(): void { + if (document.visibilityState === 'visible' && this.pendingOauth()) { + this.checkOauthSuccess(); + } + } + + private checkOauthSuccess(): void { + const addon = this.createdAddon(); + if (!addon?.id) return; + + this.actions + .getAuthorizedStorageOauthToken(addon.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + complete: () => { + const updatedAddon = this.authorizedStorageAddons().find( + (storageAddon: AuthorizedAccountModel) => storageAddon.id === addon.id + ); + + if (updatedAddon?.credentialsAvailable && updatedAddon?.authUrl === null) { + this.completeOauthFlow(updatedAddon); + } + }, + error: () => { + this.completeOauthFlow(); + }, + }); + } + + private completeOauthFlow(updatedAddon?: AuthorizedAccountModel): void { + this.pendingOauth.set(false); + document.removeEventListener('visibilitychange', this.boundOnVisibilityChange); + + if (updatedAddon && this.callbacks()?.onSuccess) { + const originalAddon = this.createdAddon(); + const addonForCallback = { + ...updatedAddon, + externalServiceName: originalAddon?.externalServiceName || updatedAddon.externalServiceName, + }; + this.callbacks()?.onSuccess(addonForCallback); + } + + this.resetServiceData(); + } + + private cleanupService(): void { + this.cleanupIncompleteOAuthAddon(); + document.removeEventListener('visibilitychange', this.boundOnVisibilityChange); + this.resetServiceData(); + } + + private cleanupIncompleteOAuthAddon(): void { + const addon = this.createdAddon(); + if (addon?.id && this.pendingOauth() && !addon.credentialsAvailable) { + this.actions.deleteAuthorizedAddon(addon.id, this.addonTypeString()).subscribe(); + } + } + + private resetServiceData(): void { + this.createdAddon.set(null); + this.addonTypeString.set(''); + this.callbacks.set(null); + } +} diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index c4bb2cd0a..0a4c0c047 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -68,7 +68,7 @@ export class AddonsService { getAuthorizedAddons(addonType: string, referenceId: string): Observable { const params = { - [`fields[external-${addonType}-services]`]: 'external_service_name', + [`fields[external-${addonType}-services]`]: 'external_service_name,credentials_format', }; return this.jsonApiService .get< diff --git a/src/app/shared/services/addons/index.ts b/src/app/shared/services/addons/index.ts index 2f53913ee..b3a89de0d 100644 --- a/src/app/shared/services/addons/index.ts +++ b/src/app/shared/services/addons/index.ts @@ -1,4 +1,5 @@ export { AddonDialogService } from './addon-dialog.service'; export { AddonFormService } from './addon-form.service'; +export { AddonOAuthService } from './addon-oauth.service'; export { AddonOperationInvocationService } from './addon-operation-invocation.service'; export { AddonsService } from './addons.service';