diff --git a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html index b3a940436..b775a6474 100644 --- a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html +++ b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html @@ -11,7 +11,7 @@

- +
@@ -33,17 +33,29 @@

[routerLink]="[baseUrl() + '/addons']" data-test-addon-cancel-button > - + @if (addonTypeString() === AddonType.REDIRECT) { + + } @else { + + }

diff --git a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts index 26d157f23..6f6bcd880 100644 --- a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts +++ b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts @@ -146,6 +146,17 @@ export class ConnectConfiguredAddonComponent { addonTypeString = computed(() => getAddonTypeString(this.addon()) as AddonType); + redirectUrl = computed(() => { + const addon = this.addon(); + if (!addon || !addon.redirectUrl) { + return null; + } + const openURL = new URL(addon.redirectUrl); + openURL.searchParams.set('nodeIri', this.resourceUri()); + openURL.searchParams.set('userIri', this.addonsUserReference()[0]?.attributes.user_uri); + return openURL.toString(); + }); + readonly baseUrl = computed(() => { const currentUrl = this.router.url; return currentUrl.split('/addons')[0]; @@ -255,6 +266,22 @@ export class ConnectConfiguredAddonComponent { }); } + goToService() { + if (!this.redirectUrl()) return; + + const newWindow = window.open( + this.redirectUrl()!.toString(), + '_blank', + 'popup,width=600,height=600,scrollbars=yes,resizable=yes' + ); + if (newWindow) { + this.router.navigate([`${this.baseUrl()}/addons`]); + newWindow.focus(); + } else { + this.toastService.showError('addons.redirect.popUpError'); + } + } + private getDataForAccountCheck() { const addonType = this.addonTypeString(); const referenceId = this.userReferenceId(); diff --git a/src/app/features/project/project-addons/project-addons.component.ts b/src/app/features/project/project-addons/project-addons.component.ts index 5ad91d482..564163c60 100644 --- a/src/app/features/project/project-addons/project-addons.component.ts +++ b/src/app/features/project/project-addons/project-addons.component.ts @@ -47,6 +47,7 @@ import { GetConfiguredLinkAddons, GetConfiguredStorageAddons, GetLinkAddons, + GetRedirectAddons, GetStorageAddons, } from '@shared/stores/addons'; import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @@ -91,6 +92,7 @@ export class ProjectAddonsComponent implements OnInit { storageAddons = select(AddonsSelectors.getStorageAddons); citationAddons = select(AddonsSelectors.getCitationAddons); linkAddons = select(AddonsSelectors.getLinkAddons); + redirectAddons = select(AddonsSelectors.getRedirectAddons); configuredStorageAddons = select(AddonsSelectors.getConfiguredStorageAddons); configuredCitationAddons = select(AddonsSelectors.getConfiguredCitationAddons); configuredLinkAddons = select(AddonsSelectors.getConfiguredLinkAddons); @@ -100,6 +102,8 @@ export class ProjectAddonsComponent implements OnInit { isResourceReferenceLoading = select(AddonsSelectors.getAddonsResourceReferenceLoading); isStorageAddonsLoading = select(AddonsSelectors.getStorageAddonsLoading); isCitationAddonsLoading = select(AddonsSelectors.getCitationAddonsLoading); + isLinkAddonsLoading = select(AddonsSelectors.getLinkAddonsLoading); + isRedirectAddonsLoading = select(AddonsSelectors.getRedirectAddonsLoading); isConfiguredStorageAddonsLoading = select(AddonsSelectors.getConfiguredStorageAddonsLoading); isConfiguredCitationAddonsLoading = select(AddonsSelectors.getConfiguredCitationAddonsLoading); isConfiguredLinkAddonsLoading = select(AddonsSelectors.getConfiguredLinkAddonsLoading); @@ -108,6 +112,7 @@ export class ProjectAddonsComponent implements OnInit { this.isStorageAddonsLoading() || this.isCitationAddonsLoading() || this.isLinkAddonsLoading() || + this.isRedirectAddonsLoading() || this.isUserReferenceLoading() || this.isCurrentUserLoading() ); @@ -135,8 +140,6 @@ export class ProjectAddonsComponent implements OnInit { return categoryLoading || this.isResourceReferenceLoading() || this.isCurrentUserLoading(); }); - isLinkAddonsLoading = select(AddonsSelectors.getLinkAddonsLoading); - currentAddonsLoading = computed(() => { switch (this.selectedCategory()) { case AddonCategory.EXTERNAL_STORAGE_SERVICES: @@ -145,6 +148,8 @@ export class ProjectAddonsComponent implements OnInit { return this.isCitationAddonsLoading(); case AddonCategory.EXTERNAL_LINK_SERVICES: return this.isLinkAddonsLoading(); + case AddonCategory.EXTERNAL_REDIRECT_SERVICES: + return this.isRedirectAddonsLoading(); default: return this.isStorageAddonsLoading(); } @@ -158,6 +163,7 @@ export class ProjectAddonsComponent implements OnInit { getStorageAddons: GetStorageAddons, getCitationAddons: GetCitationAddons, getLinkAddons: GetLinkAddons, + getRedirectAddons: GetRedirectAddons, getConfiguredStorageAddons: GetConfiguredStorageAddons, getConfiguredCitationAddons: GetConfiguredCitationAddons, getConfiguredLinkAddons: GetConfiguredLinkAddons, @@ -221,6 +227,8 @@ export class ProjectAddonsComponent implements OnInit { return this.actions.getCitationAddons; case AddonCategory.EXTERNAL_LINK_SERVICES: return this.actions.getLinkAddons; + case AddonCategory.EXTERNAL_REDIRECT_SERVICES: + return this.actions.getRedirectAddons; default: return this.actions.getStorageAddons; } @@ -234,6 +242,8 @@ export class ProjectAddonsComponent implements OnInit { return this.citationAddons(); case AddonCategory.EXTERNAL_LINK_SERVICES: return this.linkAddons(); + case AddonCategory.EXTERNAL_REDIRECT_SERVICES: + return this.redirectAddons(); default: return this.storageAddons(); } diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.html b/src/app/shared/components/addons/addon-terms/addon-terms.component.html index fb204c88f..80a0cfb42 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.html +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.html @@ -1,24 +1,37 @@ - - - - - {{ 'settings.addons.connectAddon.table.function' | translate }} - - - {{ 'settings.addons.connectAddon.table.status' | translate }} - - - - - - {{ term.function }} - {{ term.status }} - - - +@if (isRedirectService()) { +

+ {{ 'settings.addons.connectAddon.redirectAddons.terms' | translate }} +

+ +} @else { + + + + + {{ 'settings.addons.connectAddon.table.function' | translate }} + + + {{ 'settings.addons.connectAddon.table.status' | translate }} + + + + + + {{ term.function }} + {{ term.status }} + + + +} diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts index 60ded4b2f..5138bd785 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ADDON_TERMS } from '@osf/shared/constants/addon-terms.const'; -import { isCitationAddon } from '@osf/shared/helpers/addon-type.helper'; +import { isCitationAddon, isRedirectAddon } from '@osf/shared/helpers/addon-type.helper'; import { AddonModel } from '@osf/shared/models/addons/addon.model'; import { AddonTerm } from '@osf/shared/models/addons/addon-utils.models'; @@ -12,12 +12,14 @@ import { OSFTestingModule } from '@testing/osf.testing.module'; jest.mock('@shared/helpers/addon-type.helper.ts', () => ({ isCitationAddon: jest.fn(), + isRedirectAddon: jest.fn(), })); describe('AddonTermsComponent', () => { let component: AddonTermsComponent; let fixture: ComponentFixture; const mockIsCitationAddon = isCitationAddon as jest.MockedFunction; + const mockIsRedirectAddon = isRedirectAddon as jest.MockedFunction; const mockAddon: AddonModel = MOCK_ADDON; beforeEach(async () => { @@ -29,6 +31,7 @@ describe('AddonTermsComponent', () => { component = fixture.componentInstance; mockIsCitationAddon.mockReturnValue(false); + mockIsRedirectAddon.mockReturnValue(false); }); it('should create', () => { @@ -212,4 +215,24 @@ describe('AddonTermsComponent', () => { expect(hasInfoTerm || hasWarningTerm || hasDangerTerm).toBe(true); }); + + it('should handle redirect terms correctly', () => { + const redirectAddon: AddonModel = { + ...mockAddon, + type: 'redirect', + }; + + mockIsRedirectAddon.mockReturnValue(true); + fixture.componentRef.setInput('addon', redirectAddon); + fixture.detectChanges(); + + const terms = component.terms(); + expect(terms).toEqual([]); + + const termsElement: HTMLElement = fixture.nativeElement; + expect(termsElement.querySelectorAll('tr').length).toBe(0); + expect(termsElement.querySelectorAll('p').length).toBe(1); + expect(termsElement.querySelectorAll('em').length).toBe(1); + expect(termsElement.textContent).toContain('settings.addons.connectAddon.redirectAddons.terms'); + }); }); diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts index 5945f03dd..464a1f5b4 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts @@ -6,7 +6,7 @@ import { NgClass } from '@angular/common'; import { Component, computed, input } from '@angular/core'; import { ADDON_TERMS } from '@osf/shared/constants/addon-terms.const'; -import { isCitationAddon } from '@osf/shared/helpers/addon-type.helper'; +import { isCitationAddon, isRedirectAddon } from '@osf/shared/helpers/addon-type.helper'; import { AddonModel } from '@shared/models/addons/addon.model'; import { AddonTerm } from '@shared/models/addons/addon-utils.models'; import { AuthorizedAccountModel } from '@shared/models/addons/authorized-account.model'; @@ -19,6 +19,9 @@ import { AuthorizedAccountModel } from '@shared/models/addons/authorized-account }) export class AddonTermsComponent { addon = input(null); + redirectUrl = input(null); + + isRedirectService = computed(() => isRedirectAddon(this.addon())); terms = computed(() => { const addon = this.addon(); if (!addon) { @@ -31,6 +34,9 @@ export class AddonTermsComponent { const supportedFeatures = addon.supportedFeatures || []; const provider = addon.providerName; const isCitationService = isCitationAddon(addon); + if (isRedirectAddon(addon)) { + return []; + } const relevantTerms = isCitationService ? ADDON_TERMS.filter((term) => term.citation) : ADDON_TERMS; diff --git a/src/app/shared/constants/addons-category-options.const.ts b/src/app/shared/constants/addons-category-options.const.ts index 0db918573..42232392b 100644 --- a/src/app/shared/constants/addons-category-options.const.ts +++ b/src/app/shared/constants/addons-category-options.const.ts @@ -14,4 +14,8 @@ export const ADDON_CATEGORY_OPTIONS: SelectOption[] = [ label: 'settings.addons.categories.linkedServices', value: AddonCategory.EXTERNAL_LINK_SERVICES, }, + { + label: 'settings.addons.categories.otherServices', + value: AddonCategory.EXTERNAL_REDIRECT_SERVICES, + }, ]; diff --git a/src/app/shared/enums/addon-type.enum.ts b/src/app/shared/enums/addon-type.enum.ts index 8b16ce88e..041fd556d 100644 --- a/src/app/shared/enums/addon-type.enum.ts +++ b/src/app/shared/enums/addon-type.enum.ts @@ -2,6 +2,7 @@ export enum AddonType { STORAGE = 'storage', CITATION = 'citation', LINK = 'link', + REDIRECT = 'redirect', // Redirect addons will not have authorized accounts or configured addons } export enum AuthorizedAccountType { diff --git a/src/app/shared/enums/addons-category.enum.ts b/src/app/shared/enums/addons-category.enum.ts index c08bb9358..7965c3eb3 100644 --- a/src/app/shared/enums/addons-category.enum.ts +++ b/src/app/shared/enums/addons-category.enum.ts @@ -2,4 +2,5 @@ export enum AddonCategory { EXTERNAL_STORAGE_SERVICES = 'external-storage-services', EXTERNAL_CITATION_SERVICES = 'external-citation-services', EXTERNAL_LINK_SERVICES = 'external-link-services', + EXTERNAL_REDIRECT_SERVICES = 'external-redirect-services', } diff --git a/src/app/shared/helpers/addon-type.helper.ts b/src/app/shared/helpers/addon-type.helper.ts index 2b4c65f11..b3a684027 100644 --- a/src/app/shared/helpers/addon-type.helper.ts +++ b/src/app/shared/helpers/addon-type.helper.ts @@ -35,6 +35,12 @@ export function isLinkAddon(addon: AddonModel | AuthorizedAccountModel | Configu ); } +export function isRedirectAddon(addon: AddonModel | AuthorizedAccountModel | ConfiguredAddonModel | null): boolean { + if (!addon) return false; + + return addon.type === AddonCategory.EXTERNAL_REDIRECT_SERVICES; +} + export function getAddonTypeString(addon: AddonModel | AuthorizedAccountModel | ConfiguredAddonModel | null): string { if (!addon) return ''; diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index c3a70ed48..93d1027c0 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -29,6 +29,7 @@ export class AddonMapper { credentialsFormat: response.attributes.credentials_format, providerName: response.attributes.display_name, iconUrl: response.attributes.icon_url, + redirectUrl: response.attributes.redirect_url, configurableApiRoot: response.attributes.configurable_api_root, }; } diff --git a/src/app/shared/models/addons/addon-json-api.models.ts b/src/app/shared/models/addons/addon-json-api.models.ts index 78c7a685f..4479322c1 100644 --- a/src/app/shared/models/addons/addon-json-api.models.ts +++ b/src/app/shared/models/addons/addon-json-api.models.ts @@ -10,6 +10,7 @@ export interface AddonGetResponseJsonApi { credentials_format: string; wb_key: string; icon_url: string; + redirect_url?: string; configurable_api_root: boolean; [key: string]: unknown; }; diff --git a/src/app/shared/models/addons/addon.model.ts b/src/app/shared/models/addons/addon.model.ts index 4f6a7c458..44dc06871 100644 --- a/src/app/shared/models/addons/addon.model.ts +++ b/src/app/shared/models/addons/addon.model.ts @@ -14,4 +14,5 @@ export interface AddonModel { credentialsAvailable?: boolean; supportedResourceTypes?: string[]; wbKey?: string; + redirectUrl?: string; } diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index bb37a0fd6..17a9c852a 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -16,6 +16,10 @@ export class GetLinkAddons { static readonly type = '[Addons] Get Link Addons'; } +export class GetRedirectAddons { + static readonly type = '[Addons] Get Other Addons'; +} + export class GetAuthorizedStorageAddons { static readonly type = '[Addons] Get Authorized Storage Addons'; diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 4e795944f..082a40348 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -13,6 +13,7 @@ export interface AddonsStateModel { storageAddons: AsyncStateModel; citationAddons: AsyncStateModel; linkAddons: AsyncStateModel; + redirectAddons: AsyncStateModel; authorizedStorageAddons: AsyncStateModel; authorizedCitationAddons: AsyncStateModel; authorizedLinkAddons: AsyncStateModel; @@ -44,6 +45,11 @@ export const ADDONS_DEFAULTS: AddonsStateModel = { isLoading: false, error: null, }, + redirectAddons: { + data: [], + isLoading: false, + error: null, + }, authorizedStorageAddons: { data: [], isLoading: false, diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index bd8158094..20247e7e7 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -51,6 +51,16 @@ export class AddonsSelectors { return state.linkAddons.isLoading; } + @Selector([AddonsState]) + static getRedirectAddons(state: AddonsStateModel): AddonModel[] { + return state.redirectAddons.data; + } + + @Selector([AddonsState]) + static getRedirectAddonsLoading(state: AddonsStateModel): boolean { + return state.redirectAddons.isLoading; + } + @Selector([AddonsState]) static getAuthorizedStorageAddons(state: AddonsStateModel): AuthorizedAccountModel[] { return state.authorizedStorageAddons.data; diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index 9f28071f5..b3866375f 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -30,6 +30,7 @@ import { GetConfiguredLinkAddons, GetConfiguredStorageAddons, GetLinkAddons, + GetRedirectAddons, GetStorageAddons, UpdateAuthorizedAddon, UpdateConfiguredAddon, @@ -116,6 +117,30 @@ export class AddonsState { ); } + @Action(GetRedirectAddons) + getRedirectAddons(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ + redirectAddons: { + ...state.redirectAddons, + isLoading: true, + }, + }); + + return this.addonsService.getAddons(AddonType.REDIRECT).pipe( + tap((addons) => { + ctx.patchState({ + redirectAddons: { + data: addons, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'redirectAddons', error)) + ); + } + @Action(GetAuthorizedStorageAddons) getAuthorizedStorageAddons(ctx: StateContext, action: GetAuthorizedStorageAddons) { const state = ctx.getState(); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 095792668..fef2287cc 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1693,7 +1693,8 @@ "categories": { "additionalService": "Additional Storage", "citationManager": "Citation Manager", - "linkedServices": "Linked Services" + "linkedServices": "Linked Services", + "otherServices": "Other Services" }, "toast": { "updateSuccess": "Successfully updated {{addonName}} add-on configuration", @@ -1733,6 +1734,12 @@ "function": "Function", "status": "Status" }, + "redirectAddons": { + "terms": "Clicking the button below will redirect you outside of OSF. You will need to follow that service's permissions to continue.", + "tip": "Tip: if the page does not open, please disable your browser's pop-up blocker and try again. Or click on this link to go to {{serviceName}}.", + "popupError": "If you are having trouble with the pop-up window, please try clicking the link above to connect your account:", + "goToService": "Go to {{serviceName}}" + }, "confirmAccount": "Confirm account", "connectAccount": "Connect following account: {{accountName}}", "configure": "Configure",