diff --git a/CHANGELOG.md b/CHANGELOG.md index 6716cc87903..cb80936e49d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [25.04.0] - 2025-02-24 +### Added +- Addons and gravyvalet + ## [25.03.0] - 2025-01-27 ### Added - Preprint DOI Versioning diff --git a/app/adapters/addon-operation-invocation.ts b/app/adapters/addon-operation-invocation.ts new file mode 100644 index 00000000000..f2df691d23e --- /dev/null +++ b/app/adapters/addon-operation-invocation.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class AddonOperationInvocationAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'addon-operation-invocation': AddonOperationInvocationAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/addon-service.ts b/app/adapters/addon-service.ts new file mode 100644 index 00000000000..da0d990f4bd --- /dev/null +++ b/app/adapters/addon-service.ts @@ -0,0 +1,18 @@ +import JSONAPIAdapter from '@ember-data/adapter/json-api'; +import config from 'ember-osf-web/config/environment'; + +const { addonServiceUrl } = config.OSF; + +export const addonServiceNamespace = 'v1'; +export const addonServiceAPIUrl = `${addonServiceUrl}${addonServiceNamespace}/`; + +export default class AddonServiceAdapter extends JSONAPIAdapter { + host = addonServiceUrl.replace(/\/$/, ''); // Remove trailing slash to avoid // in URLs + namespace = addonServiceNamespace; + + ajaxOptions(url: string, type: string, options?: any): object { + const _ajaxopts: any = super.ajaxOptions(url, type, options); + _ajaxopts.credentials = 'include'; + return _ajaxopts; + } +} diff --git a/app/adapters/authorized-citation-account.ts b/app/adapters/authorized-citation-account.ts new file mode 100644 index 00000000000..788fb503f9f --- /dev/null +++ b/app/adapters/authorized-citation-account.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class AuthorizedCitationAccountAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'authorized-citation-account': AuthorizedCitationAccountAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/authorized-computing-account.ts b/app/adapters/authorized-computing-account.ts new file mode 100644 index 00000000000..a63ef0e62e9 --- /dev/null +++ b/app/adapters/authorized-computing-account.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class AuthorizedComputingAccountAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'authorized-computing-account': AuthorizedComputingAccountAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/authorized-storage-account.ts b/app/adapters/authorized-storage-account.ts new file mode 100644 index 00000000000..a76c3b39071 --- /dev/null +++ b/app/adapters/authorized-storage-account.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class AuthorizedStorageAccountAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'authorized-storage-account': AuthorizedStorageAccountAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/configured-citation-addon.ts b/app/adapters/configured-citation-addon.ts new file mode 100644 index 00000000000..11b125b89fe --- /dev/null +++ b/app/adapters/configured-citation-addon.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class ConfiguredCitationAddonAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'configured-citation-addon': ConfiguredCitationAddonAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/configured-computing-addon.ts b/app/adapters/configured-computing-addon.ts new file mode 100644 index 00000000000..ab6b5f03667 --- /dev/null +++ b/app/adapters/configured-computing-addon.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class ConfiguredComputingAddonAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'configured-computing-addon': ConfiguredComputingAddonAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/configured-storage-addon.ts b/app/adapters/configured-storage-addon.ts new file mode 100644 index 00000000000..d208d891ce8 --- /dev/null +++ b/app/adapters/configured-storage-addon.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class ConfiguredStorageAddonAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'configured-storage-addon': ConfiguredStorageAddonAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/external-citation-service.ts b/app/adapters/external-citation-service.ts new file mode 100644 index 00000000000..fc962d39f87 --- /dev/null +++ b/app/adapters/external-citation-service.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class ExternalCitationServiceAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'external-citation-service': ExternalCitationServiceAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/external-computing-service.ts b/app/adapters/external-computing-service.ts new file mode 100644 index 00000000000..0ce1b208878 --- /dev/null +++ b/app/adapters/external-computing-service.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class ExternalComputingServiceAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'external-computing-service': ExternalComputingServiceAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/external-storage-service.ts b/app/adapters/external-storage-service.ts new file mode 100644 index 00000000000..960461d7a0c --- /dev/null +++ b/app/adapters/external-storage-service.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class ExternalStorageServiceAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'external-storage-service': ExternalStorageServiceAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/resource-reference.ts b/app/adapters/resource-reference.ts new file mode 100644 index 00000000000..3119eac56e1 --- /dev/null +++ b/app/adapters/resource-reference.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class ResourceReferenceAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'resource-reference': ResourceReferenceAdapter; + } // eslint-disable-line semi +} diff --git a/app/adapters/user-reference.ts b/app/adapters/user-reference.ts new file mode 100644 index 00000000000..470e51850f4 --- /dev/null +++ b/app/adapters/user-reference.ts @@ -0,0 +1,10 @@ +import AddonServiceAdapter from './addon-service'; + +export default class UserReferenceAdapter extends AddonServiceAdapter { +} + +declare module 'ember-data/types/registries/adapter' { + export default interface AdapterRegistry { + 'user-reference': UserReferenceAdapter; + } // eslint-disable-line semi +} diff --git a/app/config/environment.d.ts b/app/config/environment.d.ts index 2f955b0246c..bb50a888a88 100644 --- a/app/config/environment.d.ts +++ b/app/config/environment.d.ts @@ -107,10 +107,10 @@ declare const config: { donateUrl: string; renderUrl: string; mfrUrl: string; + addonServiceUrl: string; waterbutlerUrl: string; helpUrl: string; shareBaseUrl: string; - shareApiUrl: string; shareSearchUrl: string; devMode: boolean; cookieDomain: string; @@ -192,6 +192,7 @@ declare const config: { homePageHeroTextVersionB: string; }; storageI18n: string; + gravyWaffle: string; enableInactiveSchemas: string; registrationFilesPage: string; verifyEmailModals: string; diff --git a/app/guid-file/route.ts b/app/guid-file/route.ts index 89056750431..23d774fd5a1 100644 --- a/app/guid-file/route.ts +++ b/app/guid-file/route.ts @@ -58,7 +58,9 @@ export default class GuidFile extends Route { }; this.set('headTags', this.metaTags.getHeadTags(metaTagsData)); this.headTagsService.collectHeadTags(); - await taskFor(model.target.get('getEnabledAddons')).perform(); + if(!model.target.get('isRegistration')) { + await taskFor(model.target.get('getEnabledAddons')).perform(); + } blocker.done(); } diff --git a/app/guid-node/addons/index/controller.ts b/app/guid-node/addons/index/controller.ts new file mode 100644 index 00000000000..5e176c24dc4 --- /dev/null +++ b/app/guid-node/addons/index/controller.ts @@ -0,0 +1,11 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import Media from 'ember-responsive'; + +export default class GuidNodeAddonsController extends Controller { + @service media!: Media; + + get isMobile() { + return this.media.isMobile; + } +} diff --git a/app/guid-node/addons/index/route.ts b/app/guid-node/addons/index/route.ts new file mode 100644 index 00000000000..a8eec208348 --- /dev/null +++ b/app/guid-node/addons/index/route.ts @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class GuidNodeAddons extends Route { + async model() { + return await this.modelFor('guid-node').taskInstance; + } +} diff --git a/app/guid-node/addons/index/styles.scss b/app/guid-node/addons/index/styles.scss new file mode 100644 index 00000000000..93acaca0596 --- /dev/null +++ b/app/guid-node/addons/index/styles.scss @@ -0,0 +1,126 @@ +.addon-page-wrapper { + margin: 20px; +} + +.page-heading { + font-weight: bold; +} + +.addons-list-wrapper { + display: flex; + margin-top: 20px; + + &.mobile { + flex-wrap: wrap; + } +} + +.filter-wrapper { + max-width: 200px; + display: flex; + flex-direction: column; + margin-top: 6px; + + &.mobile { + max-width: 100%; + width: 100%; + margin-right: 20px; + } +} + +.filter-button { + width: 100%; + height: 40px; + text-align: left; + padding: 0 10px; + + &.active { + background-color: $color-light; + } +} + +.addon-cards-wrapper { + display: flex; + flex-wrap: wrap; + width: 100%; + justify-content: center; +} + +.float-right { + float: right; +} + +.account-select { + margin: 20px 0; +} + +.tab-list { + margin-bottom: 10px; + border-bottom: 1px solid $color-border-gray; + box-sizing: border-box; + color: $color-grey; + display: block; + line-height: 20px; + list-style-image: none; + list-style-position: outside; + list-style-type: none; + height: 41px; + padding: 0; +} + +.tab-list { + li { + cursor: pointer; + display: block; + position: relative; + margin-bottom: -1px; + float: left; + height: 41px; + padding: 10px 15px; + } + + li:global(.ember-tabs__tab--selected) { + background-color: $bg-light; + border-bottom: 2px solid $color-blue; + } + + li:hover { + text-decoration: none; + background-color: $bg-light; + color: var(--primary-color); + } +} + +.configured-addons { + border: 1px solid $color-border-gray; + box-sizing: border-box; + padding: 10px; +} + +.configured-addons-heading { + border-bottom: 1px solid $color-border-gray; + box-sizing: border-box; +} + +.configured-addon-display-name { + margin-top: 10px; + font-size: large; +} + +.configured-addon-connected-to { + border-bottom: 1px solid $color-border-gray; + box-sizing: border-box; +} + +.remove-connected-button { + border: 0; + color: $brand-danger; +} + +.edit-connected-button { + border: 0; +} + +.add-location-button { + margin-top: 10px; +} diff --git a/app/guid-node/addons/index/template.hbs b/app/guid-node/addons/index/template.hbs new file mode 100644 index 00000000000..005c1543a8b --- /dev/null +++ b/app/guid-node/addons/index/template.hbs @@ -0,0 +1,394 @@ +{{page-title (t 'addons.heading')}} + +
+ + {{#if manager.selectedProvider}} + + {{/if}} +

{{manager.headingText}}

+ {{#if manager.selectedProvider}} + {{#let manager.selectedProvider as |provider|}} +
+ {{#if (eq manager.pageMode 'terms')}} + +
+ + +
+ {{else if (eq manager.pageMode 'newOrExistingAccount')}} + + + {{else if (eq manager.pageMode 'accountSelect')}} +
+ {{#each provider.authorizedAccounts as |account| }} +
+ +
+ {{else}} + {{t 'addons.accountSelect.no-accounts'}} + {{/each}} +
+
+ + {{#if manager.selectedAccount}} + + {{else if (and manager.selectedAccount (not manager.selectedAccount.credentialsAvailable))}} + + {{/if}} +
+ {{else if (eq manager.pageMode 'accountCreate')}} + + {{else if (eq manager.pageMode 'confirm')}} + {{t 'addons.confirm.verify'}} + {{manager.selectedAccount.displayName}} +
+ + +
+ {{else if (eq manager.pageMode 'configurationList')}} +
+
+

{{t 'addons.list.connected-locations'}}

+
+ {{#each provider.configuredAddons as |configuredAddon|}} +
+ {{configuredAddon.displayName}} + + {{#if configuredAddon.currentUserIsOwner}} + + {{/if}} + + +
+ {{#if configuredAddon.hasRootFolder}} +
+ {{#if configuredAddon.rootFolder}} + {{configuredAddon.rootFolderName}} + {{else}} + {{t 'addons.list.root-folder-not-set'}} + {{/if}} +
+ {{/if}} +
+ {{t 'addons.list.connected-to-account'}} {{configuredAddon.baseAccount.displayName}} +
+ {{/each}} +
+ {{!-- Remove ability to add a new configured addon for now --}} + {{!--
+ +
--}} + + + {{t 'addons.list.disconnect'}} {{provider.provider.displayName}} + + + {{t 'addons.list.confirm-remove-connected-location'}} +
+ {{manager.selectedConfiguration.displayName}} +
+
+ {{#if manager.selectedConfiguration.rootFolder}} + {{manager.selectedConfiguration.rootFolderName}} + {{else}} + {{t 'addons.list.root-folder-not-set'}} + {{/if}} +
+
+ {{t 'addons.list.connected-to-account'}} {{manager.selectedConfiguration.baseAccount.displayName}} +
+
+ +
+ + +
+
+
+ {{else if (eq manager.pageMode 'configure')}} + + {{/if}} +
+ {{/let}} + {{else}} + + + + {{t 'addons.list.all-addons'}} + + + {{t 'addons.list.connected-accounts'}} + + + + {{t 'addons.list.sync-details-1'}} + {{t 'addons.list.sync-details-2'}} +
+
+ + {{#each manager.possibleFilterTypes as |type|}} + + {{/each}} +
+
+ {{#if manager.currentListIsLoading}} + + {{else}} + {{#each manager.filteredAddonProviders as |addon|}} + + {{else}} + {{t 'addons.list.no-results'}} + {{/each}} + {{/if}} +
+
+
+ +
+
+ + {{#each manager.possibleFilterTypes as |type|}} + + {{/each}} +
+
+ {{#if manager.currentListIsLoading}} + + {{else}} + {{#each manager.filteredConfiguredProviders as |addon|}} + + {{else}} + {{t 'addons.list.no-results'}} + {{/each}} + {{/if}} +
+
+
+
+ {{/if}} +
+
diff --git a/app/guid-node/addons/template.hbs b/app/guid-node/addons/template.hbs new file mode 100644 index 00000000000..c24cd68950a --- /dev/null +++ b/app/guid-node/addons/template.hbs @@ -0,0 +1 @@ +{{outlet}} diff --git a/app/guid-node/controller.ts b/app/guid-node/controller.ts index 108cc42c972..d50d047224d 100644 --- a/app/guid-node/controller.ts +++ b/app/guid-node/controller.ts @@ -1,12 +1,14 @@ import Controller from '@ember/controller'; import RouterService from '@ember/routing/router-service'; import { inject as service } from '@ember/service'; +import Features from 'ember-feature-flags'; import Media from 'ember-responsive'; export default class GuidNode extends Controller { @service router!: RouterService; @service media!: Media; + @service features!: Features; get onFilesRoute() { return ( @@ -18,4 +20,8 @@ export default class GuidNode extends Controller { get isDesktop() { return this.media.isDesktop; } + + get useGravyWaffle() { + return this.features.isEnabled('gravy_waffle'); + } } diff --git a/app/guid-node/files/provider/route.ts b/app/guid-node/files/provider/route.ts index bb28666ec12..c0a660f4a50 100644 --- a/app/guid-node/files/provider/route.ts +++ b/app/guid-node/files/provider/route.ts @@ -1,8 +1,10 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; import { waitFor } from '@ember/test-waiters'; +import Store from '@ember-data/store'; import { task } from 'ember-concurrency'; import { taskFor } from 'ember-concurrency-ts'; +import Features from 'ember-feature-flags/services/features'; import Intl from 'ember-intl/services/intl'; import Toast from 'ember-toastr/services/toast'; @@ -14,12 +16,14 @@ import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/captur export default class GuidNodeFilesProviderRoute extends Route.extend({}) { @service intl!: Intl; @service toast!: Toast; + @service features!: Features; + @service store!: Store; @task @waitFor async fileProviderTask(guidRouteModel: GuidRouteModel, fileProviderId: string) { const node = await guidRouteModel.taskInstance; - await taskFor(node.getEnabledAddons).perform(); + // await taskFor(node.getEnabledAddons).perform(); try { const fileProviders = await node.queryHasMany( 'files', @@ -39,10 +43,38 @@ export default class GuidNodeFilesProviderRoute extends Route.extend({}) { } } - model(params: { providerId: string }) { + @task + @waitFor + async configuredStorageAddonTask(providerId: string) { + if(providerId === 'osfstorage') { + return undefined; + } + try { + return await this.store.findRecord('configured-storage-addon', providerId); + } catch (e) { + const errorMessage = this.intl.t( + 'osf-components.file-browser.errors.load_configured_storage_addon', + ); + captureException(e, { errorMessage }); + this.toast.error(getApiErrorMessage(e), errorMessage); + return undefined; + } + } + + async model(params: { providerId: string }) { const node = this.modelFor('guid-node'); - const fileProviderId = node.guid + ':' + params.providerId; + let configuredStorageAddon; + let fileProviderId = params.providerId; + if(this.features.isEnabled('gravy_waffle')){ + configuredStorageAddon = await taskFor(this.configuredStorageAddonTask).perform(params.providerId); + if (params.providerId === 'osfstorage'){ + fileProviderId = node.guid + ':' + params.providerId; + } + } else { + fileProviderId = node.guid + ':' + params.providerId; + } return { + configuredStorageAddon, node, providerName: params.providerId, providerTask: taskFor(this.fileProviderTask).perform(node, fileProviderId), diff --git a/app/guid-node/files/provider/template.hbs b/app/guid-node/files/provider/template.hbs index 72bffe79af8..8f1e23912c7 100644 --- a/app/guid-node/files/provider/template.hbs +++ b/app/guid-node/files/provider/template.hbs @@ -1,21 +1,16 @@ {{#unless this.model.providerTask.isRunning}} - - {{#let (get mapper this.model.providerName) as |ProviderManager|}} - -
- -
-
- {{/let}} -
+
+ +
+ {{/unless}} \ No newline at end of file diff --git a/app/guid-node/styles.scss b/app/guid-node/styles.scss index bac7b728620..dcbd5774f4f 100644 --- a/app/guid-node/styles.scss +++ b/app/guid-node/styles.scss @@ -45,29 +45,3 @@ width: 100%; } } - -.FileProviders { - display: flex; - flex-direction: column; -} - -.FileProvider { - display: flex; - align-items: center; - justify-content: space-between; - border-left: 2px solid $color-border-gray-darker; - padding-left: 17px; - margin: 0 18px; - padding-top: 2px; - - :global(.active) { - border-left: 4px solid $color-black; - font-weight: 700; - padding-left: 16px; - margin-left: -20px; - } -} - -.FileProviderIcon { - font-size: 16px; -} diff --git a/app/guid-node/template.hbs b/app/guid-node/template.hbs index 0c6ab9387d6..7cff65b427a 100644 --- a/app/guid-node/template.hbs +++ b/app/guid-node/template.hbs @@ -51,38 +51,7 @@ @label={{t 'node.left_nav.files'}} /> {{#if this.onFilesRoute}} -
- {{#each this.model.taskInstance.value.files as |provider|}} -
- - {{t (concat 'osf-components.file-browser.storage_providers.' provider.name)}} - - {{#if (eq provider.name 'osfstorage')}} - - - - - {{t 'osf-components.file-browser.storage_location'}} - {{this.model.taskInstance.value.region.name}} - - - {{/if}} -
- {{/each}} -
+ {{/if}} {{#if this.model.taskInstance.value.wikiEnabled }} {{/if}} {{#if this.model.taskInstance.value.userHasWritePermission}} - + {{#if this.useGravyWaffle}} + + {{else}} + + {{/if}} {{/if}} {{#if this.model.taskInstance.value.userHasReadPermission}} ; + @attr('object', {snakifyForApi: true}) operationResult!: OperationResult; + @attr('date') created!: Date; + @attr('date') modified!: Date; + + @belongsTo('user-reference', { inverse: null }) + byUser!: AsyncBelongsTo | UserReferenceModel; + + @belongsTo('configured-addon', { inverse: null, polymorphic: true }) + thruAddon?: AsyncBelongsTo | ConfiguredAddonModel; + + @belongsTo('authorized-account', { inverse: null, polymorphic: true }) + thruAccount?: AsyncBelongsTo | AuthorizedAccountModel; +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'addon-operation-invocation': AddonOperationInvocationModel; + } // eslint-disable-line semi +} diff --git a/app/models/authorized-account.ts b/app/models/authorized-account.ts new file mode 100644 index 00000000000..bddf3f3877e --- /dev/null +++ b/app/models/authorized-account.ts @@ -0,0 +1,44 @@ +import Model, { attr } from '@ember-data/model'; +import { OperationKwargs } from 'ember-osf-web/models/addon-operation-invocation'; + +export enum ConnectedCapabilities { + Access = 'ACCESS', + Update = 'UPDATE', +} + +export interface AddonCredentialFields { + username?: string; + password?: string; + access_token?: string; + access_key?: string; + secret_key?: string; + repo?: string; +} + +export interface AccountCreationArgs { + credentials?: AddonCredentialFields; + apiBaseUrl?: string; + displayName: string; + initiateOauth?: boolean; +} + +export default class AuthorizedAccountModel extends Model { + @attr('fixstring') displayName!: string; + @attr('fixstringarray') authorizedCapabilities!: string[]; + @attr('fixstring') apiBaseUrl?: string; // Only applicable when ExternalService.configurableApiRoot === true + @attr('object') credentials?: AddonCredentialFields; // write-only + @attr('boolean') initiateOauth!: boolean; // write-only + @attr('fixstring') readonly authUrl!: string; // Only returned when POSTing to /authorized-xyz-accounts + @attr('boolean') readonly credentialsAvailable!: boolean; + + async getFolderItems(this: AuthorizedAccountModel, _kwargs?: OperationKwargs) : Promise { + // To be implemented in child classes + return; + } + + + async getItemInfo(this: AuthorizedAccountModel, _itemId: string) : Promise { + // To be implemented in child classes + return; + } +} diff --git a/app/models/authorized-citation-account.ts b/app/models/authorized-citation-account.ts new file mode 100644 index 00000000000..2a464d57b26 --- /dev/null +++ b/app/models/authorized-citation-account.ts @@ -0,0 +1,46 @@ +import { AsyncBelongsTo, belongsTo } from '@ember-data/model'; +import { waitFor } from '@ember/test-waiters'; +import { task } from 'ember-concurrency'; +import { ConnectedCitationOperationNames, OperationKwargs } from 'ember-osf-web/models/addon-operation-invocation'; + + +import ExternalCitationServiceModel from './external-citation-service'; +import AuthorizedAccountModel from './authorized-account'; +import UserReferenceModel from './user-reference'; + +export default class AuthorizedCitationAccountModel extends AuthorizedAccountModel { + @belongsTo('user-reference', { inverse: 'authorizedCitationAccounts' }) + accountOwner!: AsyncBelongsTo & UserReferenceModel; + + @belongsTo('external-citation-service') + externalCitationService!: AsyncBelongsTo & ExternalCitationServiceModel; + + @task + @waitFor + async getFolderItems(this: AuthorizedAccountModel, kwargs?: OperationKwargs) { + const operationKwargs = kwargs || {}; + const operationName = operationKwargs.itemId ? ConnectedCitationOperationNames.ListCollectionItems : + ConnectedCitationOperationNames.ListRootCollections; + // rename 'itemId' key to 'collectionId' + delete Object.assign(operationKwargs, { ['collectionId']: operationKwargs['itemId'] })['itemId']; + const newInvocation = this.store.createRecord('addon-operation-invocation', { + operationName, + operationKwargs, + thruAccount: this, + }); + return await newInvocation.save(); + } + + @task + @waitFor + async getItemInfo(this: AuthorizedAccountModel, _itemId: string) { + // This is a noop because gravyvalet does not have getItemInfo operation for citation addons + return; + } +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'authorized-citation-account': AuthorizedCitationAccountModel; + } // eslint-disable-line semi +} diff --git a/app/models/authorized-computing-account.ts b/app/models/authorized-computing-account.ts new file mode 100644 index 00000000000..97c212c4419 --- /dev/null +++ b/app/models/authorized-computing-account.ts @@ -0,0 +1,19 @@ +import { AsyncBelongsTo, belongsTo } from '@ember-data/model'; + +import ExternalComputingServiceModel from './external-computing-service'; +import AuthorizedAccountModel from './authorized-account'; +import UserReferenceModel from './user-reference'; + +export default class AuthorizedComputingAccountModel extends AuthorizedAccountModel { + @belongsTo('user-reference', { inverse: 'authorizedComputingAccounts' }) + accountOwner!: AsyncBelongsTo & UserReferenceModel; + + @belongsTo('external-computing-service') + externalComputingService!: AsyncBelongsTo & ExternalComputingServiceModel; +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'authorized-computing-account': AuthorizedComputingAccountModel; + } // eslint-disable-line semi +} diff --git a/app/models/authorized-storage-account.ts b/app/models/authorized-storage-account.ts new file mode 100644 index 00000000000..45ea1d06685 --- /dev/null +++ b/app/models/authorized-storage-account.ts @@ -0,0 +1,47 @@ +import { AsyncBelongsTo, belongsTo } from '@ember-data/model'; +import { waitFor } from '@ember/test-waiters'; +import { task } from 'ember-concurrency'; +import { ConnectedStorageOperationNames, OperationKwargs } from 'ember-osf-web/models/addon-operation-invocation'; + +import ExternalStorageServiceModel from './external-storage-service'; +import AuthorizedAccountModel from './authorized-account'; +import UserReferenceModel from './user-reference'; + +export default class AuthorizedStorageAccountModel extends AuthorizedAccountModel { + @belongsTo('user-reference', { inverse: 'authorizedStorageAccounts' }) + readonly accountOwner!: AsyncBelongsTo & UserReferenceModel; + + @belongsTo('external-storage-service') + externalStorageService!: AsyncBelongsTo & ExternalStorageServiceModel; + + @task + @waitFor + async getFolderItems(this: AuthorizedAccountModel, kwargs?: OperationKwargs) { + const operationKwargs = kwargs || {}; + const operationName = operationKwargs.itemId ? ConnectedStorageOperationNames.ListChildItems : + ConnectedStorageOperationNames.ListRootItems; + const newInvocation = this.store.createRecord('addon-operation-invocation', { + operationName, + operationKwargs, + thruAccount: this, + }); + return await newInvocation.save(); + } + + @task + @waitFor + async getItemInfo(this: AuthorizedAccountModel, itemId: string) { + const newInvocation = this.store.createRecord('addon-operation-invocation', { + operationName: ConnectedStorageOperationNames.GetItemInfo, + operationKwargs: { itemId }, + thruAccount: this, + }); + return await newInvocation.save(); + } +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'authorized-storage-account': AuthorizedStorageAccountModel; + } // eslint-disable-line semi +} diff --git a/app/models/configured-addon.ts b/app/models/configured-addon.ts new file mode 100644 index 00000000000..3b540fd0e72 --- /dev/null +++ b/app/models/configured-addon.ts @@ -0,0 +1,59 @@ +import Model, { AsyncBelongsTo, attr, belongsTo } from '@ember-data/model'; +import { waitFor } from '@ember/test-waiters'; +import { task } from 'ember-concurrency'; + +import UserReferenceModel from 'ember-osf-web/models/user-reference'; +import { tracked } from 'tracked-built-ins'; +import { taskFor } from 'ember-concurrency-ts'; +import { ConnectedStorageOperationNames, OperationKwargs } from './addon-operation-invocation'; + + +export interface ConfiguredAddonEditableAttrs { + displayName: string; + rootFolder: string; +} + +export default class ConfiguredAddonModel extends Model { + @attr('string') displayName!: string; + @attr('fixstring') externalUserId!: string; + @attr('fixstring') externalUserDisplayName!: string; + + @attr('string') iconUrl!: string; + @attr('string') authorizedResourceUri?: string; + + @attr('array') connectedCapabilities!: ConnectedCapabilities[]; + @attr('array') connectedOperationNames!: ConnectedStorageOperationNames[]; + @attr('fixstring') rootFolder!: string; + @attr('fixstring') externalServiceName!: string; + + @belongsTo('user-reference', { inverse: null }) + accountOwner!: AsyncBelongsTo & UserReferenceModel; + + @attr('boolean') + currentUserIsOwner!: boolean; + + async getFolderItems(this: ConfiguredAddonModel, _kwargs?: OperationKwargs) : Promise { + // To be implemented in child classes + return; + } + + + async getItemInfo(this: ConfiguredAddonModel, _itemId: string) : Promise { + // To be implemented in child classes + return; + } + + get hasRootFolder() { + return true; + + } + + @tracked rootFolderName = ''; + + @task + @waitFor + async getRootFolderName(this: ConfiguredAddonModel) { + const response = await taskFor(this.getItemInfo).perform(this.rootFolder); + this.rootFolderName = response.operationResult.itemName; + } +} diff --git a/app/models/configured-citation-addon.ts b/app/models/configured-citation-addon.ts new file mode 100644 index 00000000000..25764947aa9 --- /dev/null +++ b/app/models/configured-citation-addon.ts @@ -0,0 +1,57 @@ +import { AsyncBelongsTo, belongsTo } from '@ember-data/model'; + +import ResourceReferenceModel from 'ember-osf-web/models/resource-reference'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import { ConnectedCitationOperationNames, OperationKwargs } from 'ember-osf-web/models/addon-operation-invocation'; +import AuthorizedCitationAccountModel from './authorized-citation-account'; +import ExternalCitationServiceModel from './external-citation-service'; +import ConfiguredAddonModel from './configured-addon'; + +export default class ConfiguredCitationAddonModel extends ConfiguredAddonModel { + @belongsTo('external-citation-service', { inverse: null }) + externalCitationService!: AsyncBelongsTo & ExternalCitationServiceModel; + + @belongsTo('authorized-citation-account') + baseAccount!: AsyncBelongsTo & AuthorizedCitationAccountModel; + + @belongsTo('resource-reference', { inverse: 'configuredCitationAddons' }) + authorizedResource!: AsyncBelongsTo & ResourceReferenceModel; + + get externalServiceId() { + return (this as ConfiguredCitationAddonModel).belongsTo('externalCitationService').id(); + } + + @task + @waitFor + async getFolderItems(this: ConfiguredAddonModel, kwargs?: OperationKwargs) { + const operationKwargs = kwargs || {}; + const operationName = operationKwargs.itemId ? ConnectedCitationOperationNames.ListCollectionItems : + ConnectedCitationOperationNames.ListRootCollections; + // rename 'itemId' key to 'collectionId' + delete Object.assign(operationKwargs, { ['collectionId']: operationKwargs['itemId'] })['itemId']; + const newInvocation = this.store.createRecord('addon-operation-invocation', { + operationName, + operationKwargs, + thruAddon: this, + }); + return await newInvocation.save(); + } + + @task + @waitFor + async getItemInfo(this: ConfiguredAddonModel, itemId: string) { + const newInvocation = this.store.createRecord('addon-operation-invocation', { + operationName: ConnectedCitationOperationNames.GetItemInfo, + operationKwargs: { itemId }, + thruAddon: this, + }); + return await newInvocation.save(); + } +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'configured-citation-addon': ConfiguredCitationAddonModel; + } // eslint-disable-line semi +} diff --git a/app/models/configured-computing-addon.ts b/app/models/configured-computing-addon.ts new file mode 100644 index 00000000000..30c8b116fc6 --- /dev/null +++ b/app/models/configured-computing-addon.ts @@ -0,0 +1,32 @@ +import { AsyncBelongsTo, belongsTo } from '@ember-data/model'; + +import ResourceReferenceModel from 'ember-osf-web/models/resource-reference'; +import AuthorizedComputingAccountModel from './authorized-computing-account'; +import ExternalComputingServiceModel from './external-computing-service'; +import ConfiguredAddonModel from './configured-addon'; + +export default class ConfiguredComputingAddonModel extends ConfiguredAddonModel { + @belongsTo('external-computing-service', { inverse: null }) + externalComputingService!: AsyncBelongsTo & ExternalComputingServiceModel; + + @belongsTo('authorized-computing-account') + baseAccount!: AsyncBelongsTo & AuthorizedComputingAccountModel; + + @belongsTo('resource-reference', { inverse: 'configuredComputingAddons' }) + authorizedResource!: AsyncBelongsTo & ResourceReferenceModel; + + get externalServiceId() { + return (this as ConfiguredComputingAddonModel).belongsTo('externalComputingService').id(); + } + + get hasRootFolder() { + return false; + } + +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'configured-computing-addon': ConfiguredComputingAddonModel; + } // eslint-disable-line semi +} diff --git a/app/models/configured-storage-addon.ts b/app/models/configured-storage-addon.ts new file mode 100644 index 00000000000..31defc9bbdd --- /dev/null +++ b/app/models/configured-storage-addon.ts @@ -0,0 +1,58 @@ +import { AsyncBelongsTo, attr, belongsTo } from '@ember-data/model'; +import { waitFor } from '@ember/test-waiters'; +import { task } from 'ember-concurrency'; +import { ConnectedStorageOperationNames, OperationKwargs } from 'ember-osf-web/models/addon-operation-invocation'; +import ResourceReferenceModel from 'ember-osf-web/models/resource-reference'; + +import AuthorizedStorageAccountModel from './authorized-storage-account'; +import ConfiguredAddonModel from './configured-addon'; +import ExternalStorageServiceModel from './external-storage-service'; + + +export default class ConfiguredStorageAddonModel extends ConfiguredAddonModel { + @attr('number') concurrentUploads!: number; + + @belongsTo('external-storage-service', { inverse: null }) + externalStorageService!: AsyncBelongsTo & ExternalStorageServiceModel; + + @belongsTo('authorized-storage-account') + baseAccount!: AsyncBelongsTo & AuthorizedStorageAccountModel; + + @belongsTo('resource-reference', { inverse: 'configuredStorageAddons' }) + authorizedResource!: AsyncBelongsTo & ResourceReferenceModel; + + get externalServiceId() { + return (this as ConfiguredStorageAddonModel).belongsTo('externalStorageService').id(); + } + + @task + @waitFor + async getFolderItems(this: ConfiguredAddonModel, kwargs?: OperationKwargs) { + const operationKwargs = kwargs || {}; + const operationName = operationKwargs.itemId ? ConnectedStorageOperationNames.ListChildItems : + ConnectedStorageOperationNames.ListRootItems; + const newInvocation = this.store.createRecord('addon-operation-invocation', { + operationName, + operationKwargs, + thruAddon: this, + }); + return await newInvocation.save(); + } + + @task + @waitFor + async getItemInfo(this: ConfiguredAddonModel, itemId: string) { + const newInvocation = this.store.createRecord('addon-operation-invocation', { + operationName: ConnectedStorageOperationNames.GetItemInfo, + operationKwargs: { itemId }, + thruAddon: this, + }); + return await newInvocation.save(); + } +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'configured-storage-addon': ConfiguredStorageAddonModel; + } // eslint-disable-line semi +} diff --git a/app/models/external-citation-service.ts b/app/models/external-citation-service.ts new file mode 100644 index 00000000000..12cff50cf3c --- /dev/null +++ b/app/models/external-citation-service.ts @@ -0,0 +1,11 @@ +import ExternalServiceModel from './external-service'; + +export default class ExternalCitationServiceModel extends ExternalServiceModel { + // TODO: actually need some attrs here for citation service options +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'external-citation-service': ExternalCitationServiceModel; + } // eslint-disable-line semi +} diff --git a/app/models/external-computing-service.ts b/app/models/external-computing-service.ts new file mode 100644 index 00000000000..9aaab7827d8 --- /dev/null +++ b/app/models/external-computing-service.ts @@ -0,0 +1,11 @@ +import ExternalServiceModel from './external-service'; + +export default class ExternalComputingServiceModel extends ExternalServiceModel { + // TODO: actually need some attrs here for cloud computing options +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'external-computing-service': ExternalComputingServiceModel; + } // eslint-disable-line semi +} diff --git a/app/models/external-service.ts b/app/models/external-service.ts new file mode 100644 index 00000000000..66c4cfc9642 --- /dev/null +++ b/app/models/external-service.ts @@ -0,0 +1,37 @@ +import Model, { attr } from '@ember-data/model'; + +export enum CredentialsFormat { + OAUTH = 'OAUTH1A', + OAUTH2 = 'OAUTH2', + USERNAME_PASSWORD = 'USERNAME_PASSWORD', + ACCESS_SECRET_KEYS = 'ACCESS_KEY_SECRET_KEY', + REPO_TOKEN = 'PERSONAL_ACCESS_TOKEN', + DATAVERSE_API_TOKEN = 'DATAVERSE_API_TOKEN', +} + +export enum ExternalServiceCapabilities { + ADD_UPDATE_FILES = 'ADD_UPDATE_FILES', + ADD_UPDATE_FILES_PARTIAL = 'ADD_UPDATE_FILES_PARTIAL', + DELETE_FILES = 'DELETE_FILES', + DELETE_FILES_PARTIAL = 'DELETE_FILES_PARTIAL', + FORKING = 'FORKING', + FORKING_PARTIAL = 'FORKING_PARTIAL', + LOGS = 'LOGS', + LOGS_PARTIAL = 'LOGS_PARTIAL', + PERMISSIONS = 'PERMISSIONS', + PERMISSIONS_PARTIAL = 'PERMISSIONS_PARTIAL', + REGISTERING = 'REGISTERING', + REGISTERING_PARTIAL = 'REGISTERING_PARTIAL', + FILE_VERSIONS = 'FILE_VERSIONS', + DOWNLOAD_AS_ZIP = 'DOWNLOAD_AS_ZIP', + COPY_INTO = 'COPY_INTO', +} + +export default class ExternalServiceModel extends Model { + @attr('fixstring') displayName!: string; + @attr('string') iconUrl!: string; + @attr('string') credentialsFormat!: CredentialsFormat; + @attr('array') supportedFeatures!: ExternalServiceCapabilities[]; + @attr('boolean') configurableApiRoot!: boolean; + @attr('array') apiBaseUrlOptions!: string[]; +} diff --git a/app/models/external-storage-service.ts b/app/models/external-storage-service.ts new file mode 100644 index 00000000000..572696284ba --- /dev/null +++ b/app/models/external-storage-service.ts @@ -0,0 +1,15 @@ +import { attr } from '@ember-data/model'; + +import ExternalServiceModel from './external-service'; + +export default class ExternalStorageServiceModel extends ExternalServiceModel { + @attr('number') maxConcurrentDownloads!: number; + @attr('number') maxUploadMb!: number; + @attr('string') wbKey!: string; +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'external-storage-service': ExternalStorageServiceModel; + } // eslint-disable-line semi +} diff --git a/app/models/node.ts b/app/models/node.ts index 1e845311d3c..fc40dbd4141 100644 --- a/app/models/node.ts +++ b/app/models/node.ts @@ -13,6 +13,7 @@ import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; import getRelatedHref from 'ember-osf-web/utils/get-related-href'; +import captureException from 'ember-osf-web/utils/capture-exception'; import AbstractNodeModel from 'ember-osf-web/models/abstract-node'; import CitationModel from './citation'; @@ -333,20 +334,24 @@ export default class NodeModel extends AbstractNodeModel.extend(Validations, Col @task @waitFor async getEnabledAddons() { - const endpoint = `${apiUrl}/${apiNamespace}/nodes/${this.id}/addons/`; - const response = await this.currentUser.authenticatedAJAX({ - url: endpoint, - type: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - xhrFields: { withCredentials: true }, - }); - if (response.data) { - const addonList = response.data - .filter((addon: any) => addon.attributes.node_has_auth) - .map((addon: any) => addon.id); - this.set('addonsEnabled', addonList); + try { + const endpoint = `${apiUrl}/${apiNamespace}/nodes/${this.id}/addons/`; + const response = await this.currentUser.authenticatedAJAX({ + url: endpoint, + type: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + xhrFields: { withCredentials: true }, + }); + if (response.data) { + const addonList = response.data + .filter((addon: any) => addon.attributes.node_has_auth) + .map((addon: any) => addon.id); + this.set('addonsEnabled', addonList); + } + } catch (e) { + captureException(e); } } } diff --git a/app/models/registration.ts b/app/models/registration.ts index 5b294fff129..5ec13834004 100644 --- a/app/models/registration.ts +++ b/app/models/registration.ts @@ -1,4 +1,5 @@ import { attr, belongsTo, hasMany, AsyncBelongsTo, AsyncHasMany } from '@ember-data/model'; +import { task } from 'ember-concurrency'; import { buildValidations, validator } from 'ember-cp-validations'; import DraftRegistrationModel from 'ember-osf-web/models/draft-registration'; @@ -171,6 +172,11 @@ export default class RegistrationModel extends NodeModel.extend(Validations) { RegistrationReviewStates.Pending, ].includes(this.reviewsState) && !this.withdrawn && !this.archiving; } + + @task + async getEnabledAddons() { + this.set('addonsEnabled', []); + } } declare module 'ember-data/types/registries/model' { diff --git a/app/models/resource-reference.ts b/app/models/resource-reference.ts new file mode 100644 index 00000000000..9adf8334abf --- /dev/null +++ b/app/models/resource-reference.ts @@ -0,0 +1,27 @@ +import Model, { AsyncHasMany, attr, hasMany } from '@ember-data/model'; + +import ConfiguredStorageAddonModel from './configured-storage-addon'; +import ConfiguredCitationAddonModel from './configured-citation-addon'; +import ConfiguredComputingAddonModel from './configured-computing-addon'; + +export default class ResourceReferenceModel extends Model { + + @attr('fixstring') resourceUri!: string; + + @hasMany('configured-storage-addon', { inverse: 'authorizedResource' }) + configuredStorageAddons!: AsyncHasMany & ConfiguredStorageAddonModel[]; + + @hasMany('configured-citation-addon', { inverse: 'authorizedResource' }) + configuredCitationAddons!: AsyncHasMany + & ConfiguredCitationAddonModel[]; + + @hasMany('configured-computing-addon', { inverse: 'authorizedResource' }) + configuredComputingAddons!: AsyncHasMany + & ConfiguredComputingAddonModel[]; +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'resource-reference': ResourceReferenceModel; + } // eslint-disable-line semi +} diff --git a/app/models/user-reference.ts b/app/models/user-reference.ts new file mode 100644 index 00000000000..4cf3d589ee6 --- /dev/null +++ b/app/models/user-reference.ts @@ -0,0 +1,30 @@ +import Model, { AsyncHasMany, attr, hasMany } from '@ember-data/model'; + +import AuthorizedStorageAccountModel from './authorized-storage-account'; +import AuthorizedCitationAccountModel from './authorized-citation-account'; +import AuthorizedComputingAccountModel from './authorized-computing-account'; +import ResourceReferenceModel from './resource-reference'; + +export default class UserReferenceModel extends Model { + @attr('fixstring') userUri!: string; + + @hasMany('authorized-storage-account', { inverse: 'accountOwner' }) + authorizedStorageAccounts!: AsyncHasMany & AuthorizedStorageAccountModel[]; + + @hasMany('authorized-citation-account', { inverse: 'accountOwner' }) + authorizedCitationAccounts!: AsyncHasMany & + AuthorizedCitationAccountModel[]; + + @hasMany('authorized-computing-account', { inverse: 'accountOwner' }) + authorizedComputingAccounts!: AsyncHasMany + & AuthorizedComputingAccountModel[]; + + @hasMany('resource-reference') + configuredResources!: AsyncHasMany & ResourceReferenceModel[]; +} + +declare module 'ember-data/types/registries/model' { + export default interface ModelRegistry { + 'user-reference': UserReferenceModel; + } // eslint-disable-line semi +} diff --git a/app/packages/addons-service/provider.ts b/app/packages/addons-service/provider.ts new file mode 100644 index 00000000000..602aa683fbe --- /dev/null +++ b/app/packages/addons-service/provider.ts @@ -0,0 +1,367 @@ +import { getOwner, setOwner } from '@ember/application'; +import EmberArray from '@ember/array'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import Store from '@ember-data/store'; +import { tracked } from '@glimmer/tracking'; +import { Task, task } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import Intl from 'ember-intl/services/intl'; +import Toast from 'ember-toastr/services/toast'; + +import NodeModel from 'ember-osf-web/models/node'; +import CurrentUserService from 'ember-osf-web/services/current-user'; +import UserReferenceModel from 'ember-osf-web/models/user-reference'; +import ResourceReferenceModel from 'ember-osf-web/models/resource-reference'; +import ConfiguredStorageAddonModel from 'ember-osf-web/models/configured-storage-addon'; +import ConfiguredCitationAddonModel from 'ember-osf-web/models/configured-citation-addon'; +import ConfiguredComputingAddonModel from 'ember-osf-web/models/configured-computing-addon'; +import { AccountCreationArgs } from 'ember-osf-web/models/authorized-account'; +import AuthorizedStorageAccountModel from 'ember-osf-web/models/authorized-storage-account'; +import AuthorizedCitationAccountModel from 'ember-osf-web/models/authorized-citation-account'; +import AuthorizedComputingAccountModel from 'ember-osf-web/models/authorized-computing-account'; +import ExternalStorageServiceModel from 'ember-osf-web/models/external-storage-service'; +import ExternalComputingServiceModel from 'ember-osf-web/models/external-computing-service'; +import ExternalCitationServiceModel from 'ember-osf-web/models/external-citation-service'; +import { notifyPropertyChange } from '@ember/object'; +import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; + +export type AllProviderTypes = + ExternalStorageServiceModel | + ExternalComputingServiceModel | + ExternalCitationServiceModel; +export type AllAuthorizedAccountTypes = + AuthorizedStorageAccountModel | + AuthorizedCitationAccountModel | + AuthorizedComputingAccountModel; +export type AllConfiguredAddonTypes = + ConfiguredStorageAddonModel | + ConfiguredCitationAddonModel | + ConfiguredComputingAddonModel; + +interface ProviderTypeMapper { + getAuthorizedAccounts: Task; + createAuthorizedAccount: Task; + createConfiguredAddon: Task; +} + + +export default class Provider { + @service toast!: Toast; + @service intl!: Intl; + + @tracked node?: NodeModel; + @tracked serviceNode?: ResourceReferenceModel | null; + + currentUser: CurrentUserService; + @tracked userReference!: UserReferenceModel; + provider: AllProviderTypes; + private providerMap?: ProviderTypeMapper; + + get displayName() { + return this.provider.displayName; + } + + get id() { + return this.provider.id; + } + + providerTypeMapper: Record = { + externalStorageService: { + getAuthorizedAccounts: taskFor(this.getAuthorizedStorageAccounts), + createAuthorizedAccount: taskFor(this.createAuthorizedStorageAccount), + createConfiguredAddon: taskFor(this.createConfiguredStorageAddon), + }, + externalComputingService: { + getAuthorizedAccounts: taskFor(this.getAuthorizedComputingAccounts), + createAuthorizedAccount: taskFor(this.createAuthorizedComputingAccount), + createConfiguredAddon: taskFor(this.createConfiguredComputingAddon), + }, + externalCitationService: { + getAuthorizedAccounts: taskFor(this.getAuthorizedCitationAccounts), + createAuthorizedAccount: taskFor(this.createAuthorizedCitationAccount), + createConfiguredAddon: taskFor(this.createConfiguredCitationAddon), + }, + }; + + @tracked configuredAddon?: AllConfiguredAddonTypes; + @tracked configuredAddons?: AllConfiguredAddonTypes[]; + @tracked authorizedAccount?: AllAuthorizedAccountTypes; + @tracked authorizedAccounts?: AllAuthorizedAccountTypes[]; + + @service store!: Store; + + get isConfigured() { + return Boolean(this.configuredAddons?.length); + } + + get isOwned() { + if (this.node?.userHasAdminPermission) { + return true; + } + if (!this.configuredAddons || this.configuredAddons.length === 0) { + return true; + } + if (!this.userReference) { + return false; + } + return this.configuredAddons?.any( + addon => addon.currentUserIsOwner, + ); + } + + constructor( + provider: any, + currentUser: CurrentUserService, + node?: NodeModel, + allConfiguredAddons?: EmberArray, + resourceReference?: ResourceReferenceModel | null, + userReference?: UserReferenceModel, + ) { + setOwner(this, getOwner(provider)); + this.node = node; + this.currentUser = currentUser; + this.provider = provider; + this.configuredAddons = allConfiguredAddons?.filter(addon => addon.externalServiceId === this.provider.id); + this.serviceNode = resourceReference; + if (userReference) { + this.userReference = userReference; + } + + if (provider instanceof ExternalStorageServiceModel) { + this.providerMap = this.providerTypeMapper.externalStorageService; + } else if (provider instanceof ExternalComputingServiceModel) { + this.providerMap = this.providerTypeMapper.externalComputingService; + } else if (provider instanceof ExternalCitationServiceModel) { + this.providerMap = this.providerTypeMapper.externalCitationService; + } + taskFor(this.initialize).perform(); + } + + @task + @waitFor + async initialize() { + await taskFor(this.getUserReference).perform(); + await taskFor(this.getResourceReference).perform(); + } + + @task + @waitFor + async removeConfiguredAddon(selectedConfiguration: AllConfiguredAddonTypes) { + const errorMessage = this.intl.t('addons.provider.remove-configured-addon-error'); + const successMessage = this.intl.t('addons.provider.remove-configured-addon-success'); + try { + await selectedConfiguration?.destroyRecord(); + this.configuredAddons?.removeObject(selectedConfiguration); + notifyPropertyChange(this, 'configuredAddons'); + this.toast.success(successMessage); + } catch (e) { + captureException(e, { errorMessage }); + this.toast.error(getApiErrorMessage(e), errorMessage); + return; + } + } + + @task + @waitFor + async getUserReference() { + if (this.userReference){ + return; + } + const { user } = this.currentUser; + const userReferences = await this.store.query('user-reference', { + filter: {user_uri: user?.links.iri?.toString()}, + }); + this.userReference = userReferences.firstObject; + } + + + @task + @waitFor + async getResourceReference() { + if (this.node && this.serviceNode === undefined) { + const resourceRefs = await this.store.query('resource-reference', { + filter: {resource_uri: this.node.links.iri?.toString()}, + }); + this.serviceNode = resourceRefs.firstObject; + } + } + + @task + @waitFor + async getAuthorizedStorageAccounts() { + const authorizedStorageAccounts = await this.userReference.authorizedStorageAccounts; + this.authorizedAccounts = authorizedStorageAccounts + .filterBy('externalStorageService.id', this.provider.id).toArray(); + } + + @task + @waitFor + async getAuthorizedCitationAccounts() { + const authorizedCitationAccounts = await this.userReference.authorizedCitationAccounts; + this.authorizedAccounts = authorizedCitationAccounts + .filterBy('externalCitationService.id', this.provider.id).toArray(); + } + + @task + @waitFor + async getAuthorizedComputingAccounts() { + const authorizedComputingAccounts = await this.userReference.authorizedComputingAccounts; + this.authorizedAccounts = authorizedComputingAccounts + .filterBy('externalComputingService.id', this.provider.id).toArray(); + } + + @task + @waitFor + async getAuthorizedAccounts() { + await this.providerMap?.getAuthorizedAccounts.perform(); + } + + async userAddonAccounts() { + return await this.userReference.authorizedStorageAccounts; + } + + + @task + @waitFor + private async createAuthorizedStorageAccount(arg: AccountCreationArgs) { + const { credentials, apiBaseUrl, displayName, initiateOauth } = arg; + const newAccount = this.store.createRecord('authorized-storage-account', { + credentials, + initiateOauth, + apiBaseUrl, + externalUserId: this.currentUser.user?.id, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + externalStorageService: this.provider, + displayName, + accountOwner: this.userReference, + }); + await newAccount.save(); + return newAccount; + } + + @task + @waitFor + private async createAuthorizedCitationAccount(arg: AccountCreationArgs) { + const { credentials, apiBaseUrl, displayName, initiateOauth } = arg; + const newAccount = this.store.createRecord('authorized-citation-account', { + credentials, + apiBaseUrl, + initiateOauth, + externalUserId: this.currentUser.user?.id, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + scopes: [], + externalCitationService: this.provider, + accountOwner: this.userReference, + displayName, + }); + await newAccount.save(); + return newAccount; + } + + @task + @waitFor + private async createAuthorizedComputingAccount(arg: AccountCreationArgs) { + const { credentials, apiBaseUrl, displayName, initiateOauth } = arg; + const newAccount = this.store.createRecord('authorized-computing-account', { + credentials, + apiBaseUrl, + initiateOauth, + externalUserId: this.currentUser.user?.id, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + scopes: [], + externalComputingService: this.provider, + accountOwner: this.userReference, + displayName, + }); + await newAccount.save(); + return newAccount; + } + + @task + @waitFor + public async createAuthorizedAccount(arg: AccountCreationArgs) { + return await taskFor(this.providerMap!.createAuthorizedAccount) + .perform(arg); + } + + @task + @waitFor + public async reconnectAuthorizedAccount(args: AccountCreationArgs, account: AllAuthorizedAccountTypes) { + const { credentials, apiBaseUrl, displayName } = args; + account.credentials = credentials; + account.apiBaseUrl = apiBaseUrl; + account.displayName = displayName; + await account.save(); + await account.reload(); + } + + @task + @waitFor + private async createConfiguredStorageAddon(account: AuthorizedStorageAccountModel) { + const configuredStorageAddon = this.store.createRecord('configured-storage-addon', { + rootFolder: '', + externalStorageService: this.provider, + accountOwner: this.userReference, + authorizedResourceUri: this.node!.links.iri, + baseAccount: account, + connectedCapabilities: ['ACCESS', 'UPDATE'], + }); + return await configuredStorageAddon.save(); + } + + @task + @waitFor + private async createConfiguredCitationAddon(account: AuthorizedCitationAccountModel) { + const configuredCitationAddon = this.store.createRecord('configured-citation-addon', { + rootFolder: '', + externalCitationService: this.provider, + accountOwner: this.userReference, + authorizedResourceUri: this.node!.links.iri, + baseAccount: account, + connectedCapabilities: ['ACCESS', 'UPDATE'], + }); + return await configuredCitationAddon.save(); + } + + @task + @waitFor + private async createConfiguredComputingAddon(account: AuthorizedComputingAccountModel) { + const configuredComputingAddon = this.store.createRecord('configured-computing-addon', { + // rootFolder: '', + externalComputingService: this.provider, + accountOwner: this.userReference, + // authorizedResource: this.serviceNode, + authorizedResourceUri: this.node!.links.iri, + baseAccount: account, + connectedCapabilities: ['ACCESS', 'UPDATE'], + }); + return await configuredComputingAddon.save(); + } + + @task + @waitFor + public async createConfiguredAddon(account: AllAuthorizedAccountTypes) { + const newConfiguredAddon = await taskFor(this.providerMap!.createConfiguredAddon).perform(account); + this.configuredAddons!.pushObject(newConfiguredAddon); + return newConfiguredAddon; + } + + @task + @waitFor + async disableProjectAddon() { + if (this.configuredAddon) { + await this.configuredAddon.destroyRecord(); + this.configuredAddon = undefined; + } + } + + @task + @waitFor + async setRootFolder(newRootFolder: string) { + if (this.configuredAddon) { + (this.configuredAddon as ConfiguredStorageAddonModel).rootFolder = newRootFolder; + await this.configuredAddon.save(); + } + } +} diff --git a/app/packages/files/provider-file.ts b/app/packages/files/provider-file.ts index c5be49d854e..6feb045d425 100644 --- a/app/packages/files/provider-file.ts +++ b/app/packages/files/provider-file.ts @@ -5,6 +5,7 @@ import Intl from 'ember-intl/services/intl'; import Toast from 'ember-toastr/services/toast'; import { tracked } from '@glimmer/tracking'; +import config from 'ember-osf-web/config/environment'; import FileProviderModel from 'ember-osf-web/models/file-provider'; import { Permission } from 'ember-osf-web/models/osf-model'; import { FileSortKey } from 'ember-osf-web/packages/files/file'; @@ -12,12 +13,15 @@ import CurrentUserService from 'ember-osf-web/services/current-user'; import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; import { ErrorDocument } from 'osf-api'; + export default abstract class ProviderFile { @tracked fileModel: FileProviderModel; @tracked totalFileCount = 0; userCanDownloadAsZip = true; providerHandlesVersioning = true; parallelUploadsLimit = 2; + assetsPrefix = config.assetsPrefix; + currentUser: CurrentUserService; @service intl!: Intl; @@ -74,6 +78,10 @@ export default abstract class ProviderFile { return this.fileModel.name; } + get iconLocation() { + return `${this.assetsPrefix}assets/images/addons/icons/${this.name}.png`; + } + get path() { return this.fileModel.path; } diff --git a/app/packages/files/service-file.ts b/app/packages/files/service-file.ts new file mode 100644 index 00000000000..37c38195429 --- /dev/null +++ b/app/packages/files/service-file.ts @@ -0,0 +1,288 @@ +import { getOwner, setOwner } from '@ember/application'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import { task } from 'ember-concurrency'; +import Intl from 'ember-intl/services/intl'; +import Toast from 'ember-toastr/services/toast'; +import ConfiguredStorageAddonModel from 'ember-osf-web/models/configured-storage-addon'; +import { ConnectedCapabilities } from 'ember-osf-web/models/authorized-account'; +import { ConnectedStorageOperationNames } from 'ember-osf-web/models/addon-operation-invocation'; +import FileModel from 'ember-osf-web/models/file'; +import NodeModel from 'ember-osf-web/models/node'; +import { Permission } from 'ember-osf-web/models/osf-model'; +import CurrentUserService from 'ember-osf-web/services/current-user'; +import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; +import humanFileSize from 'ember-osf-web/utils/human-file-size'; +import { ExternalServiceCapabilities } from 'ember-osf-web/models/external-service'; + + +export enum FileSortKey { + AscDateModified = 'date_modified', + DescDateModified = '-date_modified', + AscName = 'name', + DescName = '-name', +} + +// Waterbutler file version +export interface WaterButlerRevision { + id: string; + type: 'file_versions'; + attributes: { + extra: { + downloads: number, + hashes: { + md5: string, + sha256: string, + }, + user: { + name: string, + url: string, + }, + }, + version: number, + modified: Date, + modified_utc: Date, + versionIdentifier: 'version', + }; +} + +interface DataverseExtraInfo { + datasetVersion: 'latest-published' | 'latest'; + fileId: string; + hasPublishedVersion: boolean; + hashes: { + md5: string, + }; +} + +export default class ServiceFile { + @tracked fileModel: FileModel; + @tracked configuredStorageAddon: ConfiguredStorageAddonModel; + @tracked totalFileCount = 0; + @tracked waterButlerRevisions?: WaterButlerRevision[]; + @tracked userCanDownloadAsZip: boolean; + @tracked canMoveToThisProvider: boolean; + @tracked canAddOrUpdate: boolean; + @tracked isDataverse: boolean; // we have some special casing for Dataverse + shouldShowTags = false; + shouldShowRevisions: boolean; + providerHandlesVersioning: boolean; + parallelUploadsLimit = 2; + + currentUser: CurrentUserService; + @service intl!: Intl; + @service toast!: Toast; + + + constructor( + currentUser: CurrentUserService, + fileModel: FileModel, + configuredStorageAddon: ConfiguredStorageAddonModel, + ) { + setOwner(this, getOwner(fileModel)); + this.currentUser = currentUser; + this.fileModel = fileModel; + this.configuredStorageAddon = configuredStorageAddon; + this.userCanDownloadAsZip = false; + this.canMoveToThisProvider = false; + this.canAddOrUpdate = false; + this.isDataverse = false; + this.getSupportedFeatures(); + this.providerHandlesVersioning = configuredStorageAddon.connectedOperationNames + .includes(ConnectedStorageOperationNames.HasRevisions); + this.shouldShowRevisions = configuredStorageAddon.connectedOperationNames + .includes(ConnectedStorageOperationNames.HasRevisions); + this.parallelUploadsLimit = configuredStorageAddon.concurrentUploads; + } + + async getSupportedFeatures() { + const externalStorageService = await this.configuredStorageAddon.externalStorageService; + this.isDataverse = externalStorageService.get('wbKey') === 'dataverse'; + this.userCanDownloadAsZip = externalStorageService.get('supportedFeatures') + .includes(ExternalServiceCapabilities.DOWNLOAD_AS_ZIP); + this.canMoveToThisProvider = externalStorageService.get('supportedFeatures') + .includes(ExternalServiceCapabilities.COPY_INTO); + this.canAddOrUpdate = externalStorageService.get('supportedFeatures') + .includes(ExternalServiceCapabilities.ADD_UPDATE_FILES); + } + + get isFile() { + return this.fileModel.isFile; + } + + get isFolder() { + return this.fileModel.isFolder; + } + + get showAsUnviewed() { + return this.fileModel.showAsUnviewed; + } + + get size() { + return humanFileSize(this.fileModel.size); + } + + get currentUserPermission(): string { + if ( + this.fileModel.target.get('currentUserPermissions').includes(Permission.Write) && + this.configuredStorageAddon.connectedCapabilities.includes(ConnectedCapabilities.Update) && + this.canAddOrUpdate + ) { + return 'write'; + } + return 'read'; + } + + get targetIsRegistration(){ + return this.fileModel.target.get('modelName') === 'registration'; + } + + get currentUserCanDelete() { + return (this.fileModel.target.get('modelName') !== 'registration' && this.currentUserPermission === 'write'); + } + + get name() { + return this.fileModel.name; + } + + get displayName() { + if (this.isDataverse) { + const fileExtra = this.fileModel.extra as DataverseExtraInfo; + const translationKeyPrefix = 'osf-components.file-browser.provider-specific-data.dataverse.'; + const fileNameSuffix = ' ' + this.intl.t(translationKeyPrefix + fileExtra.datasetVersion); + return this.fileModel.name + fileNameSuffix; + } + return this.fileModel.name; + } + + get id() { + return this.fileModel.id; + } + + get path() { + return this.fileModel.path; + } + + get links() { + const links = this.fileModel.links; + if (this.isFolder) { + const uploadLink = new URL(links.upload as string); + uploadLink.searchParams.set('zip', ''); + + links.download = uploadLink.toString(); + } + return links; + } + + get userCanEditMetadata() { + return this.fileModel.target.get('currentUserPermissions').includes(Permission.Write); + } + + get dateModified() { + return this.fileModel.dateModified; + } + + get userCanMoveToHere() { + return ( + this.currentUserPermission === 'write' && + this.canMoveToThisProvider && + this.fileModel.target.get('modelName') !== 'registration' && + this.isFolder + ); + } + + get userCanUploadToHere() { + return ( + this.currentUserPermission === 'write' && + this.fileModel.target.get('modelName') !== 'registration' && + this.isFolder + ); + } + + get userCanDeleteFromHere() { + return ( + this.isFolder && + this.currentUserPermission === 'write' && + this.fileModel.target.get('modelName') !== 'registration' + ); + } + + get isBoaFile() { + return this.fileModel.name.endsWith('.boa'); + } + + get providerIsOsfstorage() { + return false; + } + + async createFolder(newFolderName: string) { + if (this.fileModel.isFolder) { + await this.fileModel.createFolder(newFolderName); + } + } + + async getFolderItems(page: number, sort: FileSortKey, filter: string ) { + if (this.fileModel.isFolder) { + try { + const queryResult = await this.fileModel.queryHasMany('files', + { + page, + sort, + 'filter[name]': filter, + }); + this.totalFileCount = queryResult.meta.total; + return queryResult.map(fileModel => Reflect.construct(this.constructor, [ + this.currentUser, + fileModel, + this.configuredStorageAddon, + ])); + } catch (e) { + const errorMessage = this.intl.t( + 'osf-components.file-browser.errors.load_file_list', + ); + captureException(e, { errorMessage }); + this.toast.error(getApiErrorMessage(e), errorMessage); + return []; + } + } + return []; + } + + async updateContents(data: string) { + await this.fileModel.updateContents(data); + } + + async rename(newName: string, conflict = 'replace') { + await this.fileModel.rename(newName, conflict); + } + + @task + @waitFor + async move(node: NodeModel, path: string, provider: string, options?: { conflict: string }) { + return await this.fileModel.move(node, path, provider, options); + } + + @task + @waitFor + async copy(node: NodeModel, path: string, provider: string, options?: { conflict: string }) { + return await this.fileModel.copy(node, path, provider, options); + } + + @task + @waitFor + async delete() { + return await this.fileModel.delete(); + } + + @task + @waitFor + async getRevisions() { + const revisionsLink = new URL(this.links.upload as string); + revisionsLink.searchParams.set('revisions', ''); + + const responseObject = await this.currentUser.authenticatedAJAX({ url: revisionsLink.toString() }); + this.waterButlerRevisions = responseObject.data; + return this.waterButlerRevisions; + } +} diff --git a/app/packages/files/service-provider-file.ts b/app/packages/files/service-provider-file.ts new file mode 100644 index 00000000000..15828f02cbb --- /dev/null +++ b/app/packages/files/service-provider-file.ts @@ -0,0 +1,156 @@ +import { getOwner, setOwner } from '@ember/application'; +import { inject as service } from '@ember/service'; + +import Intl from 'ember-intl/services/intl'; +import Toast from 'ember-toastr/services/toast'; + +import { tracked } from '@glimmer/tracking'; +import FileProviderModel from 'ember-osf-web/models/file-provider'; +import { Permission } from 'ember-osf-web/models/osf-model'; +import { FileSortKey } from 'ember-osf-web/packages/files/file'; +import CurrentUserService from 'ember-osf-web/services/current-user'; +import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; +import { ErrorDocument } from 'osf-api'; +import ConfiguredStorageAddonModel from 'ember-osf-web/models/configured-storage-addon'; +import { ConnectedCapabilities } from 'ember-osf-web/models/authorized-account'; +import { ConnectedStorageOperationNames } from 'ember-osf-web/models/addon-operation-invocation'; +import ServiceFile from 'ember-osf-web/packages/files/service-file'; +import { ExternalServiceCapabilities } from 'ember-osf-web/models/external-service'; + +export default class ServiceProviderFile { + @tracked fileModel: FileProviderModel; + @tracked configuredStorageAddon: ConfiguredStorageAddonModel; + @tracked totalFileCount = 0; + @tracked userCanDownloadAsZip: boolean; + @tracked canMoveToThisProvider: boolean; + @tracked canAddOrUpdate: boolean; + providerHandlesVersioning: boolean; + parallelUploadsLimit = 2; + + currentUser: CurrentUserService; + @service intl!: Intl; + @service toast!: Toast; + + constructor( + currentUser: CurrentUserService, + fileModel: FileProviderModel, + configuredStorageAddon: ConfiguredStorageAddonModel, + ) { + setOwner(this, getOwner(fileModel)); + this.currentUser = currentUser; + this.fileModel = fileModel; + this.configuredStorageAddon = configuredStorageAddon; + this.userCanDownloadAsZip = false; + this.canMoveToThisProvider = false; + this.canAddOrUpdate = false; + this.getSupportedFeatures(); + this.providerHandlesVersioning = configuredStorageAddon.connectedOperationNames + .includes(ConnectedStorageOperationNames.HasRevisions); + this.parallelUploadsLimit = configuredStorageAddon.concurrentUploads; + } + + async getSupportedFeatures() { + const externalStorageService = await this.configuredStorageAddon.externalStorageService; + this.userCanDownloadAsZip = externalStorageService.get('supportedFeatures') + .includes(ExternalServiceCapabilities.DOWNLOAD_AS_ZIP); + this.canMoveToThisProvider = externalStorageService.get('supportedFeatures') + .includes(ExternalServiceCapabilities.COPY_INTO); + this.canAddOrUpdate = externalStorageService.get('supportedFeatures') + .includes(ExternalServiceCapabilities.ADD_UPDATE_FILES); + } + + get id() { + return this.fileModel.id; + } + + get isFile() { + return false; + } + + get isFolder() { + return true; + } + + get currentUserPermission(): string { + if ( + this.fileModel.target.get('currentUserPermissions').includes(Permission.Write) && + this.configuredStorageAddon.connectedCapabilities.includes(ConnectedCapabilities.Update) && + this.canAddOrUpdate + ) { + return 'write'; + } + return 'read'; + } + + get userCanUploadToHere() { + return ( + this.currentUserPermission === 'write' && + this.fileModel.target.get('modelName') !== 'registration' + ); + } + + get userCanMoveToHere() { + return ( + this.currentUserPermission === 'write' && + this.canMoveToThisProvider && + this.fileModel.target.get('modelName') !== 'registration' + ); + } + + get userCanDeleteFromHere() { + return ( + this.isFolder && + this.currentUserPermission === 'write' && + this.fileModel.target.get('modelName') !== 'registration' + ); + } + + get name() { + return this.configuredStorageAddon.externalStorageService.get('wbKey'); + } + + get iconLocation() { + return this.configuredStorageAddon.externalStorageService.get('iconUrl'); + } + + get path() { + return this.fileModel.path; + } + + get links() { + const links = this.fileModel.links; + const uploadLink = new URL(links.upload as string); + uploadLink.searchParams.set('zip', ''); + + links.download = uploadLink.toString(); + return links; + } + + async createFolder(newFolderName: string) { + await this.fileModel.createFolder(newFolderName); + } + + async getFolderItems(page: number, sort: FileSortKey, filter: string ) { + const queryResult = await this.fileModel.queryHasMany('files', + { + page, + sort, + 'filter[name]': filter, + }); + this.totalFileCount = queryResult.meta.total; + return queryResult.map(fileModel => new ServiceFile( + this.currentUser, + fileModel, + this.configuredStorageAddon, + )); + } + + handleFetchError(e: ErrorDocument) { + const errorMessage = this.intl.t( + 'osf-components.file-browser.errors.load_file_list', + ); + captureException(e, { errorMessage }); + this.toast.error(getApiErrorMessage(e), errorMessage); + return []; + } +} diff --git a/app/router.ts b/app/router.ts index 8aa10d2343e..5250bde8670 100644 --- a/app/router.ts +++ b/app/router.ts @@ -57,6 +57,7 @@ Router.map(function() { this.route('create'); }); this.route('account'); + this.route('addons'); this.route('tokens', function() { this.route('edit', { path: '/:token_id' }); this.route('create'); @@ -96,6 +97,15 @@ Router.map(function() { this.route('drafts', { path: '/drafts/:draftId' }, function() { this.route('register'); }); + this.route('addons', function() { + this.route('index', { path: '/' }); + this.route('addon', { path: '/:addonId' }, function() { + this.route('terms'); + this.route('account'); + this.route('confirm'); + this.route('configure'); + }); + }); }); diff --git a/app/serializers/addon-operation-invocation.ts b/app/serializers/addon-operation-invocation.ts new file mode 100644 index 00000000000..e26130c55b3 --- /dev/null +++ b/app/serializers/addon-operation-invocation.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class AddonOperationInvocationSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'addon-operation-invocation': AddonOperationInvocationSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/authorized-citation-account.ts b/app/serializers/authorized-citation-account.ts new file mode 100644 index 00000000000..12fe3931fab --- /dev/null +++ b/app/serializers/authorized-citation-account.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class AuthorizedCitationAccountSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'authorized-citation-account': AuthorizedCitationAccountSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/authorized-computing-account.ts b/app/serializers/authorized-computing-account.ts new file mode 100644 index 00000000000..0114bb49c3a --- /dev/null +++ b/app/serializers/authorized-computing-account.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class AuthorizedComputingAccountSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'authorized-computing-account': AuthorizedComputingAccountSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/authorized-storage-account.ts b/app/serializers/authorized-storage-account.ts new file mode 100644 index 00000000000..a0ebf9e8d00 --- /dev/null +++ b/app/serializers/authorized-storage-account.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class AuthorizedStorageAccountSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'authorized-storage-account': AuthorizedStorageAccountSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/configured-addon.ts b/app/serializers/configured-addon.ts new file mode 100644 index 00000000000..7c6ba8306f1 --- /dev/null +++ b/app/serializers/configured-addon.ts @@ -0,0 +1,14 @@ +import DS from 'ember-data'; +import { SingleResourceDocument } from 'osf-api'; + +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class ConfiguredAddonSerializer extends GravyValetSerializer { + serialize(snapshot: DS.Snapshot, options: {}) { + const serialized = super.serialize(snapshot, options) as SingleResourceDocument; + if (!serialized.data.attributes!.authorized_resource_uri) { + delete serialized.data.attributes!.authorized_resource_uri; + } + return serialized; + } +} diff --git a/app/serializers/configured-citation-addon.ts b/app/serializers/configured-citation-addon.ts new file mode 100644 index 00000000000..d20e81b2dd8 --- /dev/null +++ b/app/serializers/configured-citation-addon.ts @@ -0,0 +1,10 @@ +import ConfiguredAddonSerializer from './configured-addon'; + +export default class ConfiguredCitationAddonSerializer extends ConfiguredAddonSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'configured-citation-addon': ConfiguredCitationAddonSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/configured-computing-addon.ts b/app/serializers/configured-computing-addon.ts new file mode 100644 index 00000000000..e0d2dd112b6 --- /dev/null +++ b/app/serializers/configured-computing-addon.ts @@ -0,0 +1,10 @@ +import ConfiguredAddonSerializer from './configured-addon'; + +export default class ConfiguredComputingAddonSerializer extends ConfiguredAddonSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'configured-computing-addon': ConfiguredComputingAddonSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/configured-storage-addon.ts b/app/serializers/configured-storage-addon.ts new file mode 100644 index 00000000000..85df67c5b82 --- /dev/null +++ b/app/serializers/configured-storage-addon.ts @@ -0,0 +1,10 @@ +import ConfiguredAddonSerializer from './configured-addon'; + +export default class ConfiguredStorageAddonSerializer extends ConfiguredAddonSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'configured-storage-addon': ConfiguredStorageAddonSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/external-citation-service.ts b/app/serializers/external-citation-service.ts new file mode 100644 index 00000000000..d20ce6a5d2a --- /dev/null +++ b/app/serializers/external-citation-service.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class ExternalCitationServiceSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'external-citation-service': ExternalCitationServiceSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/external-computing-service.ts b/app/serializers/external-computing-service.ts new file mode 100644 index 00000000000..94434927f84 --- /dev/null +++ b/app/serializers/external-computing-service.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class ExternalComputingServiceSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'external-computing-service': ExternalComputingServiceSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/external-storage-service.ts b/app/serializers/external-storage-service.ts new file mode 100644 index 00000000000..ae9c64758fe --- /dev/null +++ b/app/serializers/external-storage-service.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class ExternalStorageServiceSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'external-storage-service': ExternalStorageServiceSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/gravy-valet-serializer.ts b/app/serializers/gravy-valet-serializer.ts new file mode 100644 index 00000000000..beebd5c51b3 --- /dev/null +++ b/app/serializers/gravy-valet-serializer.ts @@ -0,0 +1,12 @@ +import JSONAPISerializer from '@ember-data/serializer/json-api'; +import { underscore } from '@ember/string'; + +export default class GravyValetSerializer extends JSONAPISerializer { + keyForAttribute(key: string) { + return underscore(key); + } + + keyForRelationship(key: string) { + return underscore(key); + } +} diff --git a/app/serializers/resource-reference.ts b/app/serializers/resource-reference.ts new file mode 100644 index 00000000000..0eccfd16f14 --- /dev/null +++ b/app/serializers/resource-reference.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class ResourceReferenceSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'resource-reference': ResourceReferenceSerializer; + } // eslint-disable-line semi +} diff --git a/app/serializers/user-reference.ts b/app/serializers/user-reference.ts new file mode 100644 index 00000000000..d6fa7092ad9 --- /dev/null +++ b/app/serializers/user-reference.ts @@ -0,0 +1,10 @@ +import GravyValetSerializer from './gravy-valet-serializer'; + +export default class UserReferenceSerializer extends GravyValetSerializer { +} + +declare module 'ember-data/types/registries/serializer' { + export default interface SerializerRegistry { + 'user-reference': UserReferenceSerializer; + } // eslint-disable-line semi +} diff --git a/app/settings/account/-components/security/styles.scss b/app/settings/account/-components/security/styles.scss index 0adb33fb348..4e59dd41d28 100644 --- a/app/settings/account/-components/security/styles.scss +++ b/app/settings/account/-components/security/styles.scss @@ -16,7 +16,7 @@ } .bg-danger { - background-color: #f2dede; + background-color: $color-bg-danger; } .scan-image code { diff --git a/app/settings/addons/controller.ts b/app/settings/addons/controller.ts new file mode 100644 index 00000000000..488b2e85900 --- /dev/null +++ b/app/settings/addons/controller.ts @@ -0,0 +1,8 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +import CurrentUser from 'ember-osf-web/services/current-user'; + +export default class SettingsAddonsController extends Controller { + @service currentUser!: CurrentUser; +} diff --git a/app/settings/addons/route.ts b/app/settings/addons/route.ts new file mode 100644 index 00000000000..ce5c7df8022 --- /dev/null +++ b/app/settings/addons/route.ts @@ -0,0 +1,4 @@ +import Route from '@ember/routing/route'; + +export default class SettingsAddonsRoute extends Route { +} diff --git a/app/settings/addons/styles.scss b/app/settings/addons/styles.scss new file mode 100644 index 00000000000..c5203c490f0 --- /dev/null +++ b/app/settings/addons/styles.scss @@ -0,0 +1,119 @@ +.configured-addons-wrapper { + width: 100%; + display: flex; + flex-direction: column; + padding-inline-start: 0; +} + +.addons-list-wrapper { + display: flex; + + &.mobile { + width: 100%; + flex-direction: column; + flex-wrap: wrap; + } +} + +.filter-wrapper { + max-width: 200px; + display: flex; + flex-direction: column; + margin-top: 6px; + + &.mobile { + max-width: 100%; + width: 100%; + margin-right: 20px; + } +} + +.filter-button { + width: 100%; + height: 40px; + text-align: left; + padding: 0 10px; + + &.active { + background-color: $color-light; + } +} + +.addons-card-wrapper { + padding-inline-start: 0; + flex-grow: 1; + display: flex; + flex-wrap: wrap; + width: 100%; + justify-content: center; +} + +.provider-list-item { + padding: 15px 10px; + border-bottom: solid 1px $color-border-gray; + width: 100%; + display: flex; + justify-content: space-between; + + &:last-child { + border-bottom: 0; + } +} + +.button-wrapper { + text-align: right; + min-width: fit-content; +} + +.float-right { + float: right; +} + +.tab-wrapper { + display: flex; + + &.mobile { + flex-direction: column; + } + + :global(.ember-tabs) { + flex-grow: 1; + } +} + +.tab-list { + margin-bottom: 10px; + border-bottom: 1px solid $color-border-gray; + box-sizing: border-box; + color: $color-grey; + display: block; + line-height: 20px; + list-style-image: none; + list-style-position: outside; + list-style-type: none; + height: 41px; + padding: 0; +} + +.tab-list { + li { + cursor: pointer; + display: block; + position: relative; + margin-bottom: -1px; + float: left; + height: 41px; + padding: 10px 15px; + } + + li:global(.ember-tabs__tab--selected) { + background-color: $bg-light; + border-bottom: 2px solid $color-blue; + } + + li:hover { + text-decoration: none; + background-color: $bg-light; + color: var(--primary-color); + } +} diff --git a/app/settings/addons/template.hbs b/app/settings/addons/template.hbs new file mode 100644 index 00000000000..cfb8f68fbf8 --- /dev/null +++ b/app/settings/addons/template.hbs @@ -0,0 +1,186 @@ +{{page-title (t 'settings.addons.page-title')}} +
+ + {{#if manager.currentListIsLoading}} + + {{else}} + {{#if manager.selectedProvider}} +
+ + {{#if (eq manager.pageMode 'terms')}} +
+

+ {{t 'addons.terms.heading' providerName=manager.selectedProvider.provider.displayName}} +

+ +
+ + +
+
+ {{else if (eq manager.pageMode 'accountCreate')}} +
+

+ {{t 'addons.accountSelect.new-account'}} +

+ +
+ {{else if (eq manager.pageMode 'accountReconnect')}} +
+

+ {{t 'addons.accountSelect.reconnect-account'}} +

+ +
+ {{/if}} +
+ {{else}} +
+
+ + {{#each manager.possibleFilterTypes as |type|}} + + {{/each}} +
+ + + + {{t 'addons.list.all-addons'}} + + + {{t 'addons.list.connected-accounts'}} + + + +
    + {{#each manager.filteredAddonProviders as |provider|}} +
  • + {{provider.displayName}} + + + +
  • + {{else}} +
  • {{t 'addons.list.no-results'}}
  • + {{/each}} +
+
+ +
    + {{#each manager.currentTypeAuthorizedAccounts as |account|}} +
  • + + {{account.displayName}} + + + + + +
  • + {{else}} +
  • {{t 'addons.list.no-connected-accounts'}}
  • + {{/each}} +
+
+
+
+ {{/if}} + {{/if}} +
+
diff --git a/app/settings/template.hbs b/app/settings/template.hbs index 8014f7c9626..52395e4456c 100644 --- a/app/settings/template.hbs +++ b/app/settings/template.hbs @@ -39,14 +39,24 @@ {{t 'settings.account.title'}} -
  • - - {{t 'settings.addons.title'}} - +
  • + {{#if (feature-flag 'gravy_waffle')}} + + {{t 'settings.addons.title'}} + + {{else}} + + {{t 'settings.addons.title'}} + + {{/if}}
  • ( ); } -export function camelizeKeys(obj: Partial>) { +export function camelizeKeys(obj: Partial>, recursive = false): any { return mapKeysAndValues( obj, key => camelize(key), - value => value, + value => recursive ? _recurseKeys(value, camelizeKeys) : value, ); } -export function snakifyKeys(obj: Record) { +export function snakifyKeys(obj: Partial>, recursive = false): any { return mapKeysAndValues( obj, key => underscore(key), - value => value, + value => recursive ? _recurseKeys(value, snakifyKeys) : value, ); } + +function _recurseKeys(value: any, keyMap: typeof camelizeKeys | typeof snakifyKeys) { + if (Array.isArray(value)) { + return value.map(_item => keyMap(_item, true)); + } else if (value !== null && typeof value === 'object') { + return keyMap(value, true); + } + return value; +} diff --git a/config/environment.js b/config/environment.js index 6de620bf12b..4d9c47cf2e1 100644 --- a/config/environment.js +++ b/config/environment.js @@ -81,6 +81,8 @@ const { OSF_RENDER_URL: renderUrl = 'http://localhost:7778/render', OSF_MFR_URL: mfrUrl = 'http://localhost:4200', OSF_FILE_URL: waterbutlerUrl = 'http://localhost:7777/', + // TODO: where shold this actually go? + ADDON_SERVICE_URL: addonServiceUrl = 'http://localhost:8004/', OSF_HELP_URL: helpUrl = 'http://localhost:4200/help', OSF_AUTHENTICATOR: osfAuthenticator = 'osf-cookie', PLAUDIT_WIDGET_URL: plauditWidgetUrl = 'https://osf-review.plaudit.pub/embed/endorsements.js', @@ -94,9 +96,8 @@ const { RECAPTCHA_SITE_KEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', REDIRECT_URI: redirectUri, ROOT_URL: rootURL = '/', - SHARE_BASE_URL: shareBaseUrl = 'https://staging-share.osf.io/', - SHARE_API_URL: shareApiUrl = 'https://staging-share.osf.io/api/v2', - SHARE_SEARCH_URL: shareSearchUrl = 'https://staging-share.osf.io/api/v2/search/creativeworks/_search', + SHARE_BASE_URL: shareBaseUrl = 'http://localhost:8003/', + SHARE_SEARCH_URL: shareSearchUrl = 'http://localhost:8003/api/v2/search/creativeworks/_search', SOURCEMAPS_ENABLED: sourcemapsEnabled = true, SHOW_DEV_BANNER = false, } = { ...process.env, ...localConfig }; @@ -188,9 +189,9 @@ module.exports = function(environment) { renderUrl, mfrUrl, waterbutlerUrl, + addonServiceUrl, helpUrl, shareBaseUrl, - shareApiUrl, shareSearchUrl, devMode, metricsStartDate, @@ -269,6 +270,7 @@ module.exports = function(environment) { 'guid-node.index': 'ember_project_detail_page', 'guid-node.drafts.index': 'ember_edit_draft_registration_page', 'guid-node.drafts.register': 'ember_edit_draft_registration_page', + 'guid-node.addons': 'gravy_waffle', 'guid-user.index': 'ember_user_profile_page', 'guid-registration.index': 'ember_old_registration_detail_page', settings: 'ember_user_settings_page', @@ -286,6 +288,7 @@ module.exports = function(environment) { 'settings.developer-apps.index': 'ember_user_settings_apps_page', 'settings.developer-apps.create': 'ember_user_settings_apps_page', 'settings.developer-apps.edit': 'ember_user_settings_apps_page', + 'settings.addons': 'gravy_waffle', register: 'ember_auth_register', 'registries.overview': 'ember_registries_detail_page', 'registries.overview.index': 'ember_registries_detail_page', @@ -308,6 +311,7 @@ module.exports = function(environment) { institutions: 'institutions_nav_bar', }, storageI18n: 'storage_i18n', + gravyWaffle: 'gravy_waffle', enableInactiveSchemas: 'enable_inactive_schemas', verifyEmailModals: 'ember_verify_email_modals', ABTesting: { diff --git a/lib/collections/addon/components/discover-page/component.ts b/lib/collections/addon/components/discover-page/component.ts index 6ef8c2a8115..9bc0db0f34e 100644 --- a/lib/collections/addon/components/discover-page/component.ts +++ b/lib/collections/addon/components/discover-page/component.ts @@ -10,7 +10,6 @@ import { camelize } from '@ember/string'; import { waitFor } from '@ember/test-waiters'; import { keepLatestTask, timeout } from 'ember-concurrency'; import { taskFor } from 'ember-concurrency-ts'; -import config from 'ember-osf-web/config/environment'; import Media from 'ember-responsive'; import { layout } from 'ember-osf-web/decorators/component'; @@ -226,12 +225,6 @@ export default class DiscoverPage extends Component { return this.totalPages < maxPages ? this.totalPages : maxPages; } - @computed('currentUser.sessionKey') - get searchUrl() { - // Pulls SHARE search url from config file. - return `${config.OSF.shareSearchUrl}?preference=${this.currentUser.sessionKey}`; - } - // Total pages of search results @computed('numberOfResults', 'size') get totalPages(): number { diff --git a/lib/collections/config/environment.d.ts b/lib/collections/config/environment.d.ts index f59b21682e8..e79fe9a370a 100644 --- a/lib/collections/config/environment.d.ts +++ b/lib/collections/config/environment.d.ts @@ -3,7 +3,6 @@ declare const config: { hostAppName: string; modulePrefix: string; OSF: { - shareSearchUrl: string; url: string; }, whiteListedProviders: string[]; diff --git a/lib/collections/config/environment.js b/lib/collections/config/environment.js index a3cdcb7cc68..7a178bb57fa 100644 --- a/lib/collections/config/environment.js +++ b/lib/collections/config/environment.js @@ -6,7 +6,6 @@ module.exports = function(environment) { hostAppName: 'collections', modulePrefix: 'collections', OSF: { - shareSearchUrl: 'https://share.osf.io/api/v2/search/creativeworks/_search', url: 'http://localhost:5000/', }, whiteListedProviders: [ diff --git a/lib/osf-components/addon/components/addon-card/component.ts b/lib/osf-components/addon/components/addon-card/component.ts new file mode 100644 index 00000000000..47589ac0235 --- /dev/null +++ b/lib/osf-components/addon/components/addon-card/component.ts @@ -0,0 +1,24 @@ +import Component from '@glimmer/component'; + +import Provider from 'ember-osf-web/packages/addons-service/provider'; +import AddonsServiceManagerComponent from 'osf-components/components/addons-service/manager/component'; + +interface Args { + addon: Provider; + manager: AddonsServiceManagerComponent; +} + +export default class AddonsCardComponent extends Component { + + get assetLogo() { + return this.args.addon.provider.iconUrl; + } + + get addonIsConfigured() { + return this.args.addon.isConfigured; + } + + get addonIsOwned() { + return this.args.addon.isOwned; + } +} diff --git a/lib/osf-components/addon/components/addon-card/styles.scss b/lib/osf-components/addon/components/addon-card/styles.scss new file mode 100644 index 00000000000..a29d774c20a --- /dev/null +++ b/lib/osf-components/addon/components/addon-card/styles.scss @@ -0,0 +1,47 @@ +.card-wrapper { + min-width: 208px; + border: solid 1px $color-border-gray; + margin: 5px 3px; + padding: 20px; + text-align: center; +} + +.addon-card-logo { + height: 36px; +} + +.provider-name { + font-weight: bold; + margin: 15px 0; +} + +.button-looking-links { + composes: Button from '../button/styles.scss'; + composes: MediumButton from '../button/styles.scss'; + composes: SecondaryButton from '../button/styles.scss'; +} + +.buttons-wrapper { + display: flex; + justify-content: center; + + .enable { + color: darken($brand-success, 10%); + } + + .configure { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + + .disconnect { + color: darken($brand-danger, 10%); + border-left: 0; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/lib/osf-components/addon/components/addon-card/template.hbs b/lib/osf-components/addon/components/addon-card/template.hbs new file mode 100644 index 00000000000..c9f61c7bbfe --- /dev/null +++ b/lib/osf-components/addon/components/addon-card/template.hbs @@ -0,0 +1,40 @@ +
    + {{t +
    + {{@addon.provider.displayName}} +
    + {{#if this.addonIsOwned}} +
    + {{#if this.addonIsConfigured}} + + {{else}} + + {{/if}} +
    + {{/if}} +
    + diff --git a/lib/osf-components/addon/components/addons-service/addon-account-setup/component.ts b/lib/osf-components/addon/components/addons-service/addon-account-setup/component.ts new file mode 100644 index 00000000000..b222eeb9f79 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/addon-account-setup/component.ts @@ -0,0 +1,289 @@ +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import IntlService from 'ember-intl/services/intl'; +import Toast from 'ember-toastr/services/toast'; + +import AddonsServiceManagerComponent from 'ember-osf-web/components/addons-service/manager/component'; +import UserAddonsManagerComponent from 'ember-osf-web/components/addons-service/user-addons-manager/component'; +import { AddonCredentialFields } from 'ember-osf-web/models/authorized-account'; +import { CredentialsFormat } from 'ember-osf-web/models/external-service'; +import ExternalStorageServiceModel from 'ember-osf-web/models/external-storage-service'; +import { AllAuthorizedAccountTypes } from 'ember-osf-web/packages/addons-service/provider'; +import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; + +interface InputFieldObject { + name: keyof AddonCredentialFields; + labelText: string; + inputType: string; + inputPlaceholder: string; + inputValue?: string; + autocomplete?: string; + postText?: string; +} + +interface Args { + onConnect: () => void; + onReconnect: () => void; + account?: AllAuthorizedAccountTypes; + provider: ExternalStorageServiceModel; // Type? + manager: AddonsServiceManagerComponent | UserAddonsManagerComponent; +} + +export default class AddonAccountSetupComponent extends Component { + @service intl!: IntlService; + @service toast!: Toast; + + @tracked selectedRepo?: string; + @tracked otherRepo?: string; + @tracked url?: string = this.args.account?.apiBaseUrl; + @tracked newAccount?: AllAuthorizedAccountTypes; + @tracked pendingOauth = false; + @tracked credentialsObject: AddonCredentialFields = {}; + @tracked displayName = this.args.account?.displayName || this.args.provider.displayName; + @tracked connectAccountError = false; + + get useOauth() { + return [CredentialsFormat.OAUTH, CredentialsFormat.OAUTH2].includes(this.args.provider.credentialsFormat); + } + + get showUrlField() { + return this.args.provider.configurableApiRoot; + } + + get isConnectAvailable() { + return this.credentialValues.reduce( + (previousValue, currentValue) => previousValue && Boolean(currentValue), + true, + ) && this.displayName && (!this.showUrlField || this.url); + } + + get credentialValues() { + return this.inputFields.map( + field => this.credentialsObject[field.name as keyof AddonCredentialFields], + ).toArray(); + } + + otherRepoLabel = this.intl.t('addons.accountCreate.other-repo-label'); + + get showRepoOptions() { + const { provider } = this.args; + return provider.apiBaseUrlOptions; + } + + get repoOptions() { + const repoSpecificOptions = this.args.provider.apiBaseUrlOptions || []; + return [...repoSpecificOptions, this.otherRepoLabel]; + } + + get otherRepoSelected() { + return this.selectedRepo === this.otherRepoLabel; + } + + get repoOtherPostText() { + if (this.args.provider.id === 'dataverse') { + return this.intl.t('addons.accountCreate.dataverse-repo-other-post-text'); + } else if (this.args.provider.id === 'gitlab') { + return this.intl.t('addons.accountCreate.gitlab-repo-other-post-text'); + } + return ''; + } + + @action + onRepoChange(newRepo: string) { + this.selectedRepo = newRepo; + } + + @action + inputFieldChanged(event: Event) { + const { name, value } = event.target as HTMLInputElement; + this.credentialsObject[name as keyof AddonCredentialFields] = value; + this.credentialsObject = {...this.credentialsObject}; + } + + get inputFields(): InputFieldObject[] { + const t = this.intl.t.bind(this.intl); + switch (this.args.provider.credentialsFormat) { + case CredentialsFormat.USERNAME_PASSWORD: { + const passwordPostText = t('addons.accountCreate.password-post-text'); + return [ + { + name: 'username', + labelText: t('addons.accountCreate.username-label'), + inputType: 'text', + inputPlaceholder: t('addons.accountCreate.username-placeholder'), + autocomplete: 'username', + }, + { + name: 'password', + labelText: t('addons.accountCreate.password-label'), + inputType: 'password', + inputPlaceholder: t('addons.accountCreate.password-placeholder'), + autocomplete: 'current-password', + postText: passwordPostText, + }, + ]; + } + case CredentialsFormat.REPO_TOKEN: { + return [ + { + name: 'access_token', + labelText: t('addons.accountCreate.api-token-label'), + inputType: 'password', + inputPlaceholder: t('addons.accountCreate.api-token-placeholder'), + }, + ]; + } + case CredentialsFormat.DATAVERSE_API_TOKEN: { + return [ + { + name: 'access_token', + labelText: t('addons.accountCreate.personal-access-token-label'), + inputType: 'password', + inputPlaceholder: t('addons.accountCreate.personal-access-token-placeholder'), + }, + ]; + } + case CredentialsFormat.ACCESS_SECRET_KEYS: { + return [ + { + name: 'access_key', + labelText: t('addons.accountCreate.access-key-label'), + inputType: 'text', + inputPlaceholder: t('addons.accountCreate.access-key-placeholder'), + }, + { + name: 'secret_key', + labelText: t('addons.accountCreate.secret-key-label'), + inputType: 'password', + inputPlaceholder: t('addons.accountCreate.secret-key-placeholder'), + }, + ]; + } + default: // OAUTH, OAUTH2 + return []; + } + } + + @task + @waitFor + async connectAccount() { + const { manager } = this.args; + const credentials = this.credentialsObject; + let apiBaseUrl; + if (this.showUrlField) { + apiBaseUrl = this.url; + } else if (this.showRepoOptions) { + apiBaseUrl = this.otherRepoSelected ? this.otherRepo : this.selectedRepo; + } + const accountCreationArgs = { + credentials, + apiBaseUrl, + displayName: this.displayName, + initiateOauth: this.useOauth, + }; + try { + await taskFor(manager.connectAccount).unlinked().perform(accountCreationArgs); + this.toast.success(this.intl.t('addons.accountCreate.connect-success')); + } catch (e) { + this.connectAccountError = true; + const errorMessage = this.intl.t('addons.accountCreate.error'); + captureException(e, { errorMessage }); + this.toast.error(getApiErrorMessage(e), errorMessage); + } + } + + @task + @waitFor + async reconnectAccount() { + const { manager } = this.args; + const credentials = this.credentialsObject; + let apiBaseUrl; + if (this.showUrlField) { + apiBaseUrl = this.url; + } else if (this.showRepoOptions) { + apiBaseUrl = this.otherRepoSelected ? this.otherRepo : this.selectedRepo; + } + const accountCreationArgs = { + credentials, + apiBaseUrl, + displayName: this.displayName, + initiateOauth: this.useOauth, + }; + try { + await taskFor((manager as UserAddonsManagerComponent).reconnectAccount).perform(accountCreationArgs); + this.toast.success(this.intl.t('addons.accountCreate.reconnect-success')); + } catch (e) { + const errorMessage = this.intl.t('addons.accountCreate.reconnect-error'); + captureException(e, { errorMessage }); + this.toast.error(getApiErrorMessage(e), errorMessage); + } + } + + @action + onVisibilityChange() { + if (document.visibilityState === 'visible') { + taskFor(this.checkOauthSuccess).perform(); + } + } + + @task + @waitFor + async checkOauthSuccess() { + const accountToCheck = this.args.account || this.newAccount; + const oauthSuccesful = await taskFor(this.args.manager.oauthFlowRefocus).perform(accountToCheck!); + if (oauthSuccesful) { + this.pendingOauth = false; + document.removeEventListener('visibilitychange', this.onVisibilityChange); + } else { + this.connectAccountError = true; + } + } + + @task + @waitFor + async startOauthFlow() { + this.newAccount = await taskFor(this.args.manager.createAuthorizedAccount).perform({ + displayName: this.displayName, + initiateOauth: true, + apiBaseUrl: '', + }); + if (this.newAccount) { // returned account should have authUrl + this.pendingOauth = true; + window.open(this.newAccount.authUrl, '_blank'); + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + } + + @task + @waitFor + async startOauthReconnectFlow() { + const { account } = this.args; + if (account) { + if (!account.authUrl) { + account.initiateOauth = true; + account.displayName = this.displayName; + await account.save(); // returned account should have authUrl + } + + if (account.authUrl) { + this.pendingOauth = true; + window.open(account.authUrl, '_blank'); + document.addEventListener('visibilitychange', this.onVisibilityChange); + } else { + this.toast.error(this.intl.t('addons.accountCreate.oauth-reconnect-error')); + } + } + } + + willDestroy() { + if (!this.args.account && this.newAccount && !this.newAccount.credentialsAvailable) { + this.newAccount.deleteRecord(); + this.newAccount.save(); + } + } +} diff --git a/lib/osf-components/addon/components/addons-service/addon-account-setup/styles.scss b/lib/osf-components/addon/components/addons-service/addon-account-setup/styles.scss new file mode 100644 index 00000000000..e503f8cfa56 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/addon-account-setup/styles.scss @@ -0,0 +1,24 @@ +.oauth-wrapper { + margin-top: 10px; +} + +.auth-text-input-label { + display: block; +} + +.repo-select { + width: 250px; +} + +.connect-error { + color: $brand-danger; +} + +.post-text { + display: block; +} + +.input { + margin-bottom: 20px; + margin-top: 5px; +} diff --git a/lib/osf-components/addon/components/addons-service/addon-account-setup/template.hbs b/lib/osf-components/addon/components/addons-service/addon-account-setup/template.hbs new file mode 100644 index 00000000000..086a9b690ee --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/addon-account-setup/template.hbs @@ -0,0 +1,182 @@ +
    + {{#if this.verifyingCredentials.isRunning}} + + {{else}} + {{#if this.useOauth}} + {{#if this.pendingOauth}} +

    + {{t 'addons.accountCreate.oauth-pending'}} + {{t 'addons.accountCreate.oauth-pending-secondary' htmlSafe=true}} +

    + + {{t 'addons.accountCreate.oauth-start'}} + + {{#if this.connectAccountError}} +

    + {{t 'addons.accountCreate.oauth-error' htmlSafe=true}} +

    + {{/if}} + {{else}} +
    + {{#let (unique-id 'display-name-label') as |displayNameId|}} + +
    + {{t 'addons.accountCreate.display-name-help'}} +
    + + {{/let}} +
    + + +
    +
    + {{/if}} + {{else}} +
    + {{#if this.showRepoOptions}} + + {{#if this.otherRepoSelected}} + + {{#if this.repoOtherPostText}} +

    + {{this.repoOtherPostText}} +

    + {{/if}} + {{/if}} + {{else if this.showUrlField}} + {{#let (unique-id 'url') as |url-id|}} + +
    + {{t 'addons.accountCreate.url-post-text' htmlSafe=true }} +
    + + {{/let}} + {{/if}} + {{#each this.inputFields as |field|}} + {{#let (unique-id (field.name)) as |id|}} + +
    + {{field.postText}} +
    + + {{/let}} + {{/each}} + {{#let (unique-id 'displayNameField') as |id|}} + +
    + {{t 'addons.accountCreate.display-name-help'}} +
    + + {{/let}} + {{#if this.connectAccountError}} +

    + {{t 'addons.accountCreate.error'}} +

    + {{/if}} +
    + + {{#if @account}} + + {{else}} + + {{/if}} +
    +
    + {{/if}} + {{/if}} +
    diff --git a/lib/osf-components/addon/components/addons-service/configured-addon-edit/component.ts b/lib/osf-components/addon/components/addons-service/configured-addon-edit/component.ts new file mode 100644 index 00000000000..fd6d7d15b96 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/configured-addon-edit/component.ts @@ -0,0 +1,77 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { TaskInstance } from 'ember-concurrency'; + +import { Item, ItemType } from 'ember-osf-web/models/addon-operation-invocation'; +import AuthorizedAccountModel from 'ember-osf-web/models/authorized-account'; +import AuthorizedCitationAccountModel from 'ember-osf-web/models/authorized-citation-account'; +import AuthorizedComputingAccountModel from 'ember-osf-web/models/authorized-computing-account'; +import AuthorizedStorageAccountModel from 'ember-osf-web/models/authorized-storage-account'; +import ConfiguredAddonModel from 'ember-osf-web/models/configured-addon'; +import ConfiguredCitationAddonModel from 'ember-osf-web/models/configured-citation-addon'; +import ConfiguredComputingAddonModel from 'ember-osf-web/models/configured-computing-addon'; +import ConfiguredStorageAddonModel from 'ember-osf-web/models/configured-storage-addon'; + + +interface Args { + configuredAddon?: ConfiguredAddonModel; + authorizedAccount?: AuthorizedAccountModel; + onSave: TaskInstance; +} + +export default class ConfiguredAddonEdit extends Component { + @tracked displayName = this.args.configuredAddon?.displayName || this.args.authorizedAccount?.displayName; + @tracked selectedFolder = this.args.configuredAddon?.rootFolder; + @tracked currentItems: Item[] = []; + + defaultKwargs: any = {}; + + constructor(owner: unknown, args: Args) { + super(owner, args); + if (this.args.configuredAddon) { + if (this.args.configuredAddon instanceof ConfiguredStorageAddonModel) { + this.defaultKwargs['itemType'] = ItemType.Folder; + } + if (this.args.configuredAddon instanceof ConfiguredCitationAddonModel) { + this.defaultKwargs['filterItems'] = ItemType.Collection; + } + } + if (this.args.authorizedAccount) { + if (this.args.authorizedAccount instanceof AuthorizedStorageAccountModel) { + this.defaultKwargs['itemType'] = ItemType.Folder; + } + if (this.args.authorizedAccount instanceof AuthorizedCitationAccountModel) { + this.defaultKwargs['filterItems'] = ItemType.Collection; + } + } + } + + get hasRootFolder() { + return !( + this.args.authorizedAccount instanceof AuthorizedComputingAccountModel + || + this.args.configuredAddon instanceof ConfiguredComputingAddonModel + ); + } + + get invalidDisplayName() { + return !this.displayName || this.displayName?.trim().length === 0; + } + + get disableSave() { + return this.invalidDisplayName || this.args.onSave.isRunning || (this.hasRootFolder && !this.selectedFolder); + } + + get onSaveArgs() { + return { + displayName: this.displayName, + rootFolder: this.selectedFolder, + }; + } + + @action + selectFolder(folder: Item) { + this.selectedFolder = folder.itemId; + } +} diff --git a/lib/osf-components/addon/components/addons-service/configured-addon-edit/styles.scss b/lib/osf-components/addon/components/addons-service/configured-addon-edit/styles.scss new file mode 100644 index 00000000000..5930b953b93 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/configured-addon-edit/styles.scss @@ -0,0 +1,64 @@ +.configured-addon-edit-wrapper { + border: solid 1px $color-border-gray; + padding: 1rem; +} + +.display-name-wrapper { + margin-bottom: 1rem; +} + +.display-name-error { + color: $brand-danger; +} + +.current-path { + margin-bottom: 1rem; +} + +.file-tree-table { + width: 100%; + border: solid 1px $color-border-gray; + border-collapse: collapse; + + thead { + border-bottom: solid 1px $color-border-gray; + } + + th { + padding: 6px; + } + + th:first-of-type { + text-align: start; + } + + th:last-of-type { + text-align: end; + width: 30px; + } +} + +.table-body { + tr { + td { + padding: 6px; + } + + td:first-of-type { + text-align: start; + } + + td:last-of-type { + text-align: center; + } + } +} + +.footer-buttons-wrapper { + margin-top: 2rem; + float: right; +} + +.item-name { + white-space: normal; +} diff --git a/lib/osf-components/addon/components/addons-service/configured-addon-edit/template.hbs b/lib/osf-components/addon/components/addons-service/configured-addon-edit/template.hbs new file mode 100644 index 00000000000..9ac7898a54e --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/configured-addon-edit/template.hbs @@ -0,0 +1,159 @@ +
    +
    + + {{#if this.invalidDisplayName}} + + {{t 'validationErrors.blank'}} + + {{/if}} +
    + {{#if this.hasRootFolder }} + +
    + + {{#each fileManager.currentPath as |pathItem|}} + + {{/each}} +
    + + + + + + + + + {{#if fileManager.isLoading}} + + {{else if fileManager.isError}} + + + + {{else}} + {{#each fileManager.currentItems as |folder|}} + + + + + {{else}} + + + + {{/each}} + {{#if fileManager.hasMore}} + + + + {{/if}} + {{/if}} + +
    {{t 'addons.configure.table-headings.folder-name'}}{{t 'addons.configure.table-headings.select'}}
    {{t 'addons.configure.error-loading-items'}}
    + {{#if folder.mayContainRootCandidates}} + + {{else}} + + {{#if (or (eq folder.itemType 'FOLDER') (eq folder.itemType 'COLLECTION'))}} + + {{else}} + + {{/if}} + {{folder.itemName}} + + {{/if}} + + {{#if folder.canBeRoot}} + + {{/if}} +
    {{t 'addons.configure.no-folders'}}
    + +
    +
    + {{/if}} +
    + + +
    +
    diff --git a/lib/osf-components/addon/components/addons-service/file-manager/component.ts b/lib/osf-components/addon/components/addons-service/file-manager/component.ts new file mode 100644 index 00000000000..2551164e582 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/file-manager/component.ts @@ -0,0 +1,132 @@ +import { assert } from '@ember/debug'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import IntlService from 'ember-intl/services/intl'; +import Toast from 'ember-toastr/services/toast'; + +import { Item, ListItemsResult, OperationKwargs } from 'ember-osf-web/models/addon-operation-invocation'; +import AuthorizedAccountModel from 'ember-osf-web/models/authorized-account'; +import ConfiguredAddonModel from 'ember-osf-web/models/configured-addon'; +import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; + +interface Args { + configuredAddon?: ConfiguredAddonModel; + authorizedAccount?: AuthorizedAccountModel; + defaultKwargs?: OperationKwargs; + startingFolderId: string; +} + +export default class FileManager extends Component { + @service intl!: IntlService; + @service toast!: Toast; + + @tracked operationInvocableModel!: ConfiguredAddonModel | AuthorizedAccountModel; + + @tracked currentPath: Item[] = []; + @tracked currentItems: Item[] = []; + @tracked currentFolderId?: string; + + @tracked cursor?: string; + @tracked hasMore = false; + + private lastInvocation: any = null; + + get isLoading() { + return taskFor(this.operationInvocableModel.getFolderItems).isRunning || + taskFor(this.getStartingFolder).isRunning; + } + + get isError() { + return taskFor(this.operationInvocableModel.getFolderItems).lastPerformed?.error || + taskFor(this.getStartingFolder).lastPerformed?.error; + } + + constructor(owner: unknown, args: Args) { + super(owner, args); + assert('Must provide a configuredAddon or authorizedAccount', args.configuredAddon || args.authorizedAccount); + this.operationInvocableModel = (args.configuredAddon || args.authorizedAccount)!; + taskFor(this.initialize).perform(); + } + + @task + @waitFor + async initialize() { + await taskFor(this.getStartingFolder).perform(); + await taskFor(this.getItems).perform(); + } + + @action + goToRoot() { + this.cursor = undefined; + this.currentPath = this.currentPath = []; + this.currentFolderId = undefined; + taskFor(this.getItems).perform(); + } + + @action + goToFolder(folder: Item) { + this.cursor = undefined; + this.currentItems = []; + if (this.currentPath.includes(folder)) { + this.currentPath = this.currentPath.slice(0, this.currentPath.indexOf(folder) + 1); + } else { + this.currentPath = [...this.currentPath, folder]; + } + this.currentFolderId = folder.itemId; + taskFor(this.getItems).perform(); + } + + @action + getMore() { + this.cursor = this.lastInvocation?.operationResult.nextSampleCursor; + taskFor(this.getItems).perform(); + } + + + @task + @waitFor + async getStartingFolder() { + const { startingFolderId } = this.args; + try { + if (startingFolderId) { + const invocation = await taskFor(this.operationInvocableModel.getItemInfo).perform(startingFolderId); + const result = invocation.operationResult as Item; + this.currentFolderId = result.itemId; + this.currentPath = result.itemPath ? [...result.itemPath] : []; + } + } catch (e) { + captureException(e); + const errorMessage = this.intl.t('osf-components.addons-service.file-manager.get-item-error'); + this.toast.error(getApiErrorMessage(e), errorMessage); + throw e; + } + } + + @task + @waitFor + async getItems() { + const kwargs = !this.currentFolderId ? {} : this.args.defaultKwargs || {}; + kwargs.itemId = this.currentFolderId; + kwargs.pageCursor = this.cursor; + try { + const getFolderArgs = Object.fromEntries( + Object.entries(kwargs).filter(([_, v]) => v !== null && v !== undefined), + ); + const invocation = await taskFor(this.operationInvocableModel.getFolderItems).perform(getFolderArgs); + this.lastInvocation = invocation; + const operationResult = invocation.operationResult as ListItemsResult; + this.currentItems = this.cursor ? [...this.currentItems, ...operationResult.items] : operationResult.items; + this.hasMore = Boolean(operationResult.nextSampleCursor); + } catch (e) { + captureException(e); + const errorMessage = this.intl.t('osf-components.addons-service.file-manager.get-items-error'); + this.toast.error(getApiErrorMessage(e), errorMessage); + throw e; + } + } +} diff --git a/lib/osf-components/addon/components/addons-service/file-manager/template.hbs b/lib/osf-components/addon/components/addons-service/file-manager/template.hbs new file mode 100644 index 00000000000..44054a036d0 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/file-manager/template.hbs @@ -0,0 +1,13 @@ +{{yield (hash + currentPath=this.currentPath + currentItems=this.currentItems + + goToRoot=this.goToRoot + goToFolder=this.goToFolder + + hasMore=this.hasMore + getMore=this.getMore + + isLoading=this.isLoading + isError=this.isError +)}} diff --git a/lib/osf-components/addon/components/addons-service/manager/component.ts b/lib/osf-components/addon/components/addons-service/manager/component.ts new file mode 100644 index 00000000000..2e82a342fef --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/manager/component.ts @@ -0,0 +1,466 @@ +import EmberArray, { A } from '@ember/array'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import Store from '@ember-data/store'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { Task, task } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import IntlService from 'ember-intl/services/intl'; +import Toast from 'ember-toastr/services/toast'; +import { TrackedObject } from 'tracked-built-ins'; + +import ResourceReferenceModel from 'ember-osf-web/models/resource-reference'; +import NodeModel from 'ember-osf-web/models/node'; +import Provider, { + AllAuthorizedAccountTypes, AllConfiguredAddonTypes, +} from 'ember-osf-web/packages/addons-service/provider'; +import CurrentUserService from 'ember-osf-web/services/current-user'; +import { ConfiguredAddonEditableAttrs } from 'ember-osf-web/models/configured-addon'; +import ConfiguredStorageAddonModel from 'ember-osf-web/models/configured-storage-addon'; +import { AccountCreationArgs} from 'ember-osf-web/models/authorized-account'; +import AuthorizedStorageAccountModel from 'ember-osf-web/models/authorized-storage-account'; +import ConfiguredCitationAddonModel from 'ember-osf-web/models/configured-citation-addon'; +import UserReferenceModel from 'ember-osf-web/models/user-reference'; + +interface FilterSpecificObject { + modelName: string; + task: Task; + list: EmberArray; + configuredAddons?: EmberArray; +} + +enum PageMode { + TERMS = 'terms', + NEW_OR_EXISTING_ACCOUNT = 'newOrExistingAccount', + ACCOUNT_SELECT = 'accountSelect', + ACCOUNT_CREATE = 'accountCreate', + CONFIRM = 'confirm', + CONFIGURE = 'configure', + CONFIGURATION_LIST = 'configurationList' +} + +export enum FilterTypes { + STORAGE = 'additional-storage', + CITATION_MANAGER = 'citation-manager', + // CLOUD_COMPUTING = 'cloud-computing', // disabled because BOA is down +} + +interface Args { + node: NodeModel; +} + +export default class AddonsServiceManagerComponent extends Component { + @service store!: Store; + @service currentUser!: CurrentUserService; + @service intl!: IntlService; + @service toast!: Toast; + + node = this.args.node; + @tracked addonServiceNode?: ResourceReferenceModel | null; + @tracked userReference?: UserReferenceModel; + + possibleFilterTypes = Object.values(FilterTypes); + mapper: Record = { + [FilterTypes.STORAGE]: { + modelName: 'external-storage-service', + task: taskFor(this.getStorageAddonProviders), + list: A([]), + configuredAddons: A([]), + }, + [FilterTypes.CITATION_MANAGER]: { + modelName: 'external-citation-service', + task: taskFor(this.getCitationAddonProviders), + list: A([]), + configuredAddons: A([]), + }, + // [FilterTypes.CLOUD_COMPUTING]: { + // modelName: 'external-computing-service', + // task: taskFor(this.getComputingAddonProviders), + // list: A([]), + // configuredAddons: A([]), + // }, + }; + filterTypeMapper = new TrackedObject(this.mapper); + @tracked filterText = ''; + @tracked activeFilterType: FilterTypes = FilterTypes.STORAGE; + + @tracked confirmRemoveConnectedLocation = false; + @tracked _pageMode?: PageMode; + @tracked _pageModeHistory: PageMode[] = []; + @tracked selectedProvider?: Provider; + @tracked selectedConfiguration?: AllConfiguredAddonTypes; + @tracked selectedAccount?: AllAuthorizedAccountTypes; + + @action + filterByAddonType(type: FilterTypes) { + if (this.activeFilterType !== type) { + this.filterText = ''; + } + this.activeFilterType = type; + const activeFilterObject = this.filterTypeMapper[type]; + if (activeFilterObject.list.length === 0) { + activeFilterObject.task.perform(); + } + } + get pageMode(): PageMode | undefined { + return this._pageMode; + } + + set pageMode(value: PageMode | undefined) { + if (this._pageMode && value) { + this._pageModeHistory.push(this._pageMode); + } + this._pageMode = value; + } + + get filteredConfiguredProviders() { + const activeFilterObject = this.filterTypeMapper[this.activeFilterType]; + const possibleProviders = activeFilterObject.list; + const textFilteredAddons = possibleProviders.filter( + (provider: any) => provider.provider.displayName.toLowerCase().includes(this.filterText.toLowerCase()), + ); + + const configuredProviders = textFilteredAddons.filter((provider: Provider) => provider.isConfigured); + + return configuredProviders; + } + + get filteredAddonProviders() { + const activeFilterObject = this.filterTypeMapper[this.activeFilterType]; + const possibleProviders = activeFilterObject.list; + const textFilteredAddons = possibleProviders.filter( + (provider: any) => provider.provider.displayName.toLowerCase().includes(this.filterText.toLowerCase()), + ); + + return textFilteredAddons; + } + + get currentListIsLoading() { + const activeFilterObject = this.filterTypeMapper[this.activeFilterType]; + return activeFilterObject.task.isRunning || taskFor(this.initialize).isRunning; + } + + @action + async configureProvider(provider: Provider, configuredAddon: AllConfiguredAddonTypes) { + this.cancelSetup(); + this.selectedProvider = provider; + this.selectedConfiguration = configuredAddon; + this.pageMode = PageMode.CONFIGURE; + } + + @action + back() { + const previousPageMode = this._pageModeHistory.pop(); + if (!previousPageMode) { + this.cancelSetup(); + return; + } + this._pageMode = previousPageMode; + switch (previousPageMode) { + case PageMode.CONFIGURATION_LIST: + this.selectedProvider = this.selectedProvider || undefined; + break; + case PageMode.CONFIGURE: + if (!this.selectedProvider || !this.selectedConfiguration) { + this.cancelSetup(); + } + break; + case PageMode.TERMS: + if (!this.selectedProvider) { + this.cancelSetup(); + } + break; + default: + break; + } + } + + + @action + listProviderConfigurations(provider: Provider) { + this.cancelSetup(); + this.selectedProvider = provider; + this.pageMode = PageMode.CONFIGURATION_LIST; + } + + @action + beginAccountSetup(provider: Provider) { + this.cancelSetup(); + this.pageMode = PageMode.TERMS; + this.selectedProvider = provider; + } + + @action + async acceptTerms() { + await taskFor(this.selectedProvider!.getAuthorizedAccounts).perform(); + if(this.selectedProvider!.authorizedAccounts!.length > 0){ + this.pageMode = PageMode.NEW_OR_EXISTING_ACCOUNT; + } else { + this.pageMode = PageMode.ACCOUNT_CREATE; + } + } + + @action + chooseExistingAccount() { + this.pageMode = PageMode.ACCOUNT_SELECT; + } + + @action + createNewAccount() { + this.pageMode = PageMode.ACCOUNT_CREATE; + } + + @action + authorizeSelectedAccount() { + if (this.selectedAccount && this.selectedAccount.credentialsAvailable) { + this.pageMode = PageMode.CONFIRM; + } else { + this.pageMode = PageMode.ACCOUNT_CREATE; + } + } + + @task + @waitFor + async createAuthorizedAccount(arg: AccountCreationArgs) { + if (this.selectedProvider) { + const newAccount = await taskFor(this.selectedProvider.createAuthorizedAccount) + .perform(arg); + return newAccount; + } + return undefined; + } + + @task + @waitFor + async createConfiguredAddon(newAccount: AllAuthorizedAccountTypes) { + if (this.selectedProvider) { + this.selectedConfiguration = await taskFor(this.selectedProvider.createConfiguredAddon).perform(newAccount); + } + } + + @task + @waitFor + async connectAccount(arg: AccountCreationArgs) { + if (this.selectedProvider) { + const newAccount = await taskFor(this.createAuthorizedAccount).perform(arg); + if (newAccount) { + await taskFor(this.createConfiguredAddon).perform(newAccount); + this.pageMode = PageMode.CONFIGURE; + } + } + } + + @task + @waitFor + async oauthFlowRefocus(newAccount: AllAuthorizedAccountTypes): Promise { + await newAccount.reload(); + if (newAccount.credentialsAvailable) { + await taskFor(this.selectedProvider!.getAuthorizedAccounts).perform(); + this.selectedAccount = undefined; + this.chooseExistingAccount(); + return true; + } + return false; + } + + @action + confirmAccountSetup() { + this.pageMode = PageMode.CONFIGURE; + } + + @action + cancelSetup() { + this._pageMode = undefined; + this._pageModeHistory = []; + this.selectedProvider = undefined; + this.selectedConfiguration = undefined; + this.selectedAccount = undefined; + this.confirmRemoveConnectedLocation = false; + } + + @task + @waitFor + async saveOrCreateConfiguration(args: ConfiguredAddonEditableAttrs) { + try { + if (!this.selectedConfiguration && this.selectedProvider && this.selectedAccount) { + this.selectedConfiguration = await taskFor(this.selectedProvider.createConfiguredAddon) + .perform(this.selectedAccount); + } + + if (this.selectedConfiguration && ( + this.selectedConfiguration instanceof ConfiguredStorageAddonModel || + this.selectedConfiguration instanceof ConfiguredCitationAddonModel) + ) { + this.selectedConfiguration.rootFolder = (args as ConfiguredAddonEditableAttrs).rootFolder; + this.selectedConfiguration.displayName = args.displayName; + await this.selectedConfiguration.save(); + this.toast.success(this.intl.t('addons.configure.success', { + configurationName: this.selectedConfiguration.displayName, + })); + } + this.cancelSetup(); + } catch(e) { + const baseMessage = this.intl.t('addons.configure.error', { + configurationName: this.selectedConfiguration?.displayName, + }); + if (e.errors && e.errors[0].detail) { + const apiMessage = e.errors[0].detail; + this.toast.error(`${baseMessage}: ${apiMessage}`); + } else { + this.toast.error(baseMessage); + } + + } + } + + @action + selectAccount(account: AuthorizedStorageAccountModel) { + this.selectedAccount = account; + } + + constructor(owner: unknown, args: Args) { + super(owner, args); + taskFor(this.initialize).perform(); + } + + @task + @waitFor + async initialize() { + await Promise.all([ + taskFor(this.getUserReference).perform(), + taskFor(this.getServiceNode).perform(), + ]); + await taskFor(this.getStorageAddonProviders).perform(); + } + + @task + @waitFor + async getServiceNode() { + const references = await this.store.query('resource-reference', { + filter: {resource_uri: this.node.links.iri}, + }); + if (references) { + this.addonServiceNode = references.firstObject || null; + } else { + this.addonServiceNode = null; + } + } + + @task + @waitFor + async getStorageAddonProviders() { + const activeFilterObject = this.filterTypeMapper[FilterTypes.STORAGE]; + + if (this.addonServiceNode) { + const configuredAddons = await this.addonServiceNode.configuredStorageAddons; + activeFilterObject.configuredAddons = A(configuredAddons.toArray()); + } + + const serviceStorageProviders: Provider[] = + await taskFor(this.getExternalProviders) + .perform(activeFilterObject.modelName, activeFilterObject.configuredAddons); + activeFilterObject.list = A(serviceStorageProviders.sort(this.providerSorter)); + } + + @task + @waitFor + async getComputingAddonProviders() { + const activeFilterObject = this.filterTypeMapper[FilterTypes.CLOUD_COMPUTING]; + + if (this.addonServiceNode) { + const configuredAddons = await this.addonServiceNode.configuredComputingAddons; + activeFilterObject.configuredAddons = A(configuredAddons.toArray()); + } + + const serviceComputingProviders: Provider[] = + await taskFor(this.getExternalProviders) + .perform(activeFilterObject.modelName, activeFilterObject.configuredAddons); + activeFilterObject.list = serviceComputingProviders.sort(this.providerSorter); + } + + @task + @waitFor + async getCitationAddonProviders() { + const activeFilterObject = this.filterTypeMapper[FilterTypes.CITATION_MANAGER]; + + if (this.addonServiceNode) { + const configuredAddons = await this.addonServiceNode.configuredCitationAddons; + activeFilterObject.configuredAddons = A(configuredAddons.toArray()); + } + + const serviceCitationProviders: Provider[] = + await taskFor(this.getExternalProviders) + .perform(activeFilterObject.modelName, activeFilterObject.configuredAddons); + activeFilterObject.list = serviceCitationProviders.sort(this.providerSorter); + } + + providerSorter(a: Provider, b: Provider) { + return a.provider.displayName.localeCompare(b.provider.displayName); + } + + get projectEnabledAddons(): ConfiguredStorageAddonModel[] { + return this.serviceProjectEnabledAddons(); + } + + get headingText() { + const providerName = this.selectedProvider?.provider.displayName; + let heading; + switch (this.pageMode) { + case PageMode.TERMS: + heading = this.intl.t('addons.terms.heading', { providerName }); + break; + case PageMode.NEW_OR_EXISTING_ACCOUNT: + heading = this.intl.t('addons.accountSelect.heading', { providerName }); + break; + case PageMode.ACCOUNT_CREATE: + heading = this.intl.t('addons.accountSelect.new-account'); + break; + case PageMode.ACCOUNT_SELECT: + heading = this.intl.t('addons.accountSelect.existing-account'); + break; + case PageMode.CONFIRM: + heading = this.intl.t('addons.confirm.heading', { providerName }); + break; + case PageMode.CONFIGURE: + case PageMode.CONFIGURATION_LIST: + heading = this.intl.t('addons.configure.heading', { providerName }); + break; + default: + heading = this.intl.t('addons.heading'); + break; + } + return heading; + } + @task + @waitFor + async getUserReference() { + if (this.userReference){ + return; + } + const { user } = this.currentUser; + const userReferences = await this.store.query('user-reference', { + filter: {user_uri: user?.links.iri?.toString()}, + }); + this.userReference = userReferences.firstObject; + } + // Service API Methods + + @task + @waitFor + async getExternalProviders(providerType: string, configuredAddons?: EmberArray) { + const serviceProviderModels = (await this.store.findAll(providerType)).toArray(); + const serviceProviders = [] as Provider[]; + for (const provider of serviceProviderModels) { + serviceProviders.addObject(new Provider( + provider, this.currentUser, this.node, configuredAddons, this.addonServiceNode, this.userReference, + )); + } + return serviceProviders; + } + + serviceProjectEnabledAddons() { + return this.addonServiceNode?.get('configuredStorageAddons').toArray() || []; + } +} diff --git a/lib/osf-components/addon/components/addons-service/manager/template.hbs b/lib/osf-components/addon/components/addons-service/manager/template.hbs new file mode 100644 index 00000000000..ab527a5258c --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/manager/template.hbs @@ -0,0 +1,34 @@ +{{yield (hash + node=this.node + addonProviders=this.addonProviders + possibleFilterTypes=this.possibleFilterTypes + activeFilterType=this.activeFilterType + filterByAddonType=this.filterByAddonType + filterText=this.filterText + filteredConfiguredProviders=this.filteredConfiguredProviders + filteredAddonProviders=this.filteredAddonProviders + currentListIsLoading=this.currentListIsLoading + projectEnabledAddons=this.projectEnabledAddons + selectedProvider=this.selectedProvider + configureProvider=this.configureProvider + beginAccountSetup=this.beginAccountSetup + acceptTerms=this.acceptTerms + selectedAccount=this.selectedAccount + selectAccount=this.selectAccount + authorizeSelectedAccount=this.authorizeSelectedAccount + chooseExistingAccount=this.chooseExistingAccount + createNewAccount=this.createNewAccount + listProviderConfigurations=this.listProviderConfigurations + createAuthorizedAccount=this.createAuthorizedAccount + createConfiguredAddon=this.createConfiguredAddon + oauthFlowRefocus=this.oauthFlowRefocus + connectAccount=this.connectAccount + confirmAccountSetup=this.confirmAccountSetup + cancelSetup=this.cancelSetup + back=this.back + saveOrCreateConfiguration=this.saveOrCreateConfiguration + pageMode=this.pageMode + headingText=this.headingText + removeConfiguredAddon=this.removeConfiguredAddon + selectedConfiguration=this.selectedConfiguration +)}} diff --git a/lib/osf-components/addon/components/addons-service/terms-of-service/component.ts b/lib/osf-components/addon/components/addons-service/terms-of-service/component.ts new file mode 100644 index 00000000000..f9acd68acf6 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/terms-of-service/component.ts @@ -0,0 +1,157 @@ +import { inject as service } from '@ember/service'; +import Component from '@glimmer/component'; +import IntlService from 'ember-intl/services/intl'; + +import { AllProviderTypes } from 'ember-osf-web/packages/addons-service/provider'; +import { ExternalServiceCapabilities } from 'ember-osf-web/models/external-service'; +import ExternalStorageServiceModel from 'ember-osf-web/models/external-storage-service'; +import ExternalComputingServiceModel from 'ember-osf-web/models/external-computing-service'; +import ExternalCitationServiceModel from 'ember-osf-web/models/external-citation-service'; + +interface Args { + provider: AllProviderTypes; +} + +type CapabilityCategory = + ExternalServiceCapabilities.ADD_UPDATE_FILES | + ExternalServiceCapabilities.DELETE_FILES | + ExternalServiceCapabilities.FORKING | + ExternalServiceCapabilities.LOGS | + ExternalServiceCapabilities.PERMISSIONS | + ExternalServiceCapabilities.REGISTERING | + ExternalServiceCapabilities.FILE_VERSIONS; + +type ServiceTranslationKey = 'storage' | 'computing' | 'citation'; + + +const capabilitiesToLabelKeyMap: Record = { + [ExternalServiceCapabilities.ADD_UPDATE_FILES]: 'addons.terms.labels.add-update-files', + [ExternalServiceCapabilities.DELETE_FILES]: 'addons.terms.labels.delete-files', + [ExternalServiceCapabilities.FORKING]: 'addons.terms.labels.forking', + [ExternalServiceCapabilities.LOGS]: 'addons.terms.labels.logs', + [ExternalServiceCapabilities.PERMISSIONS]: 'addons.terms.labels.permissions', + [ExternalServiceCapabilities.REGISTERING]: 'addons.terms.labels.registering', + [ExternalServiceCapabilities.FILE_VERSIONS]: 'addons.terms.labels.file-versions', +}; + +const capabilitiesToTextKeyMap: Record>> = { + storage: { + [ExternalServiceCapabilities.ADD_UPDATE_FILES]: { + true: 'addons.terms.storage.add-update-files-true', + false: 'addons.terms.storage.add-update-files-false', + partial: 'addons.terms.storage.add-update-files-partial', + }, + [ExternalServiceCapabilities.DELETE_FILES]: { + true: 'addons.terms.storage.delete-files-true', + false: 'addons.terms.storage.delete-files-false', + partial: 'addons.terms.storage.delete-files-partial', + }, + [ExternalServiceCapabilities.FORKING]: { + true: 'addons.terms.storage.forking-true', + }, + [ExternalServiceCapabilities.LOGS]: { + true: 'addons.terms.storage.logs-true', + false: 'addons.terms.storage.logs-false', + }, + [ExternalServiceCapabilities.PERMISSIONS]: { + true: 'addons.terms.storage.permissions-true', + }, + [ExternalServiceCapabilities.REGISTERING]: { + true: 'addons.terms.storage.registering-true', + }, + [ExternalServiceCapabilities.FILE_VERSIONS]: { + true: 'addons.terms.storage.file-versions-true', + false: 'addons.terms.storage.file-versions-false', + }, + }, + citation: { + [ExternalServiceCapabilities.FORKING]: { + partial: 'addons.terms.citation.forking-partial', + }, + [ExternalServiceCapabilities.PERMISSIONS]: { + partial: 'addons.terms.citation.permissions-partial', + }, + [ExternalServiceCapabilities.REGISTERING]: { + false: 'addons.terms.citation.registering-false', + }, + }, + computing: { + [ExternalServiceCapabilities.ADD_UPDATE_FILES]: { + partial: 'addons.terms.computing.add-update-files-partial', + }, + [ExternalServiceCapabilities.FORKING]: { + partial: 'addons.terms.computing.forking-partial', + }, + [ExternalServiceCapabilities.LOGS]: { + partial: 'addons.terms.computing.logs-partial', + }, + [ExternalServiceCapabilities.PERMISSIONS]: { + partial: 'addons.terms.computing.permissions-partial', + }, + [ExternalServiceCapabilities.REGISTERING]: { + partial: 'addons.terms.computing.registering-partial', + }, + }, +}; + + +export default class TermsOfServiceComponent extends Component { + @service intl!: IntlService; + + applicableCapabilities: CapabilityCategory[] = []; + baseTranslationKey!: ServiceTranslationKey; + + constructor(owner: unknown, args: Args) { + super(owner, args); + if (args.provider instanceof ExternalStorageServiceModel) { + this.applicableCapabilities = [ + ExternalServiceCapabilities.ADD_UPDATE_FILES, + ExternalServiceCapabilities.DELETE_FILES, + ExternalServiceCapabilities.FORKING, + ExternalServiceCapabilities.LOGS, + ExternalServiceCapabilities.PERMISSIONS, + ExternalServiceCapabilities.REGISTERING, + ExternalServiceCapabilities.FILE_VERSIONS, + ]; + this.baseTranslationKey = 'storage'; + } else if (args.provider instanceof ExternalComputingServiceModel) { + this.applicableCapabilities = [ + ExternalServiceCapabilities.ADD_UPDATE_FILES, + ExternalServiceCapabilities.FORKING, + ExternalServiceCapabilities.LOGS, + ExternalServiceCapabilities.PERMISSIONS, + ExternalServiceCapabilities.REGISTERING, + ]; + this.baseTranslationKey = 'computing'; + } else if (args.provider instanceof ExternalCitationServiceModel) { + this.applicableCapabilities = [ + ExternalServiceCapabilities.FORKING, + ExternalServiceCapabilities.PERMISSIONS, + ExternalServiceCapabilities.REGISTERING, + ]; + this.baseTranslationKey = 'citation'; + } + } + + get sections() { + const providerCapabilities = this.args.provider.supportedFeatures; + const providerName = this.args.provider.displayName; + return this.applicableCapabilities.map((capability: CapabilityCategory) => { + const textTranslationChoices = capabilitiesToTextKeyMap[this.baseTranslationKey][capability]; + let textTranslationKey = textTranslationChoices.false; + let localClass = 'danger-bg'; + if (providerCapabilities?.includes(capability)) { + textTranslationKey = textTranslationChoices.true; + localClass = 'success-bg'; + } else if (providerCapabilities?.includes((capability + '_PARTIAL' as ExternalServiceCapabilities))) { + textTranslationKey = textTranslationChoices.partial; + localClass = 'warning-bg'; + } + return { + title: this.intl.t(capabilitiesToLabelKeyMap[capability]), + text: textTranslationKey ? this.intl.t(textTranslationKey, { provider: providerName }) : undefined, + class: localClass, + }; + }); + } +} diff --git a/lib/osf-components/addon/components/addons-service/terms-of-service/styles.scss b/lib/osf-components/addon/components/addons-service/terms-of-service/styles.scss new file mode 100644 index 00000000000..3ee898140ce --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/terms-of-service/styles.scss @@ -0,0 +1,30 @@ +.capabilities-table { + border-collapse: collapse; + border-top: 1px solid $color-border-gray; + border-left: 1px solid $color-border-gray; + + th, + td { + text-align: left; + word-break: normal; + border-right: 1px solid $color-border-gray; + border-bottom: 1px solid $color-border-gray; + padding: 10px; + } +} + +.footer-list { + margin-top: 20px; +} + +.warning-bg { + background-color: $color-bg-warning; +} + +.success-bg { + background-color: $color-bg-success; +} + +.danger-bg { + background-color: $color-bg-danger; +} diff --git a/lib/osf-components/addon/components/addons-service/terms-of-service/template.hbs b/lib/osf-components/addon/components/addons-service/terms-of-service/template.hbs new file mode 100644 index 00000000000..34a19e57a78 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/terms-of-service/template.hbs @@ -0,0 +1,23 @@ + + + + + + + + + {{#each this.sections as |section|}} + + + + + {{/each}} + +
    {{t 'addons.terms.table-headings.function'}}{{t 'addons.terms.table-headings.status'}}
    {{section.title}}{{section.text}}
    + +
      +
    • {{t 'addons.terms.footer.generic-label'}}
    • + {{#if (eq this.baseTranslationKey 'storage')}} +
    • {{t 'addons.terms.footer.storage-label'}}
    • + {{/if}} +
    diff --git a/lib/osf-components/addon/components/addons-service/user-addons-manager/component.ts b/lib/osf-components/addon/components/addons-service/user-addons-manager/component.ts new file mode 100644 index 00000000000..3be4f39fc2d --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/user-addons-manager/component.ts @@ -0,0 +1,370 @@ +import EmberArray, { A } from '@ember/array'; +import { action, notifyPropertyChange } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import Store from '@ember-data/store'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import IntlService from 'ember-intl/services/intl'; +import Toast from 'ember-toastr/services/toast'; + +import UserReferenceModel from 'ember-osf-web/models/user-reference'; +import Provider, {AllProviderTypes, AllAuthorizedAccountTypes} from 'ember-osf-web/packages/addons-service/provider'; +import CurrentUserService from 'ember-osf-web/services/current-user'; +import AuthorizedAccountModel, { AccountCreationArgs } from 'ember-osf-web/models/authorized-account'; +import AuthorizedStorageAccountModel from 'ember-osf-web/models/authorized-storage-account'; +import AuthorizedCitationAccountModel from 'ember-osf-web/models/authorized-citation-account'; +import AuthorizedComputingAccountModel from 'ember-osf-web/models/authorized-computing-account'; +import UserModel from 'ember-osf-web/models/user'; + +import ExternalStorageServiceModel from 'ember-osf-web/models/external-storage-service'; +import ExternalComputingServiceModel from 'ember-osf-web/models/external-computing-service'; +import ExternalCitationServiceModel from 'ember-osf-web/models/external-citation-service'; +import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; +import getHref from 'ember-osf-web/utils/get-href'; + +import { FilterTypes } from '../manager/component'; + +enum UserSettingPageModes { + TERMS = 'terms', + ACCOUNT_CREATE = 'accountCreate', + ACCOUNT_RECONNECT = 'accountReconnect', +} + +interface Args { + user: UserModel; +} + +export default class UserAddonManagerComponent extends Component { + @service store!: Store; + @service currentUser!: CurrentUserService; + @service intl!: IntlService; + @service toast!: Toast; + + user = this.args.user; + @tracked userReference!: UserReferenceModel; + @tracked tabIndex = 0; + + possibleFilterTypes = Object.values(FilterTypes); + @tracked filterTypeMapper = { + [FilterTypes.STORAGE]: { + modelName: 'external-storage-service', + fetchProvidersTask: taskFor(this.getStorageAddonProviders), + list: A([]) as EmberArray, + getAuthorizedAccountsTask: taskFor(this.getAuthorizedStorageAccounts), + authorizedAccounts: [] as AuthorizedStorageAccountModel[], + authorizedServiceIds: [] as string[], + }, + [FilterTypes.CITATION_MANAGER]: { + modelName: 'external-citation-service', + fetchProvidersTask: taskFor(this.getCitationAddonProviders), + list: A([]) as EmberArray, + getAuthorizedAccountsTask: taskFor(this.getAuthorizedCitationAccounts), + authorizedAccounts: [] as AuthorizedCitationAccountModel[], + authorizedServiceIds: [] as string[], + }, + // [FilterTypes.CLOUD_COMPUTING]: { + // modelName: 'external-computing-service', + // fetchProvidersTask: taskFor(this.getComputingAddonProviders), + // list: A([]) as EmberArray, + // getAuthorizedAccountsTask: taskFor(this.getAuthorizedComputingAccounts), + // authorizedAccounts: [] as AuthorizedComputingAccountModel[], + // authorizedServiceIds: [] as string[], + // }, + }; + @tracked filterText = ''; + @tracked activeFilterType = FilterTypes.STORAGE; + + @tracked selectedProvider?: Provider; + @tracked selectedAccount?: AllAuthorizedAccountTypes; + + @tracked pageMode?: UserSettingPageModes; + + @action + changeTab(index: number) { + this.tabIndex = index; + } + + @action + filterByAddonType(type: FilterTypes) { + if (this.activeFilterType !== type) { + this.filterText = ''; + } + this.activeFilterType = type; + const activeFilterObject = this.filterTypeMapper[type]; + if (activeFilterObject.list.length === 0) { + activeFilterObject.fetchProvidersTask.perform(); + activeFilterObject.getAuthorizedAccountsTask.perform(); + } + } + + get currentTypeAuthorizedAccounts() { + const allAccounts = this.filterTypeMapper[this.activeFilterType].authorizedAccounts; + const filteredAccounts = (allAccounts as AllAuthorizedAccountTypes[]).filter( + (account: AllAuthorizedAccountTypes) => { + const lowerCaseDisplayName = account.displayName.toLowerCase(); + return lowerCaseDisplayName.includes(this.filterText.toLowerCase()); + }, + ); + return filteredAccounts; + } + + get filteredAddonProviders() { + const activeFilterObject = this.filterTypeMapper[this.activeFilterType]; + const possibleProviders = activeFilterObject.list; + const textFilteredAddons = possibleProviders.filter( + (provider: Provider) => provider.displayName.toLowerCase().includes(this.filterText.toLowerCase()), + ); + return textFilteredAddons; + } + + get currentTypeAuthorizedServiceIds() { + return this.filterTypeMapper[this.activeFilterType].authorizedServiceIds; + } + + get currentListIsLoading() { + const activeFilterObject = this.filterTypeMapper[this.activeFilterType]; + return activeFilterObject.fetchProvidersTask.isRunning; + } + + @action + connectNewProviderAccount(provider: Provider) { + this.pageMode = UserSettingPageModes.TERMS; + this.selectedProvider = provider; + } + + @action + acceptProviderTerms() { + this.pageMode = UserSettingPageModes.ACCOUNT_CREATE; + } + + @action + navigateToReconnectProviderAccount(account: AllAuthorizedAccountTypes) { + const activeFilterObject = this.filterTypeMapper[this.activeFilterType]; + const possibleProviders = activeFilterObject.list; + this.pageMode = UserSettingPageModes.ACCOUNT_RECONNECT; + this.selectedAccount = account; + let providerId = ''; + const accountType = (account.constructor as typeof AuthorizedAccountModel).modelName; + switch (accountType) { + case 'authorized-storage-account': + providerId = (account as AuthorizedStorageAccountModel).externalStorageService.get('id'); + break; + case 'authorized-citation-account': + providerId = (account as AuthorizedCitationAccountModel).externalCitationService.get('id'); + break; + case 'authorized-computing-account': + providerId = (account as AuthorizedComputingAccountModel).externalComputingService.get('id'); + break; + default: + break; + } + this.selectedProvider = possibleProviders.find(provider => provider.id === providerId); + } + + @action + cancelSetup() { + this.pageMode = undefined; + this.selectedProvider = undefined; + } + + constructor(owner: unknown, args: Args) { + super(owner, args); + taskFor(this.initialize).perform(); + } + + + @task + @waitFor + async initialize() { + await taskFor(this.getUserReference).perform(); + await taskFor(this.getAuthorizedAccounts).perform(); + await taskFor(this.getAddonProviders).perform(); + } + + @task + @waitFor + async getUserReference() { + const { user } = this; + const _iri = user.links.iri; + if (_iri) { + const userReferences = await this.store.query('user-reference', { + filter: {user_uri: getHref(_iri)}, + }); + this.userReference = userReferences.firstObject; + } + } + + @task + @waitFor + async getAuthorizedStorageAccounts() { + const { userReference } = this; + const mappedObject = this.filterTypeMapper[FilterTypes.STORAGE]; + const accounts = (await userReference.authorizedStorageAccounts).toArray(); + mappedObject.authorizedAccounts = accounts; + mappedObject.authorizedServiceIds = accounts.map(account => account.externalStorageService.get('id')); + notifyPropertyChange(this, 'filterTypeMapper'); + } + + @task + @waitFor + async getAuthorizedCitationAccounts() { + const { userReference } = this; + const mappedObject = this.filterTypeMapper[FilterTypes.CITATION_MANAGER]; + const accounts = (await userReference.authorizedCitationAccounts).toArray(); + mappedObject.authorizedAccounts = accounts; + mappedObject.authorizedServiceIds = accounts.map(account => account.externalCitationService.get('id')); + notifyPropertyChange(this, 'filterTypeMapper'); + } + + @task + @waitFor + async getAuthorizedComputingAccounts() { + const { userReference } = this; + const mappedObject = this.filterTypeMapper[FilterTypes.CLOUD_COMPUTING]; + const accounts = (await userReference.authorizedComputingAccounts).toArray(); + mappedObject.authorizedAccounts = accounts; + mappedObject.authorizedServiceIds = accounts.map(account => account.externalComputingService.get('id')); + notifyPropertyChange(this, 'filterTypeMapper'); + } + + @task + @waitFor + async getAuthorizedAccounts() { + const activeTypeMap = this.filterTypeMapper[this.activeFilterType]; + await taskFor(activeTypeMap.getAuthorizedAccountsTask).perform(); + } + + @task + @waitFor + async getStorageAddonProviders() { + const activeFilterObject = this.filterTypeMapper[FilterTypes.STORAGE]; + const serviceStorageProviders = await taskFor(this.getExternalProviders) + .perform(activeFilterObject.modelName) as ExternalStorageServiceModel[]; + const list = serviceStorageProviders.sort(this.providerSorter) + .map(provider => new Provider( + provider, + this.currentUser, + undefined, + undefined, + undefined, + this.userReference, + )); + activeFilterObject.list = list; + } + + @task + @waitFor + async getComputingAddonProviders() { + const activeFilterObject = this.filterTypeMapper[FilterTypes.CLOUD_COMPUTING]; + const serviceComputingProviders = await taskFor(this.getExternalProviders) + .perform(activeFilterObject.modelName) as ExternalComputingServiceModel[]; + activeFilterObject.list = serviceComputingProviders.sort(this.providerSorter) + .map(provider => new Provider( + provider, + this.currentUser, + undefined, + undefined, + undefined, + this.userReference, + )); + } + + @task + @waitFor + async getCitationAddonProviders() { + const activeFilterObject = this.filterTypeMapper[FilterTypes.CITATION_MANAGER]; + const serviceCitationProviders = await taskFor(this.getExternalProviders) + .perform(activeFilterObject.modelName) as ExternalCitationServiceModel[]; + activeFilterObject.list = serviceCitationProviders.sort(this.providerSorter) + .map(provider => new Provider( + provider, + this.currentUser, + undefined, + undefined, + undefined, + this.userReference, + )); + } + + @task + @waitFor + async getAddonProviders() { + const activeTypeMap = this.filterTypeMapper[this.activeFilterType]; + await taskFor(activeTypeMap.fetchProvidersTask).perform(); + } + + providerSorter(a: AllProviderTypes, b: AllProviderTypes) { + return a.displayName.localeCompare(b.displayName); + } + + @task + @waitFor + async getExternalProviders(providerType: string) { + const serviceProviderModels: AllProviderTypes[] = (await this.store.findAll(providerType)).toArray(); + return serviceProviderModels; + } + + @task + @waitFor + async connectAccount(arg: AccountCreationArgs) { + if (this.selectedProvider) { + await taskFor(this.selectedProvider!.createAuthorizedAccount) + .perform(arg); + this.cancelSetup(); + this.changeTab(1); + await taskFor(this.getAuthorizedAccounts).perform(); + } + } + + @task + @waitFor + async reconnectAccount(args: AccountCreationArgs) { + if (this.selectedProvider && this.selectedAccount) { + await taskFor(this.selectedProvider.reconnectAuthorizedAccount) + .perform(args, this.selectedAccount); + this.cancelSetup(); + await taskFor(this.getAuthorizedAccounts).perform(); + } + } + + @task + @waitFor + async createAuthorizedAccount(args: AccountCreationArgs) { + if (this.selectedProvider) { + return await taskFor(this.selectedProvider.createAuthorizedAccount) + .perform(args); + } + } + + @task + @waitFor + async oauthFlowRefocus(newAccount: AllAuthorizedAccountTypes): Promise { + await newAccount.reload(); + if (newAccount.credentialsAvailable) { + this.cancelSetup(); + this.changeTab(1); + await taskFor(this.getAuthorizedAccounts).perform(); + return true; + } + return false; + } + + @task + @waitFor + async disconnectAccount(account: AllAuthorizedAccountTypes) { + try { + const authorizedAccounts = this.filterTypeMapper[this.activeFilterType] + .authorizedAccounts as AllAuthorizedAccountTypes[]; + authorizedAccounts.removeObject(account); + await account.destroyRecord(); + await taskFor(this.filterTypeMapper[this.activeFilterType].getAuthorizedAccountsTask).perform(); + this.toast.success(this.intl.t('addons.accountCreate.disconnect-success')); + } catch (e) { + captureException(e); + this.toast.error(getApiErrorMessage(e), this.intl.t('addons.accountCreate.disconnect-error')); + } + } +} diff --git a/lib/osf-components/addon/components/addons-service/user-addons-manager/template.hbs b/lib/osf-components/addon/components/addons-service/user-addons-manager/template.hbs new file mode 100644 index 00000000000..7f1888a41b6 --- /dev/null +++ b/lib/osf-components/addon/components/addons-service/user-addons-manager/template.hbs @@ -0,0 +1,25 @@ +{{yield (hash + user=this.user + possibleFilterTypes=this.possibleFilterTypes + activeFilterType=this.activeFilterType + filterByAddonType=this.filterByAddonType + filterText=this.filterText + filteredAddonProviders=this.filteredAddonProviders + currentListIsLoading=this.currentListIsLoading + currentTypeAuthorizedAccounts=this.currentTypeAuthorizedAccounts + currentTypeAuthorizedServiceIds=this.currentTypeAuthorizedServiceIds + pageMode=this.pageMode + tabIndex=this.tabIndex + changeTab=this.changeTab + selectedProvider=this.selectedProvider + selectedAccount=this.selectedAccount + connectNewProviderAccount=this.connectNewProviderAccount + acceptProviderTerms=this.acceptProviderTerms + navigateToReconnectProviderAccount=this.navigateToReconnectProviderAccount + cancelSetup=this.cancelSetup + createAuthorizedAccount=this.createAuthorizedAccount + oauthFlowRefocus=this.oauthFlowRefocus + connectAccount=this.connectAccount + reconnectAccount=this.reconnectAccount + disconnectAccount=this.disconnectAccount +)}} diff --git a/lib/osf-components/addon/components/alert/styles.scss b/lib/osf-components/addon/components/alert/styles.scss index d453d15bcd4..ff3ed62c4a1 100644 --- a/lib/osf-components/addon/components/alert/styles.scss +++ b/lib/osf-components/addon/components/alert/styles.scss @@ -20,19 +20,19 @@ .warning { color: #8a6d3b; - background-color: #fcf8e3; + background-color: $color-bg-warning; border-color: #faebcc; } .danger { color: #a94442; - background-color: #f2dede; + background-color: $color-bg-danger; border-color: #ebccd1; } .success { color: #3c763d; - background-color: #dff0d8; + background-color: $color-bg-success; border-color: #d6e9c6; } diff --git a/lib/osf-components/addon/components/delete-button/component.ts b/lib/osf-components/addon/components/delete-button/component.ts index 33ffb9ae8f3..8ff43f72fc0 100644 --- a/lib/osf-components/addon/components/delete-button/component.ts +++ b/lib/osf-components/addon/components/delete-button/component.ts @@ -28,6 +28,7 @@ export default class DeleteButton extends Component { // Optional arguments small = false; + secondary = false; smallSecondary = false; buttonLayout = 'medium'; noBackground = false; diff --git a/lib/osf-components/addon/components/delete-button/template.hbs b/lib/osf-components/addon/components/delete-button/template.hbs index dbf45900ae8..1bd12fce8b4 100644 --- a/lib/osf-components/addon/components/delete-button/template.hbs +++ b/lib/osf-components/addon/components/delete-button/template.hbs @@ -11,14 +11,14 @@ > -{{else if this.smallSecondary}} +{{else if (or this.secondary this.smallSecondary)}} + {{/each}} + + {{#each manager.filteredAddonProviders as |provider index|}} + {{provider.provider.displayName}} + {{/each}} + {{/if}} + + `); + + // Default view: storage providers + assert.dom('[data-test-loading]').doesNotExist('Done loading'); + assert.dom('[data-test-provider]').exists({ count: 9 }, 'Has providers'); + assert.dom('[data-test-provider="Box"]').hasText('Box', 'Has loaded Box'); + assert.dom('[data-test-provider="Zotero"]').doesNotExist('No citation service shown'); + assert.dom('[data-test-provider="Boa"]').doesNotExist('No cloud computing service shown'); + await fillIn('input', 'OneDrive'); + assert.dom('[data-test-provider]').exists({ count: 1 }, 'Has filtered providers'); + assert.dom('[data-test-provider]').hasText('OneDrive', 'Filtered down to just OneDrive'); + await fillIn('input', ''); + assert.dom('[data-test-provider]').exists({ count: 9 }, 'Filter removed'); + + // Filter by citation manager + await click('[data-test-filter=citation-manager]'); + assert.dom('[data-test-provider]').exists({ count: 2 }, 'Has citation providers'); + assert.dom('[data-test-provider]').hasText('Mendeley', 'Has loaded Mendeley'); + await fillIn('input', 'Bolero'); + assert.dom('[data-test-provider]').doesNotExist('No Bolero'); + await fillIn('input', 'Zot'); + assert.dom('[data-test-provider]').exists({ count: 1 }, 'Has filtered providers'); + assert.dom('[data-test-provider]').hasText('Zotero', 'Filtered down to just Zotero'); + + // Filter by cloud computing + // await click('[data-test-filter=cloud-computing]'); + // assert.dom('[data-test-provider]').exists({ count: 1 }, 'Has cloud providers'); + // assert.dom('[data-test-provider]').hasText('Boa', 'Has loaded Boa'); + }); +}); diff --git a/tests/integration/components/addons-service/user-addons-manager/component-test.ts b/tests/integration/components/addons-service/user-addons-manager/component-test.ts new file mode 100644 index 00000000000..e7f93608ac8 --- /dev/null +++ b/tests/integration/components/addons-service/user-addons-manager/component-test.ts @@ -0,0 +1,251 @@ +import { click, fillIn, render } from '@ember/test-helpers'; +import { TestContext } from 'ember-test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +import { setupRenderingTest } from 'ember-qunit'; +import { module, test } from 'qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { CurrentUserStub } from 'ember-osf-web/tests/helpers/require-auth'; +import { setupIntl } from 'ember-intl/test-support'; +import ExternalStorageServiceModel from 'ember-osf-web/models/external-storage-service'; +import { ModelInstance } from 'ember-cli-mirage'; + +interface AddonManagerTestContext extends TestContext { + user: any; + userRef: any; +} + +module('Integration | Component | addons-service | user-addons-manager', hooks => { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks); + + hooks.beforeEach(async function(this: AddonManagerTestContext) { + server.loadFixtures('external-storage-services'); + server.loadFixtures('external-citation-services'); + server.loadFixtures('external-computing-services'); + this.owner.register('service:current-user', CurrentUserStub); + const store = this.owner.lookup('service:store'); + const mirageUser = server.create('user', { id: 'user' }); + const user = await store.findRecord('user', mirageUser.id); + this.owner.lookup('service:current-user').setProperties({ testUser: user, currentUserId: user.id }); + const userRef = server.create('user-reference', { id: user.id }); + this.set('user', user); + this.set('userRef', userRef); + }); + + test('it loads and filters providers and accounts', async function(this: AddonManagerTestContext, assert) { + const box = server.schema.externalStorageServices.find('box') as ModelInstance; + server.create('authorized-storage-account', { + displayName: 'My box account', + accountOwner: this.userRef, + externalStorageService: box, + }); + await render(hbs` + + {{#if manager.currentListIsLoading}} + Loading... + {{else}} + + {{#each manager.possibleFilterTypes as |type|}} + + {{/each}} + + {{#each manager.filteredAddonProviders as |provider|}} + + {{/each}} + + {{#each manager.currentTypeAuthorizedAccounts as |account|}} + {{account.displayName}} + {{/each}} + {{/if}} + + `); + + // Default view: storage providers + assert.dom('[data-test-loading]').doesNotExist('Done loading'); + assert.dom('[data-test-selected-provider]').doesNotExist('No provider selected'); + assert.dom('[data-test-provider]').exists({ count: 9 }, 'Has storage providers'); + assert.dom('[data-test-provider="Box"]').hasText('Box', 'Has loaded Box'); + assert.dom('[data-test-account]').exists({ count: 1 }, 'Has authorized account'); + assert.dom('[data-test-account="Box"]').hasText('My box account', 'Has loaded Box account'); + assert.dom('[data-test-provider="Zotero"]').doesNotExist('No citation service shown'); + assert.dom('[data-test-provider="Boa"]').doesNotExist('No cloud computing service shown'); + // Filter to OneDrive + await fillIn('input', 'OneDrive'); + assert.dom('[data-test-provider]').exists({ count: 1 }, 'Has filtered providers'); + assert.dom('[data-test-account]').doesNotExist('Box authorized account is no longer shown'); + assert.dom('[data-test-provider]').hasText('OneDrive', 'Filtered down to just OneDrive'); + // Clear filter + await fillIn('input', ''); + assert.dom('[data-test-provider]').exists({ count: 9 }, 'Filter removed'); + + // Filter by citation manager + await click('[data-test-filter=citation-manager]'); + assert.dom('[data-test-provider]').exists({ count: 2 }, 'Has citation providers'); + assert.dom('[data-test-provider]').hasText('Mendeley', 'Has loaded Mendeley'); + assert.dom('[data-test-account]').doesNotExist('No authorized accounts shown'); + // Filter to non-existent provider + await fillIn('input', 'Bolero'); + assert.dom('[data-test-provider]').doesNotExist('No Bolero'); + // Filter to Zotero + await fillIn('input', 'Zot'); + assert.dom('[data-test-provider]').exists({ count: 1 }, 'Has filtered providers'); + assert.dom('[data-test-provider]').hasText('Zotero', 'Filtered down to just Zotero'); + + // Filter by cloud computing + // await click('[data-test-filter=cloud-computing]'); + // assert.dom('[data-test-provider]').exists({ count: 1 }, 'Has cloud providers'); + // assert.dom('[data-test-provider]').hasText('Boa', 'Has loaded Boa'); + }); + + test('it traverses page modes for new account creation', async function(this: AddonManagerTestContext, assert) { + await render(hbs` + + {{#if manager.currentListIsLoading}} + Loading... + {{else}} + {{#if manager.selectedProvider}} + {{manager.selectedProvider.displayName}} + + {{#if (eq manager.pageMode 'terms')}} + + {{else if (eq manager.pageMode 'accountCreate')}} + + Connect + + {{/if}} + {{else}} + + {{#each manager.filteredAddonProviders as |provider|}} + + {{/each}} + {{/if}} + {{/if}} + + `); + + // Select a provider + await click('[data-test-provider=OneDrive]'); + assert.dom('[data-test-provider]').doesNotExist('No providers shown after selecting a provider'); + assert.dom('[data-test-selected-provider]').hasText('OneDrive', 'Selected OneDrive'); + + // Accept terms + assert.dom('[data-test-accept-terms]').exists( + 'Page mode changes to Terms after manager.connectNewProviderAccount is called', + ); + await click('[data-test-accept-terms]'); + + // Connect account + assert.dom('[data-test-create-account]').exists( + 'Page mode changes to AccountCreate after manager.acceptProviderTerms is called', + ); + + // Cancel setup + await click('[data-test-cancel-setup]'); + assert.dom('[data-test-selected-provider]').doesNotExist('No providers shown after cancelling setup'); + assert.dom('[data-test-provider]').exists({ count: 9 }, 'Shows providers again'); + }); + + test('it reauthorizes and removes authorized accounts', async function(this: AddonManagerTestContext, assert) { + const box = server.schema.externalStorageServices.find('box') as ModelInstance; + server.create('authorized-storage-account', { + displayName: 'My box account', + accountOwner: this.userRef, + externalStorageService: box, + }); + await render(hbs` + + {{#if manager.currentListIsLoading}} + Loading... + {{else}} + {{#if manager.selectedProvider}} + {{manager.selectedProvider.displayName}} + {{manager.selectedAccount.displayName}} + {{#if (eq manager.pageMode 'accountReconnect')}} + Reconnect workflow here + + {{/if}} + {{else}} + {{#each manager.currentTypeAuthorizedAccounts as |account|}} + + + {{/each}} + {{/if}} + {{/if}} + + `); + + // Select an account + await click('[data-test-account="My box account"]'); + assert.dom('[data-test-selected-provider]').hasText('Box', + 'Manager has selected Box as provider associated with authorized account'); + assert.dom('[data-test-selected-account]').hasText('My box account', + 'Manager has selected My box account as authorized account'); + assert.dom('[data-test-reconnect-placeholder]').exists('Manager is in accountReconnect page mode'); + await click('[data-test-cancel-reconnect]'); + + // Remove account + await click('[data-test-remove-account="My box account"]'); + assert.dom('[data-test-account="My box account"]').doesNotExist( + 'Manager has removed My box account from authorized accounts', + ); + }); +}); diff --git a/tests/unit/guid-node/addons/route-test.ts b/tests/unit/guid-node/addons/route-test.ts new file mode 100644 index 00000000000..b33297b3c83 --- /dev/null +++ b/tests/unit/guid-node/addons/route-test.ts @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | guid-node/addons/index', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + const route = this.owner.lookup('route:guid-node/addons/index'); + assert.ok(route); + }); +}); diff --git a/tests/unit/packages/addons-service/provider-test.ts b/tests/unit/packages/addons-service/provider-test.ts new file mode 100644 index 00000000000..6a94112161d --- /dev/null +++ b/tests/unit/packages/addons-service/provider-test.ts @@ -0,0 +1,124 @@ +import { settled } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { taskFor } from 'ember-concurrency-ts'; +import { setupTest } from 'ember-qunit'; +import { module, test } from 'qunit'; + +import Provider from 'ember-osf-web/packages/addons-service/provider'; +import { CurrentUserStub } from 'ember-osf-web/tests/helpers/require-auth'; +import { Permission } from 'ember-osf-web/models/osf-model'; + +module('Unit | Packages | addons-service | provider', function(hooks) { + setupTest(hooks); + setupMirage(hooks); + + test('It works', async function(assert) { + const store = this.owner.lookup('service:store'); + this.owner.register('service:current-user', CurrentUserStub); + const currentUser = this.owner.lookup('service:current-user'); + + const mirageUser = server.create('user', 'loggedIn'); + const user = await store.findRecord('user', mirageUser.id); + currentUser.setProperties({ testUser: user, currentUserId: user.id }); + + const mirageNode = server.create('node', { + id: 'wowza', + currentUserPermissions: [ Permission.Read, Permission.Write, Permission.Admin ], + }); + const node = await store.findRecord('node', mirageNode.id); + // Make the addons service stuff + const mirageExternalStorageService = server.create('external-storage-service', { + id: 'bropdox', + displayName: 'Bropdox', + }); + const resourceReference = server.create('resource-reference', { + id: node.id, + }); + const userReference = server.create('user-reference', { + id: user.id, + configuredResources: [resourceReference], + authorizedStorageAccounts: [], + }); + + server.create('configured-storage-addon', { + displayName: 'bropdox', + externalUserId: user.id, + externalUserDisplayName: user.fullName, + rootFolder: '/rooty-tooty/', + externalStorageService: mirageExternalStorageService, + accountOwner: userReference, + authorizedResource: resourceReference, + }); + const externalStorageService = await this.owner.lookup('service:store') + .findRecord('external-storage-service', mirageExternalStorageService.id); + + const provider = new Provider(externalStorageService, currentUser, node); + await settled(); + + assert.equal(provider.userReference.id, currentUser.user.id, 'Provider userReference is set after initialize'); + assert.equal(provider.serviceNode?.id, node.id, 'Provider serviceNode is set after initialize'); + // TODO: Fix this with [ENG-5454] + // assert.ok(provider.configuredAddon, + // 'Provider configuredAddon is set after initialize'); + }); + + test('sets rootFolder and disables addon', async function(assert) { + const store = this.owner.lookup('service:store'); + this.owner.register('service:current-user', CurrentUserStub); + const currentUser = this.owner.lookup('service:current-user'); + + const mirageUser = server.create('user', 'loggedIn'); + const user = await store.findRecord('user', mirageUser.id); + currentUser.setProperties({ testUser: user, currentUserId: user.id }); + + const mirageNode = server.create('node', { + id: 'wowza', + currentUserPermissions: [ Permission.Read, Permission.Write, Permission.Admin ], + }); + const node = await store.findRecord('node', mirageNode.id); + // Make the addons service stuff + const mirageExternalStorageService = server.create('external-storage-service', { + id: 'bropdox', + displayName: 'Bropdox', + }); + const resourceReference = server.create('resource-reference', { + id: node.id, + }); + const userReference = server.create('user-reference', { + id: user.id, + configuredResources: [resourceReference], + authorizedStorageAccounts: [], + }); + + server.create('configured-storage-addon', { + displayName: 'bropdox', + externalUserId: user.id, + externalUserDisplayName: user.fullName, + rootFolder: '/', + externalStorageService: mirageExternalStorageService, + accountOwner: userReference, + authorizedResource: resourceReference, + }); + const externalStorageService = await this.owner.lookup('service:store') + .findRecord('external-storage-service', mirageExternalStorageService.id); + + const provider = new Provider(externalStorageService, currentUser, node); + await settled(); + + await taskFor(provider.createAuthorizedAccount) + .perform({}, 'bropdox account'); + + // TODO: Fix these with [ENG-5454] + // assert.equal((provider.configuredAddon as ConfiguredStorageAddonModel) + // .baseAccount?.get('id'), account.id, 'Base account is set'); + // assert.equal((provider.configuredAddon as ConfiguredStorageAddonModel) + // .rootFolder, '/', 'Root folder is default'); + + // await taskFor(provider.setRootFolder).perform('/groot/'); + // assert.equal((provider.configuredAddon as ConfiguredStorageAddonModel) + // .rootFolder, '/groot/', 'Root folder is set'); + + await taskFor(provider.disableProjectAddon).perform(); + assert.notOk(provider.configuredAddon, 'Project addon is disabled'); + }); +}); diff --git a/tests/unit/utils/map-keys-test.ts b/tests/unit/utils/map-keys-test.ts index 8fb438e6ccb..c64914fd046 100644 --- a/tests/unit/utils/map-keys-test.ts +++ b/tests/unit/utils/map-keys-test.ts @@ -32,6 +32,28 @@ const caseCases = [ camelized: { fooBar: 1, barBaz: 2 }, snakified: { foo_bar: 1, bar_baz: 2 }, }, + { + input: { fo_o: 1, bAr: {baRb: 2, bar_c: 3, bArd: {bar_do: 1}}}, + camelized: { foO: 1, bAr: {baRb: 2, bar_c: 3, bArd: {bar_do: 1}}}, + snakified: { fo_o: 1, b_ar: {baRb: 2, bar_c: 3, bArd: {bar_do: 1}}}, + }, + { + input: { fo_o: 1, bAr: {baRb: 2, bar_c: 3, bArd: {bar_do: 1}}}, + recursive: true, + camelized: { foO: 1, bAr: {baRb: 2, barC: 3, bArd: {barDo: 1}}}, + snakified: { fo_o: 1, b_ar: {ba_rb: 2, bar_c: 3, b_ard: {bar_do: 1}}}, + }, + { + input: { fo_o: 1, bAr: [{bla: 1}, {bLa: 2}]}, + camelized: { foO: 1, bAr: [{bla: 1}, {bLa: 2}]}, + snakified: { fo_o: 1, b_ar: [{bla: 1}, {bLa: 2}]}, + }, + { + input: { fo_o: 1, bAr: [{bla: 1}, {bLa: 2}]}, + recursive: true, + camelized: { foO: 1, bAr: [{bla: 1}, {bLa: 2}]}, + snakified: { fo_o: 1, b_ar: [{bla: 1}, {b_la: 2}]}, + }, { input: { fooBar: 1, foo_bar: 2 }, camelized: new Error('Mapping keys causes duplicate key: "fooBar"'), @@ -85,7 +107,7 @@ module('Unit | Utility | map-keys', () => { caseCases.forEach(testCase => { assertPasses( assert, - () => camelizeKeys(testCase.input), + () => camelizeKeys(testCase.input, testCase.recursive), testCase.camelized, ); }); @@ -96,7 +118,7 @@ module('Unit | Utility | map-keys', () => { caseCases.forEach(testCase => { assertPasses( assert, - () => snakifyKeys(testCase.input), + () => snakifyKeys(testCase.input, testCase.recursive), testCase.snakified, ); }); diff --git a/translations/en-us.yml b/translations/en-us.yml index 929d8a464fc..61ed7365f87 100644 --- a/translations/en-us.yml +++ b/translations/en-us.yml @@ -32,6 +32,8 @@ general: api: API apply: Apply asc_paren: (asc) + authenticate: Authenticate + authorize: Authorize available: 'Available' back: Back bookmark: Bookmark @@ -39,6 +41,7 @@ general: caution: Caution close: Close component: component + confirm: Confirm contributors: Contributors unknownContributor: 'Unknown Contributor' copy: Copy @@ -62,6 +65,7 @@ general: hosted_on_the_osf: 'Hosted on OSF' last_modified: 'Last modified' loading: Loading... + load_more: 'Load more' md5: MD5 modified: Modified more: more @@ -226,6 +230,145 @@ dashboard: registries: 'OSF Registries' preprints: 'OSF Preprints' institutions: 'OSF Institutions' + +addons: + heading: 'Add-ons' + provider: + remove-configured-addon-success: 'You have successfully removed this addon' + remove-configured-addon-error: 'There was a problem removing this addon' + list: + connected-accounts-for-provider: 'Connected accounts for {providerName}' + connected-accounts: 'Connected Accounts' + connected-locations: 'Connected locations' + edit-location: 'Edit {displayName}' + remove-location: 'Remove {displayName}' + add-another-location: 'Add another location' + all-addons: 'All Add-ons' + confirm-remove-connected-location: 'You are about to disconnect the addon below from the project.' + connected-to-account: 'Connected to account' + root-folder-not-set: 'Root folder not set' + sync-details-1: 'Sync your projects with external services to help stay connected and organized. Select a category and browse the options.' + sync-details-2: 'To manage all add-ons connected to your account, visit your profile settings.' + filter-placeholder: 'Filter add-ons' + filter: + all: 'All' + additional-storage: 'Additional Storage' + citation-manager: 'Citation Manager' + cloud-computing: 'Cloud Computing' + no-results: 'No results found' + no-connected-accounts: 'No connected accounts' + connect: 'Connect' + disconnect: 'Disconnect' + reconnect: 'Reconnect' + configure: 'Configure' + disable: 'Disable' + disable-account-header: 'Disable Account' + disable-confirmation: 'Are you sure you want to disable this account? All projects connected to this account will be affected.' + terms: + heading: '{providerName} Terms' + accepting: 'Accepting terms…' + table-headings: + function: 'Function' + status: 'Status' + footer: + generic-label: 'This add-on connects your OSF project to an external service. Use of this service is bound by its terms and conditions. The OSF is not responsible for the service or for your use thereof.' + storage-label: 'This add-on allows you to store files using an external service. Files added to this add-on are not stored within the OSF.' + + labels: + add-update-files: 'Add / update files' + delete-files: 'Delete files' + forking: 'Forking' + logs: 'Logs' + permissions: 'Permissions' + registering: 'Registering' + file-versions: 'View / download file versions' + storage: + add-update-files-true: 'Adding/updating files within OSF will be reflected in {provider}.' + add-update-files-false: 'You cannot add or update files for {provider} within OSF.' + add-update-files-partial: 'Files can be added but not updated.' + delete-files-true: 'Files deleted in OSF will be deleted in {provider}.' + delete-files-false: 'You cannot delete files for {provider} within OSF.' + delete-files-partial: '{provider} has limitations on which files can be deleted within OSF.' + forking-true: 'Only the user who first authorized the {provider} add-on within source project can transfer its authorization to a forked project or component.' + logs-true: 'OSF tracks changes you make to your {provider} content within OSF, but not changes made directly within {provider}.' + logs-false: 'OSF does not keep track of changes made using {provider} directly.' + permissions-true: 'The OSF does not change permissions for linked {provider} files. Privacy changes made to an OSF project or component will not affect those set in {provider}.' + registering-true: '{provider} content will be registered, but version history will not be copied to the registration.' + file-versions-true: '{provider} files and their versions can be viewed/downloaded in OSF.' + file-versions-false: '{provider} files can be viewed/downloaded in OSF, but version history is not supported.' + citation: + forking-partial: 'Forking a project or component does not copy {provider} authorization unless the user forking the project is the same user who authorized the {provider} add-on in the source project being forked.' + permissions-partial: 'Making an OSF project public or private is independent of making a {provider} folder public or private. The OSF does not alter the permissions of a linked {provider} folder.' + registering-false: '{provider} content will not be registered.' + computing: + add-update-files-partial: 'Results generated by {provider} will be downloaded and stored in OSF Storage.' + forking-partial: '{provider} authorization will only copy to a forked project/component if the contributor that authorized the add-on is the same contributor that forked the project/component.' + logs-partial: 'The OSF tracks {provider}-generated results that were copied to OSF Storage.' + permissions-partial: "An OSF project's privacy is independent of {provider} privacy." + registering-partial: '{provider}-generated results will be registered as they are stored in OSF Storage.' + accountSelect: + heading: 'Log in to {providerName} or select an account' + no-accounts: 'No accounts available' + new-account: 'Setup new account' + reconnect-account: 'Reconnect account' + existing-account: 'Choose existing account' + unnamed-account: 'Unnamed account' + accountCreate: + display-name-label: 'Account name' + display-name-placeholder: 'Account name' + display-name-help: 'This will distinguish your account from others using the same addon.' + url-label: 'Host URL' + url-post-text: 'Please include http or https' + username-label: 'Username' + password-label: 'Password' + username-placeholder: 'Username' + password-placeholder: 'Password' + password-post-text: 'These credentials will be encrypted.' + api-token-label: 'API Token' + api-token-placeholder: 'API Token' + personal-access-token-label: 'Personal Access Token' + personal-access-token-placeholder: 'Personal Access Token' + repo-label: 'Repository' + repo-placeholder: 'Select a repository' + repo-other-placeholder: 'Enter the URL of the repository' + dataverse-repo-other-post-text: 'Only Dataverse repositories v4.0 or higher are supported.' + gitlab-repo-other-post-text: 'Only GitLab repositories v4.0 or higher are supported.' + other-repo-label: 'Other (Please specify)' + access-key-label: 'Access Key' + access-key-placeholder: 'Access Key' + secret-key-label: 'Secret Key' + secret-key-placeholder: 'Secret Key' + oauth-pending: 'Complete the OAuth process in the new window before returning to this page.' + oauth-pending-secondary: 'If you do not see a new window, please click the Start Oauth link below.' + oauth-start: 'Start OAuth' + oauth-error: 'Error connecting account. Please try again.' + oauth-reconnect-error: 'Error reconnecting account' + error: 'Error creating account' + connect: Connect + connect-success: 'Successfully connected account' + reconnect: Reconnect + reconnect-success: 'Successfully reconnected account' + reconnect-error: 'Error reconnecting account' + disconnect-success: 'Successfully disconnected account' + disconnect-error: 'Error disconnecting account' + confirm: + heading: 'Confirm {providerName} Account' + verify: 'Connect the following account:' + authorizing: 'Connecting…' + configure: + heading: 'Configure {providerName}' + display-name: 'Display name' + go-to-root: 'Go to home folder' + go-to-folder: 'Go to folder {folderName}' + select-folder: 'Select {folderName} as root folder' + table-headings: + folder-name: 'Folder Name' + select: 'Select' + no-folders: 'No folders' + error-loading-items: 'Error loading items' + success: 'Successfully updated {configurationName}' + error: 'Error updating {configurationName}' + metadata: tab-title: 'Metadata' main-tab: 'OSF' @@ -258,6 +401,7 @@ cedar: draft: 'Draft' draft-explanation: "This metadata has a status of 'Draft' and is not publicly viewable. To 'Publish' this metadata please fill in all required fields and resubmit the data." published-explanation: "This metadata has a status of 'Published' and is publicly viewable." + search: page-title: 'Search' index-card: @@ -2359,6 +2503,14 @@ routes: institution: Institution email: Email osf-components: + addons-service: + file-manager: + get-item-error: 'Error fetching item' + get-items-error: 'Error fetching items' + addon-card: + connect: Connect + configure: Configure + provider-logo-alt: '{provider} logo' search-result-card: show_additional_metadata: 'Show additional metadata' hide_additional_metadata: 'Hide additional metadata' @@ -2626,6 +2778,7 @@ osf-components: select_provider: 'Please select a file provider' no_children: 'No children' no_folders: 'No files or folders' + no_csa: 'Could not find a matching Configured Storage Addon' read_only_provider: '- Cannot move or copy to this file provider' is_being_moved: '- This folder is being moved' is_being_copied: '- This folder is being copied' @@ -2658,6 +2811,7 @@ osf-components: delete_folder: 'Delete folder' delete_fail: 'Failed to delete "{fileName}"' errors: + load_configured_storage_addon: 'Failed to load configured storage addon' load_file_list: 'Failed to load files' load_file_provider: 'Failed to load the file provider' load_file_provider_title: 'Provider error' @@ -3084,6 +3238,7 @@ settings: error: 'Could not update SHARE indexing preference. Try again in a few minutes. If the issue persists, please report it to {supportEmail}.' button-label: 'Update' addons: + page-title: 'Add-ons' title: 'Configure add-on accounts' notifications: title: Notifications diff --git a/yarn.lock b/yarn.lock index 8d9cd3813ad..65eac15fcbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -418,19 +418,6 @@ "@babel/helper-replace-supers" "^7.13.12" "@babel/helper-split-export-declaration" "^7.12.13" -"@babel/helper-create-class-features-plugin@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.16.7.tgz#9c5b34b53a01f2097daf10678d65135c1b9f84ba" - integrity sha512-kIFozAvVfK05DM4EVQYKK+zteWvY85BFdGBRQBytRyY3y+6PX0DkDOn/CZ3lEuczCfrCxEzwt0YtP/87YPTWSw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.16.7" - "@babel/helper-environment-visitor" "^7.16.7" - "@babel/helper-function-name" "^7.16.7" - "@babel/helper-member-expression-to-functions" "^7.16.7" - "@babel/helper-optimise-call-expression" "^7.16.7" - "@babel/helper-replace-supers" "^7.16.7" - "@babel/helper-split-export-declaration" "^7.16.7" - "@babel/helper-create-class-features-plugin@^7.17.12", "@babel/helper-create-class-features-plugin@^7.18.0": version "7.18.0" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.0.tgz#fac430912606331cb075ea8d82f9a4c145a4da19" @@ -1346,14 +1333,6 @@ "@babel/helper-create-class-features-plugin" "^7.13.0" "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-proposal-class-properties@^7.10.4": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.16.7.tgz#925cad7b3b1a2fcea7e59ecc8eb5954f961f91b0" - integrity sha512-IobU0Xme31ewjYOShSIqd/ZGM/r/cuOz2z0MDbNrhF5FW+ZVgi0f2lyeoj9KFPDOAqsYxmLWZte1WOwlvY9aww== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.16.7" - "@babel/helper-plugin-utils" "^7.16.7" - "@babel/plugin-proposal-class-properties@^7.16.5", "@babel/plugin-proposal-class-properties@^7.17.12": version "7.17.12" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.17.12.tgz#84f65c0cc247d46f40a6da99aadd6438315d80a4" @@ -1387,15 +1366,6 @@ "@babel/helper-plugin-utils" "^7.17.12" "@babel/plugin-syntax-class-static-block" "^7.14.5" -"@babel/plugin-proposal-decorators@^7.10.5": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.16.7.tgz#922907d2e3e327f5b07d2246bcfc0bd438f360d2" - integrity sha512-DoEpnuXK14XV9btI1k8tzNGCutMclpj4yru8aXKoHlVmbO1s+2A+g2+h4JhcjrxkFJqzbymnLG6j/niOf3iFXQ== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.16.7" - "@babel/helper-plugin-utils" "^7.16.7" - "@babel/plugin-syntax-decorators" "^7.16.7" - "@babel/plugin-proposal-decorators@^7.13.5": version "7.13.15" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.13.15.tgz#e91ccfef2dc24dd5bd5dcc9fc9e2557c684ecfb8" @@ -1668,13 +1638,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-syntax-decorators@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.16.7.tgz#f66a0199f16de7c1ef5192160ccf5d069739e3d3" - integrity sha512-vQ+PxL+srA7g6Rx6I1e15m55gftknl2X8GCUW1JTlkTaXZLJOS0UcaY0eK9jYT7IYf4awn6qwyghVHLDz1WyMw== - dependencies: - "@babel/helper-plugin-utils" "^7.16.7" - "@babel/plugin-syntax-decorators@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.22.5.tgz#329fe2907c73de184033775637dbbc507f09116a" @@ -1808,13 +1771,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-syntax-typescript@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.16.7.tgz#39c9b55ee153151990fb038651d58d3fd03f98f8" - integrity sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A== - dependencies: - "@babel/helper-plugin-utils" "^7.16.7" - "@babel/plugin-syntax-unicode-sets-regex@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357" @@ -2215,15 +2171,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" -"@babel/plugin-transform-modules-amd@^7.10.5": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.16.7.tgz#b28d323016a7daaae8609781d1f8c9da42b13186" - integrity sha512-KaaEtgBL7FKYwjJ/teH63oAmE3lP34N3kshz8mm4VMAw7U3PxjVwwUmxEFksbgsNUaO3wId9R2AVQYSEGRa2+g== - dependencies: - "@babel/helper-module-transforms" "^7.16.7" - "@babel/helper-plugin-utils" "^7.16.7" - babel-plugin-dynamic-import-node "^2.3.3" - "@babel/plugin-transform-modules-amd@^7.12.1", "@babel/plugin-transform-modules-amd@^7.14.0": version "7.14.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.0.tgz#589494b5b290ff76cf7f59c798011f6d77026553" @@ -2567,18 +2514,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" -"@babel/plugin-transform-runtime@^7.12.0": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.16.7.tgz#1da184cb83a2287a01956c10c60e66dd503c18aa" - integrity sha512-2FoHiSAWkdq4L06uaDN3rS43i6x28desUVxq+zAFuE6kbWYQeiLPJI5IC7Sg9xKYVcrBKSQkVUfH6aeQYbl9QA== - dependencies: - "@babel/helper-module-imports" "^7.16.7" - "@babel/helper-plugin-utils" "^7.16.7" - babel-plugin-polyfill-corejs2 "^0.3.0" - babel-plugin-polyfill-corejs3 "^0.4.0" - babel-plugin-polyfill-regenerator "^0.3.0" - semver "^6.3.0" - "@babel/plugin-transform-runtime@^7.12.1", "@babel/plugin-transform-runtime@^7.13.9": version "7.13.15" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.13.15.tgz#2eddf585dd066b84102517e10a577f24f76a9cd7" @@ -2699,15 +2634,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" -"@babel/plugin-transform-typescript@^7.12.0": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.7.tgz#33f8c2c890fbfdc4ef82446e9abb8de8211a3ff3" - integrity sha512-Hzx1lvBtOCWuCEwMmYOfpQpO7joFeXLgoPuzZZBtTxXqSqUGUubvFGZv2ygo1tB5Bp9q6PXV3H0E/kf7KM0RLA== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.16.7" - "@babel/helper-plugin-utils" "^7.16.7" - "@babel/plugin-syntax-typescript" "^7.16.7" - "@babel/plugin-transform-typescript@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.13.0.tgz#4a498e1f3600342d2a9e61f60131018f55774853" @@ -3099,13 +3025,6 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.12.0": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" - integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== - dependencies: - regenerator-runtime "^0.13.4" - "@babel/runtime@^7.21.0": version "7.22.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.6.tgz#57d64b9ae3cff1d67eb067ae117dac087f5bd438" @@ -3687,7 +3606,7 @@ mkdirp "^1.0.4" silent-error "^1.1.1" -"@ember/render-modifiers@^2.0.3", "@ember/render-modifiers@^2.0.4", "@ember/render-modifiers@^2.0.5", "@ember/render-modifiers@^2.1.0": +"@ember/render-modifiers@^2.0.0", "@ember/render-modifiers@^2.0.3", "@ember/render-modifiers@^2.0.4", "@ember/render-modifiers@^2.0.5", "@ember/render-modifiers@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@ember/render-modifiers/-/render-modifiers-2.1.0.tgz#f4fff95a8b5cfbe947ec46644732d511711c5bf9" integrity sha512-LruhfoDv2itpk0fA0IC76Sxjcnq/7BC6txpQo40hOko8Dn6OxwQfxkPIbZGV0Cz7df+iX+VJrcYzNIvlc3w2EQ== @@ -7043,6 +6962,11 @@ babel-import-util@^1.1.0, babel-import-util@^1.2.0: resolved "https://registry.yarnpkg.com/babel-import-util/-/babel-import-util-1.2.2.tgz#1027560e143a4a68b1758e71d4fadc661614e495" integrity sha512-8HgkHWt5WawRFukO30TuaL9EiDUOdvyKtDwLma4uBNeUSDbOO0/hiPfavrOWxSS6J6TKXfukWHZ3wiqZhJ8ONQ== +babel-import-util@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/babel-import-util/-/babel-import-util-1.4.1.tgz#1df6fd679845df45494bac9ca12461d49497fdd4" + integrity sha512-TNdiTQdPhXlx02pzG//UyVPSKE7SNWjY0n4So/ZnjQpWwaM5LvWBLkWa1JKll5u06HNscHD91XZPuwrMg1kadQ== + babel-import-util@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/babel-import-util/-/babel-import-util-1.3.0.tgz#dc9251ea39a7747bd586c1c13b8d785a42797f8e" @@ -7112,7 +7036,7 @@ babel-plugin-ember-modules-api-polyfill@^2.6.0: dependencies: ember-rfc176-data "^0.3.13" -babel-plugin-ember-modules-api-polyfill@^3.3.0, babel-plugin-ember-modules-api-polyfill@^3.5.0: +babel-plugin-ember-modules-api-polyfill@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/babel-plugin-ember-modules-api-polyfill/-/babel-plugin-ember-modules-api-polyfill-3.5.0.tgz#27b6087fac75661f779f32e60f94b14d0e9f6965" integrity sha512-pJajN/DkQUnStw0Az8c6khVcMQHgzqWr61lLNtVeu0g61LRW0k9jyK7vaedrHDWGe/Qe8sxG5wpiyW9NsMqFzA== @@ -7246,14 +7170,6 @@ babel-plugin-polyfill-corejs3@^0.2.0: "@babel/helper-define-polyfill-provider" "^0.2.0" core-js-compat "^3.9.1" -babel-plugin-polyfill-corejs3@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.4.0.tgz#0b571f4cf3d67f911512f5c04842a7b8e8263087" - integrity sha512-YxFreYwUfglYKdLUGvIF2nJEsGwj+RhWSX/ije3D2vQPOXuyMLMtg/cCGMDpOA7Nd+MwlNdnGODbd2EwUZPlsw== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.0" - core-js-compat "^3.18.0" - babel-plugin-polyfill-corejs3@^0.5.0: version "0.5.2" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz#aabe4b2fa04a6e038b688c5e55d44e78cd3a5f72" @@ -8848,7 +8764,7 @@ browserslist@^4.16.4: escalade "^3.1.1" node-releases "^1.1.71" -browserslist@^4.17.5, browserslist@^4.19.1: +browserslist@^4.17.5: version "4.19.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.1.tgz#4ac0435b35ab655896c31d53018b6dd5e9e4c9a3" integrity sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A== @@ -9136,9 +9052,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000792, caniuse-lite@^1.0.30000805, caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001214, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001503: - version "1.0.30001523" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001523.tgz" - integrity sha512-I5q5cisATTPZ1mc588Z//pj/Ox80ERYDfR71YnvY7raS/NOk8xXlZcB0sF7JdqaV//kOaa6aus7lRfpdnt1eBA== + version "1.0.30001662" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001662.tgz" + integrity sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA== capture-exit@^2.0.0: version "2.0.0" @@ -9875,14 +9791,6 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -core-js-compat@^3.18.0: - version "3.20.2" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.20.2.tgz#d1ff6936c7330959b46b2e08b122a8b14e26140b" - integrity sha512-qZEzVQ+5Qh6cROaTPFLNS4lkvQ6mBzE3R6A6EEpssj7Zr2egMHgsy4XapdifqJDGC9CBiNv7s+ejI96rLNQFdg== - dependencies: - browserslist "^4.19.1" - semver "7.0.0" - core-js-compat@^3.21.0, core-js-compat@^3.22.1: version "3.22.7" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.22.7.tgz#8359eb66ecbf726dd0cfced8e48d5e73f3224239" @@ -10895,14 +10803,15 @@ ember-animated@^0.11.0: ember-maybe-import-regenerator "^0.1.5" ember-named-arguments-polyfill "^1.0.0" -ember-aria-tabs@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/ember-aria-tabs/-/ember-aria-tabs-4.0.0.tgz#01f9aaf98468ff21e254fdfaff7747972228cadc" - integrity sha512-hqQEXdzUMRA0RD6oMYJfkZzWngZfcFgYFBwbL4WSudBgnPSz0DrJgMtwvys0qjcP0Gs/16Jr1VAUQW8Y//lyQw== +ember-aria-tabs@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/ember-aria-tabs/-/ember-aria-tabs-7.0.0.tgz#26f6195ef40b29e8efb1e535377405b0b1bb90a9" + integrity sha512-bbhvWHPru3o/dkhLb9+9DMBASzCKoeq34XOMTTnYoj0mcGcc7TtPUYjxXtYFysCuge+8kLXDUnlQDGZUjU07xg== dependencies: - ember-cached-decorator-polyfill "^0.1.1" - ember-cli-babel "7.24.0" - ember-cli-htmlbars "^5.3.1" + "@ember/render-modifiers" "^2.0.0" + ember-cached-decorator-polyfill "^1.0.1" + ember-cli-babel "7.26.11" + ember-cli-htmlbars "^6.0.0" ember-asset-loader@^0.6.1: version "0.6.1" @@ -11070,7 +10979,7 @@ ember-cache-primitive-polyfill@^1.0.1: ember-compatibility-helpers "^1.2.1" silent-error "^1.1.1" -ember-cached-decorator-polyfill@^0.1.1, ember-cached-decorator-polyfill@^0.1.4: +ember-cached-decorator-polyfill@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/ember-cached-decorator-polyfill/-/ember-cached-decorator-polyfill-0.1.4.tgz#f1e2c65cc78d0d9c4ac0e047e643af477eb85ace" integrity sha512-JOK7kBCWsTVCzmCefK4nr9BACDJk0owt9oIUaVt6Q0UtQ4XeAHmoK5kQ/YtDcxQF1ZevHQFdGhsTR3JLaHNJgA== @@ -11080,6 +10989,18 @@ ember-cached-decorator-polyfill@^0.1.1, ember-cached-decorator-polyfill@^0.1.4: ember-cli-babel "^7.21.0" ember-cli-babel-plugin-helpers "^1.1.1" +ember-cached-decorator-polyfill@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/ember-cached-decorator-polyfill/-/ember-cached-decorator-polyfill-1.0.2.tgz#26445056ebee3776c340e28652ce59be73dd3958" + integrity sha512-hUX6OYTKltAPAu8vsVZK02BfMTV0OUXrPqvRahYPhgS7D0I6joLjlskd7mhqJMcaXLywqceIy8/s+x8bxF8bpQ== + dependencies: + "@embroider/macros" "^1.8.3" + "@glimmer/tracking" "^1.1.2" + babel-import-util "^1.2.2" + ember-cache-primitive-polyfill "^1.0.1" + ember-cli-babel "^7.26.11" + ember-cli-babel-plugin-helpers "^1.1.1" + ember-changeset-validations@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ember-changeset-validations/-/ember-changeset-validations-4.1.1.tgz#2543b561869719539bad94472bdcfd6bd5e58ce0" @@ -11123,30 +11044,33 @@ ember-cli-babel-plugin-helpers@^1.0.0, ember-cli-babel-plugin-helpers@^1.1.0, em resolved "https://registry.yarnpkg.com/ember-cli-babel-plugin-helpers/-/ember-cli-babel-plugin-helpers-1.1.1.tgz#5016b80cdef37036c4282eef2d863e1d73576879" integrity sha512-sKvOiPNHr5F/60NLd7SFzMpYPte/nnGkq/tMIfXejfKHIhaiIkYFqX8Z9UFTKWLLn+V7NOaby6niNPZUdvKCRw== -ember-cli-babel@7.24.0: - version "7.24.0" - resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.24.0.tgz#d58670d0f214c63a46f44f86e7c407799d77fc45" - integrity sha512-IpqMqOS1VI2wVLIREdEXG9WG05YBulg20t0K27yl2aBjmShvLMRIodcNiKataTgf/dDiU0EWQBGl7VLBLBkFgQ== +ember-cli-babel@7.26.11, ember-cli-babel@^7.26.0, ember-cli-babel@^7.26.10, ember-cli-babel@^7.26.11: + version "7.26.11" + resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.26.11.tgz#50da0fe4dcd99aada499843940fec75076249a9f" + integrity sha512-JJYeYjiz/JTn34q7F5DSOjkkZqy8qwFOOxXfE6pe9yEJqWGu4qErKxlz8I22JoVEQ/aBUO+OcKTpmctvykM9YA== dependencies: "@babel/core" "^7.12.0" "@babel/helper-compilation-targets" "^7.12.0" - "@babel/plugin-proposal-class-properties" "^7.10.4" - "@babel/plugin-proposal-decorators" "^7.10.5" - "@babel/plugin-transform-modules-amd" "^7.10.5" - "@babel/plugin-transform-runtime" "^7.12.0" - "@babel/plugin-transform-typescript" "^7.12.0" + "@babel/plugin-proposal-class-properties" "^7.16.5" + "@babel/plugin-proposal-decorators" "^7.13.5" + "@babel/plugin-proposal-private-methods" "^7.16.5" + "@babel/plugin-proposal-private-property-in-object" "^7.16.5" + "@babel/plugin-transform-modules-amd" "^7.13.0" + "@babel/plugin-transform-runtime" "^7.13.9" + "@babel/plugin-transform-typescript" "^7.13.0" "@babel/polyfill" "^7.11.5" - "@babel/preset-env" "^7.12.0" - "@babel/runtime" "^7.12.0" + "@babel/preset-env" "^7.16.5" + "@babel/runtime" "7.12.18" amd-name-resolver "^1.3.1" babel-plugin-debug-macros "^0.3.4" babel-plugin-ember-data-packages-polyfill "^0.1.2" - babel-plugin-ember-modules-api-polyfill "^3.3.0" + babel-plugin-ember-modules-api-polyfill "^3.5.0" babel-plugin-module-resolver "^3.2.0" broccoli-babel-transpiler "^7.8.0" broccoli-debug "^0.6.4" broccoli-funnel "^2.0.2" broccoli-source "^2.1.2" + calculate-cache-key-for-tree "^2.0.0" clone "^2.1.2" ember-cli-babel-plugin-helpers "^1.1.1" ember-cli-version-checker "^4.1.0" @@ -11208,42 +11132,6 @@ ember-cli-babel@^7.0.0, ember-cli-babel@^7.1.0, ember-cli-babel@^7.1.2, ember-cl rimraf "^3.0.1" semver "^5.5.0" -ember-cli-babel@^7.26.0, ember-cli-babel@^7.26.10, ember-cli-babel@^7.26.11: - version "7.26.11" - resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.26.11.tgz#50da0fe4dcd99aada499843940fec75076249a9f" - integrity sha512-JJYeYjiz/JTn34q7F5DSOjkkZqy8qwFOOxXfE6pe9yEJqWGu4qErKxlz8I22JoVEQ/aBUO+OcKTpmctvykM9YA== - dependencies: - "@babel/core" "^7.12.0" - "@babel/helper-compilation-targets" "^7.12.0" - "@babel/plugin-proposal-class-properties" "^7.16.5" - "@babel/plugin-proposal-decorators" "^7.13.5" - "@babel/plugin-proposal-private-methods" "^7.16.5" - "@babel/plugin-proposal-private-property-in-object" "^7.16.5" - "@babel/plugin-transform-modules-amd" "^7.13.0" - "@babel/plugin-transform-runtime" "^7.13.9" - "@babel/plugin-transform-typescript" "^7.13.0" - "@babel/polyfill" "^7.11.5" - "@babel/preset-env" "^7.16.5" - "@babel/runtime" "7.12.18" - amd-name-resolver "^1.3.1" - babel-plugin-debug-macros "^0.3.4" - babel-plugin-ember-data-packages-polyfill "^0.1.2" - babel-plugin-ember-modules-api-polyfill "^3.5.0" - babel-plugin-module-resolver "^3.2.0" - broccoli-babel-transpiler "^7.8.0" - broccoli-debug "^0.6.4" - broccoli-funnel "^2.0.2" - broccoli-source "^2.1.2" - calculate-cache-key-for-tree "^2.0.0" - clone "^2.1.2" - ember-cli-babel-plugin-helpers "^1.1.1" - ember-cli-version-checker "^4.1.0" - ensure-posix-path "^1.0.2" - fixturify-project "^1.10.0" - resolve-package-path "^3.1.0" - rimraf "^3.0.1" - semver "^5.5.0" - ember-cli-blueprint-test-helpers@^0.19.2: version "0.19.2" resolved "https://registry.yarnpkg.com/ember-cli-blueprint-test-helpers/-/ember-cli-blueprint-test-helpers-0.19.2.tgz#9e563cd81ab39931253ced0982c5d02475895401"