From ffd16dfec6dc4b4900b58a4c413511ada3dcbeaf Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Mon, 28 Nov 2022 10:44:52 -0500 Subject: [PATCH] [ui, epic] SSO and Auth improvements (#15110) * Top nav auth dropdown (#15055) * Basic dropdown styles * Some cleanup * delog * Default nomad hover state styles * Component separation-of-concerns and acceptance tests for auth dropdown * lintfix * [ui, sso] Handle token expiry 500s (#15073) * Handle error states generally * Dont direct, just redirect * no longer need explicit error on controller * Redirect on token-doesnt-exist * Forgot to import our time lib * Linting on _blank * Redirect tests * changelog * [ui, sso] warn user about pending token expiry (#15091) * Handle error states generally * Dont direct, just redirect * no longer need explicit error on controller * Linting on _blank * Custom notification actions and shift the template to within an else block * Lintfix * Make the closeAction optional * changelog * Add a mirage token that will always expire in 11 minutes * Test for token expiry with ember concurrency waiters * concurrency handling for earlier test, and button redirect test * [ui] if ACLs are disabled, remove the Sign In link from the top of the UI (#15114) * Remove top nav link if ACLs disabled * Change to an enabled-by-default model since you get no agent config when ACLs are disabled but you lack a token * PR feedback addressed; down with double negative conditionals * lintfix * ember getter instead of ?.prop * [SSO] Auth Methods and Mock OIDC Flow (#15155) * Big ol first pass at a redirect sign in flow * dont recursively add queryparams on redirect * Passing state and code qps * In which I go off the deep end and embed a faux provider page in the nomad ui * Buggy but self-contained flow * Flow auto-delay added and a little more polish to resetting token * secret passing turned to accessor passing * Handle SSO Failure * General cleanup and test fix * Lintfix * SSO flow acceptance tests * Percy snapshots added * Explicitly note the OIDC test route is mirage only * Handling failure case for complete-auth * Leentfeex * Tokens page styles (#15273) * styling and moving columns around * autofocus and enter press handling * Styles refined * Split up manager and regular tests * Standardizing to a binary status state * Serialize auth-methods response to use "name" as primary key (#15380) * Serializer for unique-by-name * Use @classic because of class extension --- .changelog/15073.txt | 3 + .changelog/15091.txt | 3 + ui/app/adapters/auth-method.js | 41 ++++ ui/app/components/global-header.js | 11 + ui/app/components/profile-navbar-item.hbs | 24 ++ ui/app/components/profile-navbar-item.js | 36 +++ ui/app/controllers/oidc-mock.js | 32 +++ ui/app/controllers/settings/tokens.js | 120 ++++++++-- ui/app/models/auth-method.js | 19 ++ ui/app/models/token.js | 5 + ui/app/router.js | 4 + ui/app/routes/application.js | 13 +- ui/app/routes/oidc-mock.js | 9 + ui/app/routes/settings/tokens.js | 12 + ui/app/serializers/auth-method.js | 7 + ui/app/services/keyboard.js | 4 +- ui/app/services/token.js | 75 +++++- ui/app/styles/components.scss | 1 + ui/app/styles/components/authorization.scss | 50 ++++ ui/app/styles/core/forms.scss | 32 +++ ui/app/styles/core/navbar.scss | 29 ++- ui/app/styles/core/notifications.scss | 9 +- ui/app/templates/application.hbs | 10 +- ui/app/templates/components/global-header.hbs | 6 +- ui/app/templates/oidc-mock.hbs | 17 ++ ui/app/templates/settings/tokens.hbs | 219 +++++++++++------- ui/mirage/config.js | 33 +++ ui/mirage/factories/agent.js | 3 + ui/mirage/factories/auth-method.js | 15 ++ ui/mirage/factories/token.js | 5 + ui/mirage/scenarios/default.js | 9 + ui/tests/acceptance/global-header-test.js | 37 ++- ui/tests/acceptance/token-test.js | 214 ++++++++++++++++- ui/tests/pages/layout.js | 16 ++ ui/tests/pages/settings/tokens.js | 2 + 35 files changed, 1020 insertions(+), 105 deletions(-) create mode 100644 .changelog/15073.txt create mode 100644 .changelog/15091.txt create mode 100644 ui/app/adapters/auth-method.js create mode 100644 ui/app/components/profile-navbar-item.hbs create mode 100644 ui/app/components/profile-navbar-item.js create mode 100644 ui/app/controllers/oidc-mock.js create mode 100644 ui/app/models/auth-method.js create mode 100644 ui/app/routes/oidc-mock.js create mode 100644 ui/app/routes/settings/tokens.js create mode 100644 ui/app/serializers/auth-method.js create mode 100644 ui/app/styles/components/authorization.scss create mode 100644 ui/app/templates/oidc-mock.hbs create mode 100644 ui/mirage/factories/auth-method.js diff --git a/.changelog/15073.txt b/.changelog/15073.txt new file mode 100644 index 000000000..bed479b46 --- /dev/null +++ b/.changelog/15073.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: redirect users to Sign In should their tokens ever come back expired or not-found +``` diff --git a/.changelog/15091.txt b/.changelog/15091.txt new file mode 100644 index 000000000..0a51fb7a2 --- /dev/null +++ b/.changelog/15091.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: give users a notification if their token is going to expire within the next 10 minutes +``` diff --git a/ui/app/adapters/auth-method.js b/ui/app/adapters/auth-method.js new file mode 100644 index 000000000..42926c52c --- /dev/null +++ b/ui/app/adapters/auth-method.js @@ -0,0 +1,41 @@ +// @ts-check +import { default as ApplicationAdapter, namespace } from './application'; +import { dasherize } from '@ember/string'; +import classic from 'ember-classic-decorator'; + +@classic +export default class AuthMethodAdapter extends ApplicationAdapter { + namespace = `${namespace}/acl`; + + /** + * @param {string} modelName + * @returns {string} + */ + urlForFindAll(modelName) { + return dasherize(this.buildURL(modelName)); + } + + /** + * @typedef {Object} ACLOIDCAuthURLParams + * @property {string} AuthMethod + * @property {string} RedirectUri + * @property {string} ClientNonce + * @property {Object[]} Meta // NOTE: unsure if array of objects or kv pairs + */ + + /** + * @param {ACLOIDCAuthURLParams} params + * @returns + */ + getAuthURL({ AuthMethod, RedirectUri, ClientNonce, Meta }) { + const url = `/${this.namespace}/oidc/auth-url`; + return this.ajax(url, 'POST', { + data: { + AuthMethod, + RedirectUri, + ClientNonce, + Meta, + }, + }); + } +} diff --git a/ui/app/components/global-header.js b/ui/app/components/global-header.js index a3f3b26bf..613a757ec 100644 --- a/ui/app/components/global-header.js +++ b/ui/app/components/global-header.js @@ -11,4 +11,15 @@ export default class GlobalHeader extends Component { 'data-test-global-header' = true; onHamburgerClick() {} + + // Show sign-in if: + // - User can't load agent config (meaning ACLs are enabled but they're not signed in) + // - User can load agent config in and ACLs are enabled (meaning ACLs are enabled and they're signed in) + // The excluded case here is if there is both an agent config and ACLs are disabled + get shouldShowProfileNav() { + return ( + !this.system.agent?.get('config') || + this.system.agent?.get('config.ACL.Enabled') === true + ); + } } diff --git a/ui/app/components/profile-navbar-item.hbs b/ui/app/components/profile-navbar-item.hbs new file mode 100644 index 000000000..35f3113dc --- /dev/null +++ b/ui/app/components/profile-navbar-item.hbs @@ -0,0 +1,24 @@ +{{#if this.token.selfToken}} + + Profile + {{option.label}} + +{{else}} + + Sign In + +{{/if}} + +{{yield}} diff --git a/ui/app/components/profile-navbar-item.js b/ui/app/components/profile-navbar-item.js new file mode 100644 index 000000000..76b12d812 --- /dev/null +++ b/ui/app/components/profile-navbar-item.js @@ -0,0 +1,36 @@ +// @ts-check + +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class ProfileNavbarItemComponent extends Component { + @service token; + @service router; + @service store; + + profileOptions = [ + { + label: 'Authorization', + key: 'authorization', + action: () => { + this.router.transitionTo('settings.tokens'); + }, + }, + { + label: 'Sign Out', + key: 'sign-out', + action: () => { + this.token.setProperties({ + secret: undefined, + }); + + // Clear out all data to ensure only data the anonymous token is privileged to see is shown + this.store.unloadAll(); + this.token.reset(); + this.router.transitionTo('jobs.index'); + }, + }, + ]; + + profileSelection = this.profileOptions[0]; +} diff --git a/ui/app/controllers/oidc-mock.js b/ui/app/controllers/oidc-mock.js new file mode 100644 index 000000000..2a7293c71 --- /dev/null +++ b/ui/app/controllers/oidc-mock.js @@ -0,0 +1,32 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import Ember from 'ember'; + +export default class OidcMockController extends Controller { + @service router; + + queryParams = ['auth_method', 'client_nonce', 'redirect_uri', 'meta']; + + @action + signIn(fakeAccount) { + const url = `${this.redirect_uri.split('?')[0]}?code=${ + fakeAccount.accessor + }&state=success`; + if (Ember.testing) { + this.router.transitionTo(url); + } else { + window.location = url; + } + } + + @action + failToSignIn() { + const url = `${this.redirect_uri.split('?')[0]}?state=failure`; + if (Ember.testing) { + this.router.transitionTo(url); + } else { + window.location = url; + } + } +} diff --git a/ui/app/controllers/settings/tokens.js b/ui/app/controllers/settings/tokens.js index 7fe6df52f..1a5548bb1 100644 --- a/ui/app/controllers/settings/tokens.js +++ b/ui/app/controllers/settings/tokens.js @@ -1,3 +1,4 @@ +// @ts-check import { inject as service } from '@ember/service'; import { reads } from '@ember/object/computed'; import Controller from '@ember/controller'; @@ -5,16 +6,25 @@ import { getOwner } from '@ember/application'; import { alias } from '@ember/object/computed'; import { action } from '@ember/object'; import classic from 'ember-classic-decorator'; +import { tracked } from '@glimmer/tracking'; +import Ember from 'ember'; @classic export default class Tokens extends Controller { @service token; @service store; + @service router; + + queryParams = ['code', 'state']; @reads('token.secret') secret; - tokenIsValid = false; - tokenIsInvalid = false; + /** + * @type {(null | "success" | "failure")} signInStatus + */ + @tracked + signInStatus = null; + @alias('token.selfToken') tokenRecord; resetStore() { @@ -25,22 +35,27 @@ export default class Tokens extends Controller { clearTokenProperties() { this.token.setProperties({ secret: undefined, + tokenNotFound: false, }); - this.setProperties({ - tokenIsValid: false, - tokenIsInvalid: false, - }); + this.signInStatus = null; // Clear out all data to ensure only data the anonymous token is privileged to see is shown this.resetStore(); this.token.reset(); + this.store.findAll('auth-method'); + } + + get authMethods() { + return this.store.peekAll('auth-method'); } @action verifyToken() { const { secret } = this; + this.clearTokenProperties(); const TokenAdapter = getOwner(this).lookup('adapter:token'); this.set('token.secret', secret); + this.set('secret', null); TokenAdapter.findSelf().then( () => { @@ -50,18 +65,95 @@ export default class Tokens extends Controller { // Refetch the token and associated policies this.get('token.fetchSelfTokenAndPolicies').perform().catch(); - this.setProperties({ - tokenIsValid: true, - tokenIsInvalid: false, - }); + this.signInStatus = 'success'; + this.token.set('tokenNotFound', false); }, () => { this.set('token.secret', undefined); - this.setProperties({ - tokenIsValid: false, - tokenIsInvalid: true, - }); + this.signInStatus = 'failure'; } ); } + + // Generate a 20-char nonce, using window.crypto to + // create a sufficiently-large output then trimming + generateNonce() { + let randomArray = new Uint32Array(10); + crypto.getRandomValues(randomArray); + return randomArray.join('').slice(0, 20); + } + + @action redirectToSSO(method) { + const provider = method.name; + const nonce = this.generateNonce(); + + window.localStorage.setItem('nomadOIDCNonce', nonce); + window.localStorage.setItem('nomadOIDCAuthMethod', provider); + + method + .getAuthURL({ + AuthMethod: provider, + ClientNonce: nonce, + RedirectUri: Ember.testing + ? this.router.currentURL + : window.location.toString(), + }) + .then(({ AuthURL }) => { + if (Ember.testing) { + this.router.transitionTo(AuthURL.split('/ui')[1]); + } else { + window.location = AuthURL; + } + }); + } + + @tracked code = null; + @tracked state = null; + + get isValidatingToken() { + if (this.code && this.state === 'success') { + this.validateSSO(); + return true; + } else { + return false; + } + } + + async validateSSO() { + const res = await this.token.authorizedRequest( + '/v1/acl/oidc/complete-auth', + { + method: 'POST', + body: JSON.stringify({ + AuthMethod: window.localStorage.getItem('nomadOIDCAuthMethod'), + ClientNonce: window.localStorage.getItem('nomadOIDCNonce'), + Code: this.code, + State: this.state, + }), + } + ); + + if (res.ok) { + const data = await res.json(); + this.token.set('secret', data.ACLToken); + this.verifyToken(); + this.state = null; + this.code = null; + } else { + this.state = 'failure'; + this.code = null; + } + } + + get SSOFailure() { + return this.state === 'failure'; + } + + get canSignIn() { + return !this.tokenRecord || this.tokenRecord.isExpired; + } + + get shouldShowPolicies() { + return this.tokenRecord; + } } diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js new file mode 100644 index 000000000..2cfb05b94 --- /dev/null +++ b/ui/app/models/auth-method.js @@ -0,0 +1,19 @@ +// @ts-check +import Model from '@ember-data/model'; +import { attr } from '@ember-data/model'; + +export default class AuthMethodModel extends Model { + @attr('string') name; + @attr('string') type; + @attr('string') tokenLocality; + @attr('string') maxTokenTTL; + @attr('boolean') default; + @attr('date') createTime; + @attr('number') createIndex; + @attr('date') modifyTime; + @attr('number') modifyIndex; + + getAuthURL(params) { + return this.store.adapterFor('authMethod').getAuthURL(params); + } +} diff --git a/ui/app/models/token.js b/ui/app/models/token.js index 17937cc1f..47faeeabb 100644 --- a/ui/app/models/token.js +++ b/ui/app/models/token.js @@ -11,6 +11,11 @@ export default class Token extends Model { @attr('string') type; @hasMany('policy') policies; @attr() policyNames; + @attr('date') expirationTime; @alias('id') accessor; + + get isExpired() { + return this.expirationTime && this.expirationTime < new Date(); + } } diff --git a/ui/app/router.js b/ui/app/router.js index 9ec659001..78236609d 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -98,4 +98,8 @@ Router.map(function () { path: '/path/*absolutePath', }); }); + // Mirage-only route for testing OIDC flow + if (config['ember-cli-mirage']) { + this.route('oidc-mock'); + } }); diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index be815bd44..c4d60aec1 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -13,6 +13,7 @@ export default class ApplicationRoute extends Route { @service system; @service store; @service token; + @service router; queryParams = { region: { @@ -140,7 +141,17 @@ export default class ApplicationRoute extends Route { @action error(error) { if (!(error instanceof AbortError)) { - this.controllerFor('application').set('error', error); + if ( + error.errors?.any( + (e) => + e.detail === 'ACL token expired' || + e.detail === 'ACL token not found' + ) + ) { + this.router.transitionTo('settings.tokens'); + } else { + this.controllerFor('application').set('error', error); + } } } } diff --git a/ui/app/routes/oidc-mock.js b/ui/app/routes/oidc-mock.js new file mode 100644 index 000000000..a147c8ba2 --- /dev/null +++ b/ui/app/routes/oidc-mock.js @@ -0,0 +1,9 @@ +import Route from '@ember/routing/route'; + +export default class OidcMockRoute extends Route { + // This route only exists for testing SSO/OIDC flow in development, backed by our mirage server. + // This route won't load outside of a mirage environment, nor will the model hook here return anything meaningful. + model() { + return this.store.findAll('token'); + } +} diff --git a/ui/app/routes/settings/tokens.js b/ui/app/routes/settings/tokens.js new file mode 100644 index 000000000..db7cc5490 --- /dev/null +++ b/ui/app/routes/settings/tokens.js @@ -0,0 +1,12 @@ +// @ts-check +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class SettingsTokensRoute extends Route { + @service store; + model() { + return { + authMethods: this.store.findAll('auth-method'), + }; + } +} diff --git a/ui/app/serializers/auth-method.js b/ui/app/serializers/auth-method.js new file mode 100644 index 000000000..6c959f024 --- /dev/null +++ b/ui/app/serializers/auth-method.js @@ -0,0 +1,7 @@ +import ApplicationSerializer from './application'; +import classic from 'ember-classic-decorator'; + +@classic +export default class AuthMethodSerializer extends ApplicationSerializer { + primaryKey = 'Name'; +} diff --git a/ui/app/services/keyboard.js b/ui/app/services/keyboard.js index 78a6cc5af..5e9950bd2 100644 --- a/ui/app/services/keyboard.js +++ b/ui/app/services/keyboard.js @@ -77,7 +77,7 @@ export default class KeyboardService extends Service { 'Go to Clients': ['g', 'c'], 'Go to Topology': ['g', 't'], 'Go to Evaluations': ['g', 'e'], - 'Go to ACL Tokens': ['g', 'a'], + 'Go to Profile': ['g', 'p'], 'Next Subnav': ['Shift+ArrowRight'], 'Previous Subnav': ['Shift+ArrowLeft'], 'Previous Main Section': ['Shift+ArrowUp'], @@ -126,7 +126,7 @@ export default class KeyboardService extends Service { rebindable: true, }, { - label: 'Go to ACL Tokens', + label: 'Go to Profile', action: () => this.router.transitionTo('settings.tokens'), rebindable: true, }, diff --git a/ui/app/services/token.js b/ui/app/services/token.js index de591393c..5e49e4502 100644 --- a/ui/app/services/token.js +++ b/ui/app/services/token.js @@ -3,18 +3,25 @@ import { computed } from '@ember/object'; import { alias, reads } from '@ember/object/computed'; import { getOwner } from '@ember/application'; import { assign } from '@ember/polyfills'; -import { task } from 'ember-concurrency'; +import { task, timeout } from 'ember-concurrency'; import queryString from 'query-string'; import fetch from 'nomad-ui/utils/fetch'; import classic from 'ember-classic-decorator'; +import moment from 'moment'; +const MINUTES_LEFT_AT_WARNING = 10; +const EXPIRY_NOTIFICATION_TITLE = 'Your access is about to expire'; @classic export default class TokenService extends Service { @service store; @service system; + @service router; + @service flashMessages; aclEnabled = true; + tokenNotFound = false; + @computed get secret() { return window.localStorage.nomadTokenSecret; @@ -39,6 +46,9 @@ export default class TokenService extends Service { if (errors.find((error) => error === 'ACL support disabled')) { this.set('aclEnabled', false); } + if (errors.find((error) => error === 'ACL token not found')) { + this.set('tokenNotFound', true); + } return null; } }) @@ -71,6 +81,7 @@ export default class TokenService extends Service { @task(function* () { yield this.fetchSelfToken.perform(); + this.kickoffTokenTTLMonitoring(); if (this.aclEnabled) { yield this.fetchSelfTokenPolicies.perform(); } @@ -109,7 +120,69 @@ export default class TokenService extends Service { this.fetchSelfToken.cancelAll({ resetState: true }); this.fetchSelfTokenPolicies.cancelAll({ resetState: true }); this.fetchSelfTokenAndPolicies.cancelAll({ resetState: true }); + this.monitorTokenTime.cancelAll({ resetState: true }); + window.localStorage.removeItem('nomadOIDCNonce'); + window.localStorage.removeItem('nomadOIDCAuthMethod'); } + + kickoffTokenTTLMonitoring() { + this.monitorTokenTime.perform(); + } + + @task(function* () { + while (this.selfToken?.expirationTime) { + const diff = new Date(this.selfToken.expirationTime) - new Date(); + // Let the user know at the 10 minute mark, + // or any time they refresh with under 10 minutes left + if (diff < 1000 * 60 * MINUTES_LEFT_AT_WARNING) { + const existingNotification = this.flashMessages.queue?.find( + (m) => m.title === EXPIRY_NOTIFICATION_TITLE + ); + // For the sake of updating the "time left" message, we keep running the task down to the moment of expiration + if (diff > 0) { + if (existingNotification) { + existingNotification.set( + 'message', + `Your token access expires ${moment( + this.selfToken.expirationTime + ).fromNow()}` + ); + } else { + if (!this.expirationNotificationDismissed) { + this.flashMessages.add({ + title: EXPIRY_NOTIFICATION_TITLE, + message: `Your token access expires ${moment( + this.selfToken.expirationTime + ).fromNow()}`, + type: 'error', + destroyOnClick: false, + sticky: true, + customCloseAction: () => { + this.set('expirationNotificationDismissed', true); + }, + customAction: { + label: 'Re-authenticate', + action: () => { + this.router.transitionTo('settings.tokens'); + }, + }, + }); + } + } + } else { + if (existingNotification) { + existingNotification.setProperties({ + title: 'Your access has expired', + message: `Your token will need to be re-authenticated`, + }); + } + this.monitorTokenTime.cancelAll(); // Stop updating time left after expiration + } + } + yield timeout(1000); + } + }) + monitorTokenTime; } function addParams(url, params) { diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 960c9c6d1..b4784bc1c 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -50,3 +50,4 @@ @import './components/keyboard-shortcuts-modal'; @import './components/services'; @import './components/task-sub-row'; +@import './components/authorization'; diff --git a/ui/app/styles/components/authorization.scss b/ui/app/styles/components/authorization.scss new file mode 100644 index 000000000..16a5b3922 --- /dev/null +++ b/ui/app/styles/components/authorization.scss @@ -0,0 +1,50 @@ +.authorization-page { + + .sign-in-methods { + h3, p { + margin-bottom: 1.5rem; + } + + .sso-auth-methods { + display: flex; + flex-flow: wrap; + gap: 0.5rem; + } + } + + .status-notifications { + &.is-half { + width: 50%; + } + margin-bottom: 1.5rem; + } + + .or-divider { + display: block; + width: 100%; + text-align: center; + margin: 2rem 0; + height: 2rem; + + &:before { + border-bottom: 1px solid $ui-gray-200; + position: relative; + top: 50%; + content: ""; + display: block; + width: 100%; + height: 0px; + } + + span { + color: $ui-gray-700; + background-color: white; + padding: 0 1rem; + text-transform: uppercase; + position: relative; + height: 100%; + align-content: center; + display: inline-grid; + } + } +} \ No newline at end of file diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index b34e7ca89..0ee608c5b 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -88,3 +88,35 @@ font-weight: $weight-medium; } } + + +.mock-sso-provider { + margin: 25vh auto; + width: 500px; + top: 25vh; + height: auto; + max-height: 50vh; + box-shadow: 0 0 0 100vw rgba(0, 2, 30, 0.8); + padding: 1rem; + text-align: center; + background-color: white; + h1 { + font-size: 2rem; + font-weight: 400; + } + h2 { + margin-bottom: 1rem; + font-size: 1rem; + } + .providers { + display: grid; + gap: 0.5rem; + button { + background-color: #444; + color: white; + &.error { + background-color: darkred; + } + } + } +} \ No newline at end of file diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss index dffa0ce01..52f8c0632 100644 --- a/ui/app/styles/core/navbar.scss +++ b/ui/app/styles/core/navbar.scss @@ -3,7 +3,11 @@ align-items: center; &.is-primary { - background: linear-gradient(to right, $nomad-green-darker, $nomad-green-dark); + background: linear-gradient( + to right, + $nomad-green-darker, + $nomad-green-dark + ); height: 3.5rem; color: $primary-invert; padding-left: 20px; @@ -147,4 +151,27 @@ } } } + + .profile-dropdown { + padding: 0.5rem 1rem 0.5rem 0.75rem; + background-color: transparent; + border: none !important; + height: auto; + box-shadow: none !important; + + &:focus { + background-color: #21a572; + } + + .ember-power-select-prefix { + color: rgba($primary-invert, 0.8); + } + .ember-power-select-selected-item { + margin-left: 0; + border: none; + } + .ember-power-select-status-icon { + border-top-color: white; + } + } } diff --git a/ui/app/styles/core/notifications.scss b/ui/app/styles/core/notifications.scss index 90ff30662..d73a8258b 100644 --- a/ui/app/styles/core/notifications.scss +++ b/ui/app/styles/core/notifications.scss @@ -1,3 +1,5 @@ +$bonusRightPadding: 20px; + section.notifications { position: fixed; bottom: 10px; @@ -11,7 +13,7 @@ section.notifications { box-shadow: 1px 1px 4px 0px rgb(0, 0, 0, 0.1); position: relative; overflow: hidden; - padding-right: 20px; + padding-right: $bonusRightPadding; &.alert-success { background-color: lighten($nomad-green, 50%); @@ -54,5 +56,10 @@ section.notifications { } } } + + .custom-action-button { + width: calc(100% + $bonusRightPadding - 1rem); + margin: 1.5rem 0 0; + } } } diff --git a/ui/app/templates/application.hbs b/ui/app/templates/application.hbs index 8a16c4f2d..edf8c1c3f 100644 --- a/ui/app/templates/application.hbs +++ b/ui/app/templates/application.hbs @@ -8,13 +8,21 @@
{{#each this.flashMessages.queue as |flash|}} - × + × {{#if flash.title}}

{{flash.title}}

{{/if}} {{#if flash.message}}

{{flash.message}}

{{/if}} + {{#if flash.customAction}} + + {{/if}} {{#if component.showProgressBar}}
diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs index 384d2c67e..9624c274e 100644 --- a/ui/app/templates/components/global-header.hbs +++ b/ui/app/templates/components/global-header.hbs @@ -60,9 +60,9 @@ > Documentation - - ACL Tokens - + {{#if this.shouldShowProfileNav}} + + {{/if}}