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')}}
+
+
+
+ {{#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 @@
+
+

+
+ {{@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}}
+ {{/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}}
+
+
+
+
+
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 @@
+
+
+
+ {{t 'addons.terms.table-headings.function'}} |
+ {{t 'addons.terms.table-headings.status'}} |
+
+
+
+ {{#each this.sections as |section|}}
+
+ {{section.title}} |
+ {{section.text}} |
+
+ {{/each}}
+
+
+
+
+ - {{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)}}