diff --git a/ui/app/adapters/mfa-setup.js b/ui/app/adapters/mfa-setup.js new file mode 100644 index 0000000000000..c22561ef28005 --- /dev/null +++ b/ui/app/adapters/mfa-setup.js @@ -0,0 +1,13 @@ +import ApplicationAdapter from './application'; + +export default class MfaSetupAdapter extends ApplicationAdapter { + adminGenerate(data) { + let url = `/v1/identity/mfa/method/totp/admin-generate`; + return this.ajax(url, 'POST', { data }); + } + + adminDestroy(data) { + let url = `/v1/identity/mfa/method/totp/admin-destroy`; + return this.ajax(url, 'POST', { data }); + } +} diff --git a/ui/app/components/auth-info.js b/ui/app/components/auth-info.js index 1d52f69adad07..e4a1705b444fd 100644 --- a/ui/app/components/auth-info.js +++ b/ui/app/components/auth-info.js @@ -20,8 +20,13 @@ export default class AuthInfoComponent extends Component { @service wizard; @service router; - @tracked - fakeRenew = false; + @tracked fakeRenew = false; + + get hasEntityId() { + // root users will not have an entity_id because they are not associated with an entity. + // in order to use the MFA end user setup they need an entity_id + return this.auth.authData.entity_id ? true : false; + } get isRenewing() { return this.fakeRenew || this.auth.isRenewing; diff --git a/ui/app/components/mfa-setup-step-one.js b/ui/app/components/mfa-setup-step-one.js new file mode 100644 index 0000000000000..52b533b8c95ab --- /dev/null +++ b/ui/app/components/mfa-setup-step-one.js @@ -0,0 +1,77 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module MfaSetupStepOne + * MfaSetupStepOne component is a child component used in the end user setup for MFA. It records the UUID (aka method_id) and sends a admin-generate request. + * + * @param {string} entityId - the entityId of the user. This comes from the auth service which records it on loading of the cluster. A root user does not have an entityId. + * @param {function} isUUIDVerified - a function that consumes a boolean. Is true if the admin-generate is successful and false if it throws a warning or error. + * @param {boolean} restartFlow - a boolean that is true that is true if the user should proceed to step two or false if they should stay on step one. + * @param {function} saveUUIDandQrCode - A function that sends the inputted UUID and return qrCode from step one to the parent. + * @param {boolean} showWarning - whether a warning is returned from the admin-generate query. Needs to be passed to step two. + */ + +export default class MfaSetupStepOne extends Component { + @service store; + @tracked error = ''; + @tracked warning = ''; + @tracked qrCode = ''; + + @action + redirectPreviousPage() { + this.args.restartFlow(); + window.history.back(); + } + + @action + async verifyUUID(evt) { + evt.preventDefault(); + let response = await this.postAdminGenerate(); + + if (response === 'stop_progress') { + this.args.isUUIDVerified(false); + } else if (response === 'reset_method') { + this.args.showWarning(this.warning); + } else { + this.args.isUUIDVerified(true); + } + } + + async postAdminGenerate() { + this.error = ''; + this.warning = ''; + let adapter = this.store.adapterFor('mfa-setup'); + let response; + + try { + response = await adapter.adminGenerate({ + entity_id: this.args.entityId, + method_id: this.UUID, // comes from value on the input + }); + this.args.saveUUIDandQrCode(this.UUID, response.data?.url); + // if there was a warning it won't fail but needs to be handled here and the flow needs to be interrupted + let warnings = response.warnings || []; + if (warnings.length > 0) { + this.UUID = ''; // clear UUID + const alreadyGenerated = warnings.find((w) => + w.includes('Entity already has a secret for MFA method') + ); + if (alreadyGenerated) { + this.warning = + 'A QR code has already been generated, scanned, and MFA set up for this entity. If a new code is required, contact your administrator.'; + return 'reset_method'; + } + this.warning = warnings; // in case other kinds of warnings comes through. + return 'reset_method'; + } + } catch (error) { + this.UUID = ''; // clear the UUID + this.error = error.errors; + return 'stop_progress'; + } + return response; + } +} diff --git a/ui/app/components/mfa-setup-step-two.js b/ui/app/components/mfa-setup-step-two.js new file mode 100644 index 0000000000000..d81765160ca47 --- /dev/null +++ b/ui/app/components/mfa-setup-step-two.js @@ -0,0 +1,40 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * @module MfaSetupStepTwo + * MfaSetupStepTwo component is a child component used in the end user setup for MFA. It displays a qrCode or a warning and allows a user to reset the method. + * + * @param {string} entityId - the entityId of the user. This comes from the auth service which records it on loading of the cluster. A root user does not have an entityId. + * @param {string} uuid - the UUID that is entered in the input on step one. + * @param {string} qrCode - the returned url from the admin-generate post. Used to create the qrCode. + * @param {boolean} restartFlow - a boolean that is true that is true if the user should proceed to step two or false if they should stay on step one. + * @param {string} warning - if there is a warning returned from the admin-generate post then it's sent to the step two component in this param. + */ + +export default class MfaSetupStepTwo extends Component { + @service store; + + @action + redirectPreviousPage() { + this.args.restartFlow(); + window.history.back(); + } + + @action + async restartSetup() { + this.error = null; + let adapter = this.store.adapterFor('mfa-setup'); + try { + await adapter.adminDestroy({ + entity_id: this.args.entityId, + method_id: this.args.uuid, + }); + } catch (error) { + this.error = error.errors; + return 'stop_progress'; + } + this.args.restartFlow(); + } +} diff --git a/ui/app/components/splash-page.js b/ui/app/components/splash-page.js index a8966ae67d63b..5942d3ab8187b 100644 --- a/ui/app/components/splash-page.js +++ b/ui/app/components/splash-page.js @@ -7,6 +7,7 @@ export default Component.extend({ auth: service(), store: service(), tagName: '', + showTruncatedNavBar: true, activeCluster: computed('auth.activeCluster', function () { return this.store.peekRecord('cluster', this.auth.activeCluster); diff --git a/ui/app/controllers/vault/cluster/mfa-setup.js b/ui/app/controllers/vault/cluster/mfa-setup.js new file mode 100644 index 0000000000000..54c250cc17184 --- /dev/null +++ b/ui/app/controllers/vault/cluster/mfa-setup.js @@ -0,0 +1,43 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +export default class VaultClusterMfaSetupController extends Controller { + @service auth; + @tracked onStep = 1; + @tracked warning = ''; + @tracked uuid = ''; + @tracked qrCode = ''; + + get entityId() { + return this.auth.authData.entity_id; + } + + @action isUUIDVerified(verified) { + this.warning = ''; // clear the warning, otherwise it persists. + if (verified) { + this.onStep = 2; + } else { + this.restartFlow(); + } + } + + @action + restartFlow() { + this.onStep = 1; + } + + @action + saveUUIDandQrCode(uuid, qrCode) { + // qrCode could be an empty string if the admin-generate was not successful + this.uuid = uuid; + this.qrCode = qrCode; + } + + @action + showWarning(warning) { + this.warning = warning; + this.onStep = 2; + } +} diff --git a/ui/app/router.js b/ui/app/router.js index c0a2ab543bbcb..3cd477670a0ff 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -17,6 +17,7 @@ Router.map(function () { this.route('logout'); this.mount('open-api-explorer', { path: '/api-explorer' }); this.route('license'); + this.route('mfa-setup'); this.route('clients', function () { this.route('current'); this.route('history'); diff --git a/ui/app/routes/vault/cluster/mfa-setup.js b/ui/app/routes/vault/cluster/mfa-setup.js new file mode 100644 index 0000000000000..e8f10fa096ac7 --- /dev/null +++ b/ui/app/routes/vault/cluster/mfa-setup.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class MfaSetupRoute extends Route {} diff --git a/ui/app/styles/components/list-item-row.scss b/ui/app/styles/components/list-item-row.scss index 2558358990b05..75cad84fb957b 100644 --- a/ui/app/styles/components/list-item-row.scss +++ b/ui/app/styles/components/list-item-row.scss @@ -16,6 +16,12 @@ font-weight: $font-weight-semibold; color: $ui-gray-500; } + + .center-display { + width: 50%; + margin-left: auto; + margin-right: auto; + } } a.list-item-row, diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 123a0f594efdb..031ee33e980f9 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -183,6 +183,10 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); font-weight: $font-weight-semibold; } + &.has-text-danger { + border: 1px solid $red-500; + } + &.tool-tip-trigger { color: $grey-dark; min-width: auto; diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index f74971d66720a..8e836582bde25 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -81,6 +81,10 @@ .is-flex-start { display: flex !important; justify-content: flex-start; + + &.has-gap { + gap: $spacing-m; + } } .is-flex-full { flex-basis: 100%; diff --git a/ui/app/templates/components/auth-info.hbs b/ui/app/templates/components/auth-info.hbs index ce7a77c9e8e66..ab9592cca0e5e 100644 --- a/ui/app/templates/components/auth-info.hbs +++ b/ui/app/templates/components/auth-info.hbs @@ -16,6 +16,13 @@ /> {{/if}} + {{#if this.hasEntityId}} +