diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 82d9b212cab..7dc40777623 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -1,7 +1,11 @@ module.exports = { extends: ["eslint:recommended", "plugin:vue/essential"], // airbnb-base, eventually env: { - browser: true + browser: true, + commonjs: true, + es6: true, + node: true, + mocha: true }, parserOptions: { parser: "babel-eslint" }, plugins: ["html"], @@ -16,5 +20,10 @@ module.exports = { // properties: "always" // } // ] + }, + globals: { + // chai tests + assert: true, + expect: true } }; diff --git a/client/galaxy/scripts/components/MaskedInput.js b/client/galaxy/scripts/components/MaskedInput.js new file mode 100644 index 00000000000..7cac27045eb --- /dev/null +++ b/client/galaxy/scripts/components/MaskedInput.js @@ -0,0 +1,29 @@ +import BInput from "bootstrap-vue/es/components/form-input/form-input"; +import { createMask } from "imask"; + +export default { + extends: BInput, + props: { + mask: { + type: String, + required: false, + default: "" + } + }, + computed: { + masker() { + return createMask({ + mask: this.mask + }); + } + }, + methods: { + getFormatted(value) { + const result = this.stringifyValue(value); + if (this.mask.length) { + return this.masker.resolve(result); + } + return result; + } + } +}; diff --git a/client/galaxy/scripts/components/User/CloudAuth/CloudAuth.test.js b/client/galaxy/scripts/components/User/CloudAuth/CloudAuth.test.js new file mode 100644 index 00000000000..7d930f4e2e8 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/CloudAuth.test.js @@ -0,0 +1,87 @@ +import sinon from "sinon"; +import flushPromises from "flush-promises"; +import { shallowMount, createLocalVue } from "@vue/test-utils"; + +import { default as CloudAuth, __RewireAPI__ as rewire } from "./CloudAuth"; +import CloudAuthItem from "./CloudAuthItem"; +import { Credential } from "./model"; + +import _l from "utils/localization"; +import BootstrapVue from "bootstrap-vue"; + +// test data +import listCredentials from "./testdata/listCredentials.json"; + +const localVue = createLocalVue(); +localVue.use(BootstrapVue); +localVue.filter("localize", value => _l(value)); + +describe("CloudAuth component", () => { + let stub, wrapper; + + let mockSvc = { + listCredentials: async () => null + }; + + rewire.__Rewire__("svc", mockSvc); + + beforeEach(async () => { + let creds = listCredentials.map(Credential.create); + stub = sinon.stub(mockSvc, "listCredentials").resolves(creds); + wrapper = shallowMount(CloudAuth, { localVue }); + await flushPromises(); + }); + + afterEach(() => { + if (stub) { + stub.restore(); + } + }); + + describe("initialization", () => { + it("should render the initial list", () => { + assert(wrapper); + assert(wrapper.contains(CloudAuthItem)); + assert(wrapper.vm.items.length == 2); + assert(wrapper.vm.filteredItems.length == 2); + }); + }); + + describe("text filter", () => { + it("should show filter result by text match", () => { + let results; + + wrapper.vm.filter = "aws"; + results = wrapper.vm.filteredItems; + assert(wrapper.contains(CloudAuthItem)); + assert(results.length == 1, `Wrong number of items: ${results.length}`); + + wrapper.vm.filter = "azure"; + results = wrapper.vm.filteredItems; + assert(results.length == 1, `Wrong number of items: ${results.length}`); + + wrapper.vm.filter = ""; + results = wrapper.vm.filteredItems; + assert(results.length == 2, `Wrong number of items: ${results.length}`); + }); + }); + + describe("create button", () => { + it("clicking create button should add a blank key", () => { + let results = wrapper.vm.filteredItems; + assert(wrapper.contains(CloudAuthItem)); + assert(results.length == 2, `Wrong number of items: ${results.length}`); + + let button = wrapper.find("button[name=createNewKey]"); + assert(button); + button.trigger("click"); + + results = wrapper.vm.filteredItems; + assert(results.length == 3, `Wrong number of items: ${results.length}`); + + let blank = results.find(i => i.id == null); + assert(blank, "missing blank key"); + assert(blank.id == null); + }); + }); +}); diff --git a/client/galaxy/scripts/components/User/CloudAuth/CloudAuth.vue b/client/galaxy/scripts/components/User/CloudAuth/CloudAuth.vue new file mode 100644 index 00000000000..74bc81e5592 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/CloudAuth.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/client/galaxy/scripts/components/User/CloudAuth/CloudAuthItem.vue b/client/galaxy/scripts/components/User/CloudAuth/CloudAuthItem.vue new file mode 100644 index 00000000000..39d8c43f7d5 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/CloudAuthItem.vue @@ -0,0 +1,95 @@ + + + diff --git a/client/galaxy/scripts/components/User/CloudAuth/CredentialConfig.vue b/client/galaxy/scripts/components/User/CloudAuth/CredentialConfig.vue new file mode 100644 index 00000000000..8314cb93bb8 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/CredentialConfig.vue @@ -0,0 +1,44 @@ + + + diff --git a/client/galaxy/scripts/components/User/CloudAuth/CredentialForm.vue b/client/galaxy/scripts/components/User/CloudAuth/CredentialForm.vue new file mode 100644 index 00000000000..0dd4d16a332 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/CredentialForm.vue @@ -0,0 +1,108 @@ + + + diff --git a/client/galaxy/scripts/components/User/CloudAuth/index.js b/client/galaxy/scripts/components/User/CloudAuth/index.js new file mode 100644 index 00000000000..3e5bd9184df --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/index.js @@ -0,0 +1 @@ +export { default as CloudAuth } from "./CloudAuth.vue"; diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/AwsConfig.js b/client/galaxy/scripts/components/User/CloudAuth/model/AwsConfig.js new file mode 100644 index 00000000000..269d75578a3 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/AwsConfig.js @@ -0,0 +1,27 @@ +import { safeAssign } from "utils/safeAssign"; +import { BaseModel } from "./BaseModel"; + +export class AwsConfig extends BaseModel { + constructor(props = {}) { + super(); + this.role_arn = ""; + safeAssign(this, props); + this.updateState(); + } +} + +AwsConfig.setValidator(function(model) { + const errors = {}; + if (!model.role_arn.length) { + errors.role_arn = "Missing role_arn"; + } + return errors; +}); + +AwsConfig.fields = { + role_arn: { + label: "Role ARN", + description: "The Amazon resource name (ARN) of the role to be assumed by Galaxy.", + placeholder: "arn:aws:iam::XXXXXXXXXXXX:role/XXXXXXXXXXX" + } +}; diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/AwsConfig.test.js b/client/galaxy/scripts/components/User/CloudAuth/model/AwsConfig.test.js new file mode 100644 index 00000000000..462683198e2 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/AwsConfig.test.js @@ -0,0 +1,22 @@ +import { AwsConfig } from "./AwsConfig"; + +describe("AwsConfig", () => { + it("should instantiate", () => { + let instance = new AwsConfig(); + assert(instance); + assert(!instance.dirty); + assert(!instance.valid); + }); + + it("should validate role_arn", () => { + let instance = new AwsConfig(); + instance.role_arn = "abc"; + assert(instance.fieldValid("role_arn")); + }); + + it("should invalidate role_arn", () => { + let instance = new AwsConfig(); + instance.role_arn = ""; + assert(!instance.fieldValid("role_arn")); + }); +}); diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/AzureConfig.js b/client/galaxy/scripts/components/User/CloudAuth/model/AzureConfig.js new file mode 100644 index 00000000000..7cee6dacf06 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/AzureConfig.js @@ -0,0 +1,61 @@ +import { safeAssign } from "utils/safeAssign"; +import { BaseModel } from "./BaseModel"; + +export class AzureConfig extends BaseModel { + constructor(props = {}) { + super(); + this.tenant_id = ""; + this.client_id = ""; + this.client_secret = ""; + safeAssign(this, props); + this.updateState(); + } +} + +AzureConfig.setValidator(function(model) { + const errors = {}; + + if (model.tenant_id.length < 36) { + errors.tenant_id = "Tenant ID too short"; + } + if (!model.tenant_id) { + errors.tenant_id = "Missing Tenant ID"; + } + + if (model.client_id.length < 36) { + errors.client_id = "Client ID too short"; + } + if (!model.client_id) { + errors.client_id = "Missing client ID"; + } + + if (!model.client_secret) { + errors.client_secret = "Missing secret"; + } + + return errors; +}); + +AzureConfig.fields = { + tenant_id: { + label: "Tenant ID", + mask: "********-****-****-****-************", + placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + maxlength: 36, + description: "Your Tenant ID (or Directory ID) on Azure." + }, + client_id: { + label: "Client ID", + mask: "********-****-****-****-************", + placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + maxlength: 36, + description: "The Client ID (or Application ID) you defined for Galaxy on your Azure directory." + }, + client_secret: { + label: "Client Secret", + mask: "", + placeholder: "Client Secret", + description: + "A secret string you obtained from Azure portal that Galaxy can use to prove its identity when requesting tokens to access your resources." + } +}; diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/AzureConfig.test.js b/client/galaxy/scripts/components/User/CloudAuth/model/AzureConfig.test.js new file mode 100644 index 00000000000..cc90a205bd6 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/AzureConfig.test.js @@ -0,0 +1,58 @@ +import { AzureConfig } from "./AzureConfig"; + +describe("AzureConfig", () => { + it("should instantiate", () => { + let instance = new AzureConfig(); + assert(instance); + assert(!instance.dirty); + assert(!instance.valid); + }); + + describe("client_secret", () => { + it("should validate client_secret", () => { + let instance = new AzureConfig(); + instance.client_secret = "abc"; + assert(instance.fieldValid("client_secret")); + }); + + it("should invalidate client_secret", () => { + let instance = new AzureConfig(); + instance.client_secret = ""; + assert(!instance.fieldValid("client_secret")); + }); + }); + + describe("tenant_id", () => { + it("should validate tenant_id", () => { + let validTenantId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"; + let instance = new AzureConfig(); + instance.tenant_id = validTenantId; + assert(instance.fieldValid("tenant_id")); + }); + + it("should invalidate tenant_id", () => { + let instance = new AzureConfig(); + instance.tenant_id = ""; + assert(!instance.fieldValid("tenant_id")); + instance.tenant_id = "asdfa"; + assert(!instance.fieldValid("tenant_id")); + }); + }); + + describe("client_id", () => { + it("should validate client_id", () => { + let validClientId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"; + let instance = new AzureConfig(); + instance.client_id = validClientId; + assert(instance.fieldValid("client_id")); + }); + + it("should invalidate client_id", () => { + let instance = new AzureConfig(); + instance.client_id = ""; + assert(!instance.fieldValid("client_id")); + instance.client_id = "asdf"; + assert(!instance.fieldValid("client_id")); + }); + }); +}); diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/BaseModel.js b/client/galaxy/scripts/components/User/CloudAuth/model/BaseModel.js new file mode 100644 index 00000000000..651ce0d0ab1 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/BaseModel.js @@ -0,0 +1,102 @@ +const lastState = new WeakMap(); +const validators = new WeakMap(); +const transients = new WeakMap(); +const counter = new WeakMap(); + +// Helps with Vue for-loop keys +let instanceCounter = 0; + +export class BaseModel { + constructor() { + counter.set(this, instanceCounter++); + } + + /* object ID */ + + get counter() { + return counter.get(this); + } + + /* Dirty state tracking */ + + get dirty() { + return lastState.get(this) != this.state; + } + + get clean() { + return !this.dirty; + } + + get state() { + // build state JSON ignoring transient fields + const tFields = this.transientFields; + return JSON.stringify(this, function(key) { + if (!tFields.has(key)) { + return this[key]; + } + return undefined; + }); + } + + get lastState() { + return lastState.get(this); + } + + get transientFields() { + const key = this.constructor; + if (!transients.has(key)) { + transients.set(key, new Set()); + } + return transients.get(key); + } + + updateState() { + lastState.set(this, this.state); + } + + // Setting a property name as transient for a class means its + // state will be caclulated without regard to that propety + static setTransient(...fieldNames) { + const klass = this; // this will be a class + if (!transients.has(klass)) { + transients.set(klass, new Set()); + } + + const fields = transients.get(klass); + fieldNames.forEach(fieldName => fields.add(fieldName)); + transients.set(klass, fields); + } + + /* Validation */ + + get valid() { + return Object.keys(this.validationErrors).length == 0; + } + + get validationErrors() { + return this.constructor.validate(this); + } + + errorMessage(field) { + if (field in this.validationErrors) { + return this.validationErrors[field]; + } + return "errmessage"; + } + + fieldValid(fieldName) { + return !(fieldName in this.validationErrors); + } + + static validate(model) { + if (validators.has(this)) { + const validator = validators.get(this); + return validator(model); + } + throw new Error("Missing validator"); + } + + static setValidator(validationFunction) { + validators.set(this, validationFunction); + } +} diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/Credential.js b/client/galaxy/scripts/components/User/CloudAuth/model/Credential.js new file mode 100644 index 00000000000..defa15e67df --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/Credential.js @@ -0,0 +1,104 @@ +import { safeAssign } from "utils/safeAssign"; +import { ResourceProviders } from "./ResourceProviders"; +import { BaseModel } from "./BaseModel"; + +export class Credential extends BaseModel { + constructor(props = {}) { + super(); + + this.id = null; + this.description = ""; + this.authn_id = null; // identity provider + this.provider = null; // resource provider + + // transient props, exclude from state + this.expanded = false; + this.loading = false; + + // populate props + const options = Object.assign({}, Credential.defaults, props); + safeAssign(this, options); + + // init nested config + this.config = new this.configClass(options.config); + + // initialize state + this.updateState(); + } + + get title() { + return this.description.length ? this.description : this.provider; + } + + get valid() { + return super.valid && this.config.valid; + } + + // Alias for provider also changes config object when updated + // Set this when updating in the UI + + get resourceProvider() { + return this.provider; + } + + set resourceProvider(newProvider) { + this.provider = newProvider; + this.config = new this.configClass({}); + } + + // Polymorphic config class + + get configClass() { + return ResourceProviders.get(this.provider).klass; + } + + // Methods + + match(searchText = "") { + // TODO: more robust object matching? + return searchText.length ? this.title.includes(searchText) : true; + } + + // Statics + + static get defaults() { + return { + authn_id: null, + provider: "aws", + expanded: false + }; + } + + static create(props = {}) { + return new Credential(props); + } +} + +/** + * Transient fields do not factor into the state + * for purposes of dirty tracking + */ +Credential.setTransient("expanded", "loading"); + +/** + * A validator function's job is to return an object + * where the keys are field names and the values are + * error messages. + */ +Credential.setValidator(function(model) { + const errors = {}; + + if (!model.provider) { + errors.provider = "Provider must be set"; + } + + if (!model.authn_id) { + errors.authn_id = "Please pick an identity provider"; + } + + if (!model.config.valid) { + errors.config = "Invalid config object"; + } + + return errors; +}); diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/Credential.test.js b/client/galaxy/scripts/components/User/CloudAuth/model/Credential.test.js new file mode 100644 index 00000000000..ee2be51b315 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/Credential.test.js @@ -0,0 +1,106 @@ +import { Credential } from "./Credential"; + +describe("Credential model", () => { + describe("basic model props", () => { + it("should exist", () => { + assert(Credential); + }); + + it("should build with defaults", () => { + let instance = new Credential(); + assert(instance); + }); + + it("should build with props", () => { + let description = "i am the test description"; + let props = { description }; + let instance = new Credential(props); + assert(instance); + assert(instance.description == description); + }); + + it("default config object should be AWS", () => { + let instance = new Credential(); + assert("role_arn" in instance.config, "Missing role_arn prop"); + assert(instance.config.constructor.name == "AwsConfig"); + }); + + it("should load a different config object given the right provider", () => { + let provider = "azure"; + let props = { provider }; + let instance = new Credential(props); + assert(instance.provider == provider); + assert(instance.config.constructor.name == "AzureConfig"); + }); + + it("should dynamically switch config objects as resourceProvider is changed", () => { + let instance = new Credential(); + assert(instance.provider == "aws", "Should default to AWS config' "); + instance.resourceProvider = "azure"; + assert(instance.config.constructor.name == "AzureConfig", "Should have been AzureConfig"); + instance.resourceProvider = "aws"; + assert(instance.config.constructor.name == "AwsConfig", "Should be AWS Config again"); + }); + }); + + describe("dirty state", () => { + it("should flag as dirty when a prop is changed", () => { + let instance = new Credential(); + instance.description = "foo"; + assert(instance.dirty); + }); + + it("should flag as clean when a prop is restored", () => { + let instance = new Credential(); + instance.description = "foo"; + assert(instance.dirty); + instance.description = ""; + assert(!instance.dirty); + }); + + it("should not flag as dirty when a transient prop is changed", () => { + let instance = new Credential(); + instance.loading = true; + assert(!instance.dirty); + }); + }); + + describe("validation", () => { + it("a new object should be invalid", () => { + let instance = new Credential(); + assert(!instance.valid); + }); + + it("it should become valid when props are assigned", () => { + let instance = new Credential(); + instance.authn_id = "floob"; + instance.resourceProvider = "aws"; + instance.config.role_arn = "abc"; + assert(instance.valid); + }); + + it("it should be valid when initialized with correct props", () => { + let props = { + authn_id: "asdfasdf", + provider: "aws", + config: { + role_arn: "floobar" + } + }; + let instance = new Credential(props); + assert(instance.valid); + }); + + it("should be invalid when bad props assigned", () => { + let props = { + authn_id: "asdfasdf", + provider: "aws", + config: { + role_arn: "" + } + }; + let instance = new Credential(props); + assert(!instance.valid); + }); + }); +}); diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/IdentityProvider.js b/client/galaxy/scripts/components/User/CloudAuth/model/IdentityProvider.js new file mode 100644 index 00000000000..7c0974fc50b --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/IdentityProvider.js @@ -0,0 +1,29 @@ +import { safeAssign } from "utils/safeAssign"; + +export class IdentityProvider { + constructor(props = {}) { + this.id = ""; + this.provider = ""; + safeAssign(this, props); + } + + // Aliases + + get authn_id() { + return this.id; + } + + get text() { + return this.provider; + } + + get value() { + return this.id; + } + + // Statics + + static create(props = {}) { + return new IdentityProvider(props); + } +} diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/ResourceProviders.js b/client/galaxy/scripts/components/User/CloudAuth/model/ResourceProviders.js new file mode 100644 index 00000000000..965e254b2f1 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/ResourceProviders.js @@ -0,0 +1,14 @@ +import { AwsConfig } from "./AwsConfig"; +import { AzureConfig } from "./AzureConfig"; + +export const ResourceProviders = new Map(); + +ResourceProviders.set("aws", { + klass: AwsConfig, + label: "Amazon Web Services (AWS)" +}); + +ResourceProviders.set("azure", { + klass: AzureConfig, + label: "Microsoft Azure" +}); diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/index.js b/client/galaxy/scripts/components/User/CloudAuth/model/index.js new file mode 100644 index 00000000000..24f019b67c5 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/index.js @@ -0,0 +1,3 @@ +export { Credential } from "./Credential"; +export { IdentityProvider } from "./IdentityProvider"; +export { ResourceProviders } from "./ResourceProviders"; diff --git a/client/galaxy/scripts/components/User/CloudAuth/model/service.js b/client/galaxy/scripts/components/User/CloudAuth/model/service.js new file mode 100644 index 00000000000..28e6339b8c0 --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/model/service.js @@ -0,0 +1,78 @@ +/** + * Data retrieval/storage for the auth keys + */ + +import axios from "axios"; +import { Credential, IdentityProvider } from "./index"; +import { getRootFromIndexLink } from "onload"; + +const getUrl = path => getRootFromIndexLink() + path; + +export async function listCredentials() { + const url = getUrl("api/cloud/authz"); + const response = await axios.get(url); + if (response.status != 200) { + throw new Error("Unexpected response from listing."); + } + return response.data.map(Credential.create); +} + +export async function getCredential(id) { + const url = getUrl("api/cloud/authz/${id}"); + const response = await axios.get(url); + if (response.status != 200) { + throw new Error("Unexpected response loading key."); + } + return Credential.create(response.data); +} + +export async function saveCredential(newItem) { + const model = Credential.create(newItem); + const response = await saveOrUpdate(model); + if (response.status != 200) { + throw new Error("Save failure."); + } + return Credential.create(response.data); +} + +async function saveOrUpdate(model) { + return model.id + ? axios.put(getUrl(`api/cloud/authz/${model.id}`), model) + : axios.post(getUrl("api/cloud/authz"), model); +} + +export async function deleteCredential(doomed) { + const model = Credential.create(doomed); + if (model.id) { + const url = getUrl(`api/cloud/authz/${doomed.id}`); + const response = await axios.delete(url); + if (response.status != 200) { + throw new Error("Delete failure."); + } + } + return model; +} + +// Memoize results (basically never changes) + +let identityProviders; + +export async function getIdentityProviders() { + if (!identityProviders) { + const url = getUrl("authnz"); + const response = await axios.get(url); + if (response.status != 200) { + throw new Error("Unable to load identity providers"); + } + identityProviders = response.data.map(IdentityProvider.create); + } + return identityProviders; +} + +export default { + listCredentials, + getCredential, + saveCredential, + deleteCredential, + getIdentityProviders +}; diff --git a/client/galaxy/scripts/components/User/CloudAuth/testdata/listCredentials.json b/client/galaxy/scripts/components/User/CloudAuth/testdata/listCredentials.json new file mode 100644 index 00000000000..82973dc3a7f --- /dev/null +++ b/client/galaxy/scripts/components/User/CloudAuth/testdata/listCredentials.json @@ -0,0 +1,32 @@ +[ + { + "authn_id": "f2db41e1fa331b3e", + "user_id": "f2db41e1fa331b3e", + "description": "aws description", + "last_update": "2019-04-04 09:13:16.135032", + "last_activity": "2019-04-04 09:13:16.135042", + "create_time": "2019-04-04 16:13:16.135755", + "provider": "aws", + "model_class": "CloudAuthz", + "config": { + "role_arn": "floobadooba" + }, + "id": "a7db2fac67043c7e" + }, + { + "authn_id": "f2db41e1fa331b3e", + "user_id": "f2db41e1fa331b3e", + "description": "azure description", + "last_update": "2019-04-03 20:21:51.350748", + "last_activity": "2019-04-03 20:21:51.350804", + "create_time": "2019-04-04 03:21:51.351508", + "provider": "azure", + "model_class": "CloudAuthz", + "config": { + "client_secret": "dannon likes Nickleback", + "client_id": "22222222-2222-2222-2222-222222222222", + "tenant_id": "11111111-1111-1111-1111-111111111111" + }, + "id": "b472e2eb553fa0d1" + } +] \ No newline at end of file diff --git a/client/galaxy/scripts/entry/analysis/AnalysisRouter.js b/client/galaxy/scripts/entry/analysis/AnalysisRouter.js index 201fe9cdd31..94e5c91f2a8 100644 --- a/client/galaxy/scripts/entry/analysis/AnalysisRouter.js +++ b/client/galaxy/scripts/entry/analysis/AnalysisRouter.js @@ -38,6 +38,7 @@ import DatasetEditAttributes from "mvc/dataset/dataset-edit-attributes"; import Citations from "components/Citations.vue"; import DisplayStructure from "components/DisplayStructured.vue"; import Vue from "vue"; +import { CloudAuth } from "components/User/CloudAuth"; /** Routes */ export const getAnalysisRouter = Galaxy => @@ -47,6 +48,7 @@ export const getAnalysisRouter = Galaxy => "(/)root*": "home", "(/)tours(/)(:tour_id)": "show_tours", "(/)user(/)": "show_user", + "(/)user(/)cloud_auth": "show_cloud_auth", "(/)user(/)(:form_id)": "show_user_form", "(/)pages(/)create(/)": "show_pages_create", "(/)pages(/)edit(/)": "show_pages_edit", @@ -75,18 +77,19 @@ export const getAnalysisRouter = Galaxy => "(/)datasets/error": "show_dataset_error" }, - require_login: ["show_user", "show_user_form", "show_workflows"], + require_login: ["show_user", "show_user_form", "show_workflows", "show_cloud_auth"], authenticate: function(args, name) { const Galaxy = getGalaxyInstance(); return (Galaxy.user && Galaxy.user.id) || this.require_login.indexOf(name) == -1; }, - _display_vue_helper: function(component, props) { + _display_vue_helper: function(component, props = {}) { const instance = Vue.extend(component); - const vm = document.createElement("div"); - this.page.display(vm); - new instance(props).$mount(vm); + const container = document.createElement("div"); + this.page.display(container); + const vm = new instance(props).$mount(container); + return vm; }, show_tours: function(tour_id) { @@ -109,6 +112,10 @@ export const getAnalysisRouter = Galaxy => this.page.display(new FormWrapper.View(_.extend(model.get(form_id), { active_tab: "user" }))); }, + show_cloud_auth: function() { + this._display_vue_helper(CloudAuth); + }, + show_visualizations: function(action_id) { var activeTab = action_id == "list_published" ? "shared" : "user"; this.page.display( diff --git a/client/galaxy/scripts/mvc/user/user-preferences.js b/client/galaxy/scripts/mvc/user/user-preferences.js index dbb00fcd6b4..6efdc17bb59 100644 --- a/client/galaxy/scripts/mvc/user/user-preferences.js +++ b/client/galaxy/scripts/mvc/user/user-preferences.js @@ -90,6 +90,15 @@ var Model = Backbone.Model.extend({ submit_title: "Create a new key", submit_icon: "fa-check" }, + cloud_auth: { + title: _l("Manage Cloud Authorization"), + description: _l( + "Add or modify the configuration that grants Galaxy to access your cloud-based resources." + ), + icon: "fa-cloud", + submit_title: "Create a new key", + submit_icon: "fa-check" + }, toolbox_filters: { title: _l("Manage Toolbox filters"), description: _l("Customize your Toolbox by displaying or omitting sets of Tools."), @@ -174,6 +183,7 @@ var View = Backbone.View.extend({ self._addLink("permissions"); self._addLink("make_data_private"); self._addLink("api_key"); + self._addLink("cloud_auth"); if (config.enable_openid) { self._addLink("genomespace"); } diff --git a/client/galaxy/scripts/utils/safeAssign.js b/client/galaxy/scripts/utils/safeAssign.js new file mode 100644 index 00000000000..b10591e7fbf --- /dev/null +++ b/client/galaxy/scripts/utils/safeAssign.js @@ -0,0 +1,11 @@ +// Object.assign, but props transferred must exist in target + +export function safeAssign(target, source = {}) { + if (!(source instanceof Object)) { + console.warn("safeAssign expected an object, instead got:", source); + source = {}; + } + Object.keys(source) + .filter(prop => target.hasOwnProperty(prop)) + .forEach(prop => (target[prop] = source[prop])); +} diff --git a/client/galaxy/style/scss/mixins.scss b/client/galaxy/style/scss/mixins.scss index 6cdf41fd46f..81f7b907670 100644 --- a/client/galaxy/style/scss/mixins.scss +++ b/client/galaxy/style/scss/mixins.scss @@ -1,3 +1,7 @@ +$fa-font-path: "~font-awesome/fonts/"; +@import "~font-awesome/scss/font-awesome.scss"; +@import "~font-awesome/scss/_mixins"; + // Utility mixin expands to container edges @mixin fill() { position: relative; @@ -29,3 +33,51 @@ display: none; } } + +@mixin scrollMe() { + overflow-x: hidden; + overflow-y: scroll; + &::-webkit-scrollbar { + display: none; + } +} + +// top selector fixed, bottom list scrolls +@mixin scrollingListLayout($fixedTop, $listRegion) { + @include fill(); + + display: flex; + flex-direction: column; + + #{$fixedTop} { + flex-grow: 0; + } + + #{$listRegion} { + flex-grow: 1; + @include scrollMe(); + } +} + +// Removes browser formatting of lists + +@mixin list_reset() { + list-style: none; + padding: 0 0 0 0; + margin: 0 0 0 0; +} + +// font awesome as a mixin +// icon variables come from ~font-awesome/scss/_variables.scss + +@mixin fontawesome($icon) { + @include fa-icon(); + &:before { + content: $icon; + } + span { + // Span for text readers + // Label + @include sr-only(); + } +} diff --git a/client/package.json b/client/package.json index 4aeb15dd09e..99e6b5a7201 100644 --- a/client/package.json +++ b/client/package.json @@ -23,8 +23,10 @@ "bootstrap-vue": "^2.0.0-rc.16", "d3": "3", "decode-uri-component": "^0.2.0", + "flush-promises": "^1.0.2", "font-awesome": "^4.7.0", "handsontable": "^2.0.0", + "imask": "^4.1.5", "imports-loader": "^0.8.0", "jquery": "2", "jquery-migrate": "~1.4", diff --git a/client/webpack.config.js b/client/webpack.config.js index 18973b9d67b..20a17781fd9 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -162,7 +162,10 @@ let buildconfig = { loader: "sass-loader", options: { sourceMap: true, - includePaths: ["galaxy/style/scss"] + includePaths: [ + "galaxy/style/scss", + path.resolve(__dirname, './node_modules') + ] } } ] diff --git a/client/yarn.lock b/client/yarn.lock index 3f407c345fc..8562dc2841f 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -5530,6 +5530,11 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916" integrity sha512-R+H8IZclI8AAkSBRQJLVOsxwAoHd6WC40b4QTNWIjzAa6BXOBfQcM587MXDTVPeYaopFNWHUFLx7eNmHDSxMWg== +flush-promises@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/flush-promises/-/flush-promises-1.0.2.tgz#4948fd58f15281fed79cbafc86293d5bb09b2ced" + integrity sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA== + flush-write-stream@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.3.tgz#c5d586ef38af6097650b49bc41b55fabb19f35bd" @@ -6480,6 +6485,11 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== +imask@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/imask/-/imask-4.1.5.tgz#9942993ece68b7b33d03e12781a7401b1207e52c" + integrity sha512-n9e3KkJxe6YZKueT02I8/tXpC0vzxmSzoBB5BPi6OPq0rp7qOZfXN5777Dkw6WEYRswlsxm+7pl7WocsG0LWyA== + immer@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" diff --git a/lib/galaxy/managers/cloudauthzs.py b/lib/galaxy/managers/cloudauthzs.py index 3d879c3498d..1e5173f7b79 100644 --- a/lib/galaxy/managers/cloudauthzs.py +++ b/lib/galaxy/managers/cloudauthzs.py @@ -44,7 +44,8 @@ class CloudAuthzsSerializer(base.ModelSerializer): 'authn_id', 'last_update', 'last_activity', - 'create_time' + 'create_time', + 'description' ]) def add_serializers(self): @@ -63,7 +64,8 @@ class CloudAuthzsSerializer(base.ModelSerializer): 'authn_id' : lambda i, k, **c: self.app.security.encode_id(i.authn_id), 'last_update' : lambda i, k, **c: str(i.last_update), 'last_activity': lambda i, k, **c: str(i.last_activity), - 'create_time' : lambda i, k, **c: str(i.create_time) + 'create_time' : lambda i, k, **c: str(i.create_time), + 'description' : lambda i, k, **c: str(i.description) }) @@ -80,7 +82,7 @@ class CloudAuthzsDeserializer(base.ModelDeserializer): 'authn_id': self.deserialize_and_validate_authn_id, 'provider': self.default_deserializer, 'config': self.default_deserializer, - 'deleted': self.default_deserializer + 'description': self.default_deserializer }) def deserialize_and_validate_authn_id(self, item, key, val, **context):