Cloud auth key UI elements

This commit is contained in:
Mason
2019-04-05 08:06:41 -07:00
parent 842297e2e9
commit a57ebcaf01
32 changed files with 1497 additions and 6 deletions
@@ -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) {
let result = this.stringifyValue(value);
if (this.mask.length) {
return this.masker.resolve(result);
}
return result;
}
}
}
@@ -0,0 +1,61 @@
@import "scss/mixins";
@import "./operations";
// General Layout
.cloud-auth {
// header sticks, bottom part scrolls
@include scrollingListLayout("header", ".scroll-container");
// title left, icons right
.cloud-auth-title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
// menu
.operations {
.help a {
@include fontawesome($fa-var-info-circle);
}
.filter a {
@include fontawesome($fa-var-filter);
}
.create a {
@include fontawesome($fa-var-plus);
}
}
}
// Transitions
.fade-enter-active,
.fade-leave-active {
transition: opacity .5s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
// Unhose base.scss hacks
.cloud-auth div.form-row label {
font-weight: 400;
}
#deleteCredentialModal {
.modal-body {
display: none;
}
.modal-dialog {
max-width: 300px;
}
}
@@ -0,0 +1,53 @@
/* global expect */
import sinon from "sinon";
import { default as CloudAuth, __RewireAPI__ as rewire } from "./CloudAuth";
import CloudAuthItem from "./CloudAuthItem.vue";
import { mount, shallowMount, createLocalVue } from "@vue/test-utils";
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 wrapper, stub;
let mockSvc = {
listCredentials: async () => null,
};
beforeEach(() => {
wrapper = shallowMount(CloudAuth, { localVue });
rewire.__Rewire__("svc", mockSvc);
});
afterEach(() => {
if (stub) {
stub.restore();
}
});
describe("initialization", () => {
// fake list load
stub = sinon.stub(mockSvc, "listCredentials").resolves(listCredentials);
it("should mount", () => {
assert(wrapper);
})
it("should show one item", () => {
let keyItems = wrapper.findAll(".cloud-auth-key");
let firstitem = keyItems.at(0);
assert(keyItems);
})
})
})
@@ -0,0 +1,188 @@
<template>
<section class="cloud-auth pt-1">
<header>
<hgroup class="cloud-auth-title">
<h1>Manage Cloud Authorization</h1>
<nav class="operations">
<ul>
<li class="help">
<a @click.prevent="showHelp = !showHelp"
v-b-tooltip.hover
aria-label="Instructions"
title="Instructions">
<span>Instructions</span>
</a>
</li>
<li class="filter">
<a @click.prevent="showFilter = !showFilter"
v-b-tooltip.hover
aria-label="Filter Results"
title="Filter Results">
<span>Filter</span>
</a>
</li>
<li class="create">
<a @click.prevent="onCreate"
v-b-tooltip.hover
aria-label="Create New Key"
title="Create New Key">
<span>Create New Key</span>
</a>
</li>
</ul>
</nav>
</hgroup>
<transition name="fade">
<hgroup v-if="showHelp">
<p>See the online <a href="https://galaxyproject.org/cloud/authnz/">Documentation</a>.</p>
</hgroup>
</transition>
<transition name="fade">
<hgroup v-if="showFilter">
<b-form-group :description="filterDescription">
<b-form-input type="text" placeholder="Filter"
v-model="filter" />
</b-form-group>
</hgroup>
</transition>
<b-button-group class="mb-4">
<button variant="success" @click="onCreate">
Create New Authorization Key
</button>
</b-button-group>
</header>
<div class="scroll-container">
<b-list-group>
<transition-group name="fade">
<cloud-auth-item v-for="credential in filteredItems"
:key="credential.counter"
:credential="credential"
class="mb-1"
@delete="onDelete"
@save="onSave"
@expand="onExpand(credential, $event)" />
</transition-group>
</b-list-group>
</div>
<b-modal id="deleteCredentialModal" ref="deleteModal"
title="Delete Item?" size="sm"
@ok="onConfirmDelete"
@cancel="onCancelDelete">
</b-modal>
</section>
</template>
<script>
import CloudAuthItem from "./CloudAuthItem";
import { Credential } from "./model";
import svc from "./model/service";
export default {
components: {
CloudAuthItem
},
data() {
return {
items: [],
filter: "",
showHelp: true,
showFilter: false,
loading: false
}
},
computed: {
filteredItems() {
return this.items
.filter(o => o.filter(this.filter));
},
filterDescription() {
return `${this.filteredItems.length} matches out of ${this.items.length} items`;
},
deleteButtonVariant() {
return this.showDeleted ? "primary": "secondary";
}
},
watch: {
showDeleted(deleted) {
this.loadCredentials({ deleted });
}
},
methods: {
onCreate() {
this.addItem(Credential.create());
},
onSave(item) {
if (!item.valid) {
return;
}
item.loading = true;
svc.saveCredential(item)
.then(result => {
item.id = result.id;
item.updateState();
})
.catch(err => console.warn('bad save', err))
.finally(() => item.loading = false);
},
onDelete(doomed) {
// bootstrapVue modal v-model property is bugged
// so need to set and retrieve the doomed item manually
this.doomedItem = doomed;
this.$refs.deleteModal.show();
},
onConfirmDelete() {
let doomed = this.doomedItem;
doomed.loading = true;
svc.deleteCredential(doomed)
.then(() => this.removeItem(doomed))
.catch(err => console.warn('bad delete', err))
.finally(() => {
doomed.loading = false;
this.onCancelDelete();
});
},
onCancelDelete() {
this.doomedItem = null;
},
onExpand(credential, { expanded }) {
credential.expanded = expanded;
if (expanded) {
this.items.filter(i => i !== credential)
.forEach(i => i.expanded = false);
}
},
addItem(item) {
this.items = [ ...this.items, item ];
},
removeItem(item) {
this.items = this.items.filter(o => o !== item);
},
loadCredentials(params = {}) {
this.loading = true;
svc.listCredentials(params)
.then(items => this.items = items)
.catch(err => console.warn("bad load", err))
.finally(() => this.loading = false);
}
},
created() {
this.loadCredentials();
}
}
</script>
<style lang="scss" src="./CloudAuth.scss"></style>
@@ -0,0 +1,70 @@
@import "scss/theme/blue.scss";
@import "scss/mixins";
@import "./operations";
.cloud-auth-key {
header hgroup {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
h4 {
cursor: pointer;
user-select: none;
margin-bottom: 0;
}
}
form {
.custom-select {
background: none; // removes wierd double arrows on select
}
footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
}
// icon menu
.operations {
.save a {
@include fontawesome($fa-var-save)
}
.delete a {
@include fontawesome($fa-var-times);
}
.details a {
@include fontawesome($fa-var-window-minimize);
&:hover {
@include fontawesome($fa-var-window-maximize);
}
}
}
// reverse icons when key is expanded
&.expanded .details a {
@include fontawesome($fa-var-window-maximize);
&:hover {
@include fontawesome($fa-var-window-minimize);
}
}
// save disk changes color based on form valid/dirty
&.valid .save a {
color: $state-success-text;
}
&:not(.valid) .save a {
color: $state-danger-text;
}
}
@@ -0,0 +1,88 @@
<template>
<b-list-group-item class="cloud-auth-key"
:class="statusClasses"
:variant="variant"
:button="!expanded">
<header>
<hgroup>
<h4 @click.prevent="expand()">{{ credential.title }}</h4>
<nav class="operations">
<ul>
<transition name="fade">
<li class="save" v-if="expanded && credential.dirty">
<a @click.prevent="$emit('save', credential)"
v-b-tooltip.hover
aria-label="Save Key"
title="Save Key">
<span>Save</span>
</a>
</li>
</transition>
<li class="delete">
<a @click.prevent="$emit('delete', credential)"
v-b-tooltip.hover
aria-label="Delete this key"
title="Delete this key">
<span>Delete</span>
</a>
</li>
<li class="details">
<a @click.prevent="expand()"
v-b-tooltip.hover
aria-label="Show Details"
title="Show Details">
<span>Details</span>
</a>
</li>
</ul>
</nav>
</hgroup>
</header>
<credential-form v-if="expanded"
class="mt-4 pt-4 border-top"
v-model="credential"
@click.self="expand()"
@save="$emit('save', credential)"
@delete="$emit('delete', credential)" />
</b-list-group-item>
</template>
<script>
import Vue from "vue";
import { Credential } from "./model";
import CredentialForm from "./CredentialForm";
export default {
components: {
CredentialForm
},
props: {
credential: { type: Credential, required: true }
},
computed: {
statusClasses() {
let { expanded, valid, dirty, loading } = this.credential;
return { loading, expanded, valid, dirty, collapsed: !expanded };
},
variant() {
return this.expanded ? "" : "primary";
},
expanded() {
return this.credential.expanded;
}
},
methods: {
expand(forceState) {
let expanded = (forceState !== undefined) ? forceState : !this.expanded;
this.$emit("expand", { expanded });
}
}
}
</script>
<style lang="scss" src="./CloudAuthItem.scss"></style>
@@ -0,0 +1,45 @@
<template>
<div>
<b-form-group v-for="(field, fieldName) in fieldDefs"
:key="fieldName"
:label="field.label || fieldName"
:label-for="'config-' + fieldName"
label-cols-lg="3"
:state="config.fieldValid(fieldName)"
:invalid-feedback="config.errorMessage(fieldName)">
<masked-input :id="'config-' + fieldName"
v-model="config[fieldName]"
:mask="field.mask"
:placeholder="field.placeholder"
:maxlength="field.maxlength"
:state="config.fieldValid(fieldName)"
trim />
</b-form-group>
</div>
</template>
<script>
import MaskedInput from "components/MaskedInput";
export default {
components: {
MaskedInput
},
props: {
value: { type: Object, required: true }
},
computed: {
config() {
return this.value;
},
fieldDefs() {
return this.value.constructor.fields;
}
}
}
</script>
@@ -0,0 +1,103 @@
<template>
<b-form :validated="credential.isValid">
<!-- user label -->
<b-form-group
label="Description"
label-for="credentialDescription"
label-cols-lg="3">
<b-form-input
id="credentialDescription"
v-model="credential.description"
:state="credential.fieldValid('description')"
placeholder="Description (optional)"
trim />
</b-form-group>
<!-- google-openidconnect -->
<b-form-group
label="Identity Provider"
label-for="identityProvider"
label-cols-lg="3"
v-if="identityProviders.length > 1">
<b-form-select
id="identityProvider"
v-model="credential.authn_id"
:state="credential.fieldValid('authn_id')"
:options="identityProviders" />
</b-form-group>
<!-- aws/azure, etc -->
<b-form-group
label="Resource Provider"
label-for="resourceProvider"
label-cols-lg="3">
<b-form-select
id="resourceProvider"
v-model="credential.resourceProvider"
:state="credential.fieldValid('provider')"
:options="resourceProviderOptions" />
</b-form-group>
<credential-config v-model="credential.config" />
<footer>
<b-button type="cancel"
@click.prevent="$emit('delete', credential)">
Delete Key
</b-button>
<b-button type="submit"
aria-label="Save Key"
@click.prevent="$emit('save', credential)">
Save This Key
</b-button>
</footer>
</b-form>
</template>
<script>
import { Credential, ResourceProviders } from "./model";
import { getIdentityProviders } from "./model/service";
import CredentialConfig from "./CredentialConfig";
export default {
components: {
CredentialConfig
},
props: {
value: { type: Credential, required: true }
},
data() {
return {
identityProviders: []
}
},
computed: {
credential() {
return this.value;
},
// transformed for bootstrap component
resourceProviderOptions() {
let opts = Array.from(ResourceProviders.entries())
.map(([value, o]) => ({ value, text: o.label }));
return opts;
},
config() {
return this.credential.config;
}
},
created() {
getIdentityProviders()
.then(result => {
if (!this.credential.authn_id && result.length == 1) {
this.credential.authn_id = result[0].authn_id;
this.credential.updateState();
}
this.identityProviders = result;
});
}
}
</script>
@@ -0,0 +1 @@
export { default as CloudAuth } from "./CloudAuth.vue";
@@ -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) {
let 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"
}
}
@@ -0,0 +1,60 @@
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) {
let 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."
}
}
@@ -0,0 +1,21 @@
import { AzureConfig } from "./AzureConfig";
describe("AzureConfig", () => {
it("should instantiate", () => {
let instance = new AzureConfig();
assert(instance);
assert(!instance.dirty);
assert(!instance.valid);
})
it("should validate client_secret", () => {
let instance = new AzureConfig();
instance.tenant_id = "abc";
instance.client_secret = "abc";
assert(instance.fieldValid("client_secret"));
assert(!instance.fieldValid("tenant_id"));
assert(!instance.fieldValid("client_id"));
})
})
@@ -0,0 +1,109 @@
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
let 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() {
let 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) {
let klass = this; // this will be a class
if (!transients.has(klass)) {
transients.set(klass, new Set());
}
let 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)) {
let validator = validators.get(this);
return validator(model);
}
throw new Error("Missing validator");
}
static setValidator(validationFunction) {
validators.set(this, validationFunction);
}
}
@@ -0,0 +1,111 @@
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
let 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
filter(searchText = "") {
// TODO: better text filtering
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) {
let 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;
})
@@ -0,0 +1,114 @@
import { Credential } from "./Credential";
import { ResourceProviders } from "./ResourceProviders";
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);
})
})
})
@@ -0,0 +1,30 @@
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);
}
}
@@ -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"
});
@@ -0,0 +1,3 @@
export { Credential } from "./Credential";
export { IdentityProvider } from "./IdentityProvider";
export { ResourceProviders } from "./ResourceProviders";
@@ -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() {
let url = getUrl("api/cloud/authz");
let response = await axios.get(url);
if (response.status != 200) {
throw new Error("Bad mojo");
}
return response.data.map(Credential.create);
}
export async function getCredential(id) {
let url = getUrl("api/cloud/authz/${id}");
let response = await axios.get(url);
if (response.status != 200) {
throw new Error("Bad load");
}
return Credential.create(response.data);
}
export async function saveCredential(newItem) {
let model = Credential.create(newItem);
let response = await saveOrUpdate(model);
if (response.status != 200) {
throw new Error("bad save");
}
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) {
let model = Credential.create(doomed);
if (model.id) {
let url = getUrl(`api/cloud/authz/${doomed.id}`);
let response = await axios.delete(url);
if (response.status != 200) {
throw new Error("Bad delete");
}
}
return model;
}
// Memoize results (basically never changes)
let identityProviders;
export async function getIdentityProviders() {
if (!identityProviders) {
let url = getUrl("authnz");
let 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
}
@@ -0,0 +1,30 @@
/**
I put the styles for this little icon menu in its own file because
I think this should probably become its own general component
TODO: Build operations menu component for reuse wherever we do little
icon doodads
*/
// TODO: Show this to Sam, this is what I need to import
// to use ml-2 because our own base.scss is not importable
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins";
@import "~bootstrap/scss/utilities/spacing";
@import "scss/theme/blue.scss";
@import "scss/mixins";
.operations {
margin-bottom: 0;
ul {
@include list_reset();
display: flex;
li:not(:first-child) {
@extend .ml-2; // terrible
}
}
}
@@ -0,0 +1,17 @@
[
{
"authn_id": "f2db41e1fa331b3e",
"user_id": "f2db41e1fa331b3e",
"deleted": "True",
"last_update": "2019-03-27 10:08:27.711092",
"last_activity": "2019-03-27 10:08:27.711104",
"create_time": "2019-03-27 17:08:27.712045",
"provider": "aws",
"model_class": "CloudAuthz",
"config": {
"role_arn": "arn:sws:idm::347162595"
},
"description": "floob",
"id": "a799d38679e985db"
}
]
@@ -0,0 +1,39 @@
<section class="cloud-auth"><header><hgroup class="cloud-auth-title"><h2>Cloud
Auth Keys</h2> <nav class="operations"><ul><li><a class="fa
fa-info-circle"><span>Help</span></a></li> <li><a
class="fa fa-filter"><span>Filter</span></a></li>
<li><a class="fa fa-plus"><span>New Item</span></a></li></ul></nav></hgroup>
<hgroup class="help active"><p>Lorem ipsum dolor sit amet, consectetur
adipiscing elit.
Fusce vehicula placerat ante et tincidunt. Donec congue
pellentesque tortor sagittis feugiat. Duis rhoncus arcu
id erat ultrices egestas. Suspendisse rutrum ipsum id</p></hgroup>
<hgroup class="filter active"><fieldset class="form-group"><!----><div
tabindex="-1" role="group"><input type="text"
placeholder="Filter" class="form-control"><!----><!----><small
tabindex="-1" class="form-text text-muted">0 matches out
of 0 items</small></div></fieldset></hgroup></header>
<div class="scroll-container"><div class="list-group"><button type="button"
class="list-group-item cloud-auth-key list-group-item-success
list-group-item-action">
Create New Authorization
</button> </div></div> <div><div
id="deleteCredentialModal___BV_modal_outer_" style="position:
absolute; z-index: 2000;"><div id="deleteCredentialModal"
role="dialog" tabindex="-1" aria-hidden="true" class="modal
fade" style="display: none;"><div class="modal-dialog modal-sm"><div
role="document"
id="deleteCredentialModal___BV_modal_content_"
aria-labelledby="deleteCredentialModal___BV_modal_header_"
aria-describedby="deleteCredentialModal___BV_modal_body_"
class="modal-content"><header
id="deleteCredentialModal___BV_modal_header_"
class="modal-header"><h5 class="modal-title">Delete
Item?</h5><button type="button"
aria-label="Close" class="close">×</button></header><div
id="deleteCredentialModal___BV_modal_body_"
class="modal-body"></div><footer
id="deleteCredentialModal___BV_modal_footer_"
class="modal-footer"><button type="button"
class="btn btn-secondary">Cancel</button><button
type="button" class="btn btn-primary">OK</button></footer></div></div></div><!----><!----></div></div></section>
@@ -0,0 +1,31 @@
/authnz
// options for authn dropdown
[
{"id": "f597429621d6eb2b", "provider": "google-openidconnect"},
{"id": "f597429621d6eb2b", "provider": "google-openidconnect"},
{"id": "f597429621d6eb2b", "provider": "google-openidconnect"},
{"id": "f597429621d6eb2b", "provider": "google-openidconnect"},
{"id": "f597429621d6eb2b", "provider": "google-openidconnect"}
]
{
"provider": "aws",
"authn_id":"f2db41e1fa331b3e",
"description": "asdfasdf",
"config": {
"role_arn": "arn:aws:iam::347162595075:role/CloudAuthzTests"
}
}
{
"provider": "azure",
"authn_id": "f2db41e1fa331b3e",
"description": "asdfasdf",
"config": {
'tenant_id': TENANT_ID,
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET
}
}
@@ -0,0 +1,38 @@
[
{
"authn_id": "f2db41e1fa331b3e",
"user_id": "f2db41e1fa331b3e",
"last_update": "2019-03-24 23:29:10.939661",
"last_activity": "2019-03-24 23:29:10.939670",
"provider": "aws",
"model_class": "CloudAuthz",
"config": {
"role_arn": "asdfadfasdf"
},
"id": "ebfb8f50c6abde6d"
},
{
"authn_id": "f2db41e1fa331b3e",
"user_id": "f2db41e1fa331b3e",
"last_update": "2019-03-24 23:25:51.678020",
"last_activity": "2019-03-24 23:25:51.678030",
"provider": "aws",
"model_class": "CloudAuthz",
"config": {
"role_arn": "asdfadfasdf"
},
"id": "1cd8e2f6b131e891"
},
{
"authn_id": "f2db41e1fa331b3e",
"user_id": "f2db41e1fa331b3e",
"last_update": "2019-03-23 15:43:14.511896",
"last_activity": "2019-03-23 15:43:14.511905",
"provider": "aws",
"model_class": "CloudAuthz",
"config": {
"role_arn": "asdfadfasdf"
},
"id": "f597429621d6eb2b"
}
]
@@ -0,0 +1,41 @@
<section class="cloud-auth">
<header>
<hgroup class="cloud-auth-title">
<h2>Cloud Auth Keys</h2>
<nav class="operations">
<ul><li><a class="fa fa-info-circle"><span>Help</span></a></li>
<li><a class="fa fa-filter"><span>Filter</span></a></li>
<li><a class="fa fa-plus"><span>New Item</span></a></li>
</ul>
</nav>
</hgroup>
<hgroup class="help active">
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Fusce vehicula placerat ante et tincidunt. Donec congue
pellentesque tortor sagittis feugiat. Duis rhoncus arcu
id erat ultrices egestas. Suspendisse rutrum ipsum id</p>
</hgroup>
<hgroup class="filter active">
<b-form-group-stub description="0 matches out of 0 items">
<b-form-input-stub value="" placeholder="Filter"
type="text"></b-form-input-stub>
</b-form-group-stub>
</hgroup>
</header>
<div class="scroll-container">
<b-list-group-stub tag="div">
<b-list-group-item-stub tag="div" button="true" variant="success" target="_self" event="click" routertag="a" class="cloud-auth-key">
Create New Authorization
</b-list-group-item-stub>
</b-list-group-stub>
</div>
<b-modal-stub id="deleteCredentialModal" title="Delete Item?"
titletag="h5" size="sm" buttonsize="" headercloselabel="Close"
canceltitle="Cancel" oktitle="OK" cancelvariant="secondary"
okvariant="primary">
</b-modal-stub>
</section>
@@ -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) {
let 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 = {}) {
let instance = Vue.extend(component);
let vm = document.createElement("div");
this.page.display(vm);
new instance(props).$mount(vm);
let container = document.createElement("div");
this.page.display(container);
let 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(
@@ -90,6 +90,13 @@ 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."),
@@ -166,6 +173,7 @@ var View = Backbone.View.extend({
self._addLink("permissions");
self._addLink("make_data_private");
self._addLink("api_key");
self._addLink("cloud_auth");
if (config.has_user_tool_filters) {
self._addLink("toolbox_filters");
}
+11
View File
@@ -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]);
}
+55
View File
@@ -1,3 +1,8 @@
$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 +34,53 @@
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
// <a><span>Label</span></a>
@include sr-only();
}
}
+1
View File
@@ -25,6 +25,7 @@
"decode-uri-component": "^0.2.0",
"font-awesome": "^4.7.0",
"handsontable": "^2.0.0",
"imask": "^4.1.5",
"imports-loader": "^0.8.0",
"jquery": "2",
"jquery-migrate": "~1.4",
+4 -1
View File
@@ -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')
]
}
}
]
+5
View File
@@ -6501,6 +6501,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"