mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 09:42:25 +00:00
UI: remodel shamir flow (#21871)
This commit is contained in:
3
changelog/21871.txt
Normal file
3
changelog/21871.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
ui: update unseal and DR operation token flow components
|
||||
```
|
||||
@@ -211,17 +211,19 @@ export default ApplicationAdapter.extend({
|
||||
},
|
||||
|
||||
generateDrOperationToken(data, options) {
|
||||
let verb = options && options.checkStatus ? 'GET' : 'PUT';
|
||||
if (options.cancel) {
|
||||
verb = 'DELETE';
|
||||
}
|
||||
let verb = 'POST';
|
||||
let url = `${this.buildURL()}/replication/dr/secondary/generate-operation-token/`;
|
||||
if (!data || data.pgp_key || data.attempt) {
|
||||
// start the generation
|
||||
url = url + 'attempt';
|
||||
if (options?.cancel) {
|
||||
verb = 'DELETE';
|
||||
url += 'attempt';
|
||||
} else if (options?.checkStatus) {
|
||||
verb = 'GET';
|
||||
url += 'attempt';
|
||||
} else if (data?.pgp_key || data?.attempt) {
|
||||
url += 'attempt';
|
||||
} else {
|
||||
// progress the operation
|
||||
url = url + 'update';
|
||||
url += 'update';
|
||||
}
|
||||
return this.ajax(url, verb, {
|
||||
data,
|
||||
|
||||
@@ -15,6 +15,10 @@ export default Controller.extend({
|
||||
});
|
||||
},
|
||||
|
||||
reloadCluster() {
|
||||
return this.model.reload();
|
||||
},
|
||||
|
||||
isUnsealed(data) {
|
||||
return data.sealed === false;
|
||||
},
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
.vlt-radio {
|
||||
position: relative;
|
||||
input[type='radio'] {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
input[type='radio'] + label {
|
||||
content: '';
|
||||
border: 1px solid $grey-light;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0.25rem 0;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
position: relative;
|
||||
left: 0;
|
||||
top: 0.3rem;
|
||||
}
|
||||
|
||||
input[type='radio']:checked + label {
|
||||
content: '';
|
||||
background: $blue;
|
||||
border: 1px solid $blue;
|
||||
box-shadow: inset 0 0 0 0.15rem $white;
|
||||
position: relative;
|
||||
left: 0;
|
||||
}
|
||||
input[type='radio']:focus + label {
|
||||
content: '';
|
||||
box-shadow: 0 0 10px 1px rgba($blue, 0.4), inset 0 0 0 0.15rem $white;
|
||||
position: relative;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
@@ -120,5 +120,4 @@
|
||||
@import './components/unseal-warning';
|
||||
// @import './components/ui-wizard'; // remove, see PR https://github.com/hashicorp/vault/pull/19220
|
||||
@import './components/vault-loading';
|
||||
@import './components/vlt-radio';
|
||||
@import './components/vlt-table';
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<br />
|
||||
How do you want to get started?
|
||||
</h2>
|
||||
<div class="vlt-radio is-block">
|
||||
<div class="has-bottom-margin-s">
|
||||
<RadioButton
|
||||
id="join"
|
||||
name="setup-pref"
|
||||
@@ -33,10 +33,9 @@
|
||||
@groupValue={{this.preference}}
|
||||
@onChange={{fn (mut this.preference)}}
|
||||
/>
|
||||
<label for="join"></label>
|
||||
Join an existing Raft cluster
|
||||
<label for="join">Join an existing Raft cluster</label>
|
||||
</div>
|
||||
<div class="vlt-radio is-block">
|
||||
<div>
|
||||
<RadioButton
|
||||
id="init"
|
||||
name="setup-pref"
|
||||
@@ -44,8 +43,7 @@
|
||||
@groupValue={{this.preference}}
|
||||
@onChange={{fn (mut this.preference)}}
|
||||
/>
|
||||
<label for="init" data-test-join-init></label>
|
||||
Create a new Raft cluster
|
||||
<label for="init" data-test-join-init>Create a new Raft cluster</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box is-marginless is-shadowless">
|
||||
|
||||
@@ -31,21 +31,27 @@
|
||||
is
|
||||
{{if this.model.unsealed "unsealed" "sealed"}}
|
||||
</p>
|
||||
<p>
|
||||
Unseal Vault by entering portions of the unseal key. This can be done via multiple mechanisms on multiple
|
||||
computers. Once all portions are entered, the root key will be decrypted and Vault will unseal.
|
||||
</p>
|
||||
{{#if this.model.unsealed}}
|
||||
<p>Please wait while we redirect you.</p>
|
||||
{{else}}
|
||||
<p>
|
||||
Unseal Vault by entering portions of the unseal key. This can be done via multiple mechanisms on multiple
|
||||
computers. Once all portions are entered, the root key will be decrypted and Vault will unseal.
|
||||
</p>
|
||||
<Shamir::Flow
|
||||
@action="unseal"
|
||||
@threshold={{this.model.sealThreshold}}
|
||||
@progress={{this.model.sealProgress}}
|
||||
@updateProgress={{action "reloadCluster"}}
|
||||
@inputLabel="Unseal Key Portion"
|
||||
@buttonText="Unseal"
|
||||
@onLicenseError={{action "handleLicenseError"}}
|
||||
@checkComplete={{action "isUnsealed"}}
|
||||
@onShamirSuccess={{action "transitionToCluster"}}
|
||||
class="has-top-margin-m"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
<ShamirFlow
|
||||
@action="unseal"
|
||||
@onLicenseError={{action "handleLicenseError"}}
|
||||
@onShamirSuccess={{action "transitionToCluster"}}
|
||||
@buttonText="Unseal"
|
||||
@thresholdPath="t"
|
||||
@isComplete={{action "isUnsealed"}}
|
||||
@threshold={{this.model.sealThreshold}}
|
||||
@progress={{this.model.sealProgress}}
|
||||
/>
|
||||
</Page.content>
|
||||
<Page.footer>
|
||||
<div class="box is-borderless is-shadowless">
|
||||
|
||||
64
ui/lib/core/addon/components/choose-pgp-key-form.hbs
Normal file
64
ui/lib/core/addon/components/choose-pgp-key-form.hbs
Normal file
@@ -0,0 +1,64 @@
|
||||
{{#if this.selectedPgp}}
|
||||
<form
|
||||
id="confirm-pgp-key"
|
||||
{{on "submit" this.handleSubmit}}
|
||||
aria-label="begin token generation with PGP key"
|
||||
data-test-confirm-pgp-key
|
||||
>
|
||||
<div>
|
||||
<p class="has-bottom-margin-m" data-test-pgp-key-confirm>
|
||||
{{or
|
||||
@confirmText
|
||||
(concat
|
||||
'Below is the base-64 encoded PGP Key that will be used. Click the "' this.buttonText '" button to proceed.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<h4 class="field-title has-bottom-padding-m is-fullwidth">
|
||||
{{concat "PGP Key " this.pgpKeyFile.filename}}
|
||||
</h4>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
<HoverCopyButton @copyValue={{this.pgpKey}} />
|
||||
<code class="is-word-break" data-test-pgp-key-copy>{{this.pgpKey}}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button type="button" class="button" {{on "click" (fn (mut this.selectedPgp) "")}} data-test-confirm-pgp-back-button>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" disabled={{not this.pgpKey}} class="button is-primary" data-test-confirm-pgp-key-submit>
|
||||
{{this.buttonText}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<form
|
||||
id="choose-pgp-key"
|
||||
{{on "submit" this.usePgpKey}}
|
||||
aria-label="provide PGP key"
|
||||
data-test-choose-pgp-key-form="begin"
|
||||
>
|
||||
<div class="has-bottom-margin-m">
|
||||
<p data-test-choose-pgp-key-description>
|
||||
{{this.formText}}
|
||||
</p>
|
||||
<PgpFile @index="" @key={{this.pgpKeyFile}} @onChange={{this.setKey}} />
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button type="button" class="button" {{on "click" @onCancel}} data-test-use-pgp-key-cancel>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" disabled={{not this.pgpKeyFile.value}} class="button is-primary" data-test-use-pgp-key-button>
|
||||
Use PGP Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{/if}}
|
||||
53
ui/lib/core/addon/components/choose-pgp-key-form.js
Normal file
53
ui/lib/core/addon/components/choose-pgp-key-form.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { action } from '@ember/object';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
const pgpKeyFileDefault = () => ({ value: '' });
|
||||
|
||||
/**
|
||||
* @module ChoosePgpKeyForm
|
||||
* ChoosePgpKeyForm component is used for DR Operation Token Generation workflow. It provides
|
||||
* an interface for the user to upload or paste a PGP key for use
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <ChoosePgpKeyForm @onCancel={{this.reset}} @onSubmit={{handleGenerateWithPgpKey}}>
|
||||
* ```
|
||||
* @param {function} onCancel - required - This function will be triggered when the modal intends to be closed
|
||||
* @param {function} onSubmit - required - When the PGP key is confirmed, it will call this method with the pgpKey value as the only param
|
||||
* @param {string} buttonText - Button text for onSubmit. Defaults to "Continue with key"
|
||||
* @param {string} formText - Form text above where the users uploads or pastes the key. Has default
|
||||
*/
|
||||
export default class ChoosePgpKeyForm extends Component {
|
||||
@tracked pgpKeyFile = pgpKeyFileDefault();
|
||||
@tracked selectedPgp = '';
|
||||
|
||||
get pgpKey() {
|
||||
return this.pgpKeyFile.value;
|
||||
}
|
||||
|
||||
get buttonText() {
|
||||
return this.args.buttonText || 'Continue with key';
|
||||
}
|
||||
|
||||
get formText() {
|
||||
return (
|
||||
this.args.formText ||
|
||||
'Choose a PGP Key from your computer or paste the contents of one in the form below.'
|
||||
);
|
||||
}
|
||||
|
||||
@action setKey(_, keyFile) {
|
||||
this.pgpKeyFile = keyFile;
|
||||
}
|
||||
|
||||
// Form submit actions:
|
||||
@action usePgpKey(evt) {
|
||||
evt.preventDefault();
|
||||
this.selectedPgp = this.pgpKey;
|
||||
}
|
||||
@action handleSubmit(evt) {
|
||||
evt.preventDefault();
|
||||
this.args.onSubmit(this.pgpKey);
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import { inject as service } from '@ember/service';
|
||||
import { gt } from '@ember/object/computed';
|
||||
import { camelize } from '@ember/string';
|
||||
import Component from '@ember/component';
|
||||
import { get, computed } from '@ember/object';
|
||||
import layout from '../templates/components/shamir-flow';
|
||||
import { A } from '@ember/array';
|
||||
|
||||
const pgpKeyFileDefault = () => ({ value: '' });
|
||||
const DEFAULTS = {
|
||||
key: null,
|
||||
loading: false,
|
||||
errors: A(),
|
||||
threshold: null,
|
||||
progress: null,
|
||||
pgp_key: null,
|
||||
haveSavedPGPKey: false,
|
||||
started: false,
|
||||
generateWithPGP: false,
|
||||
pgpKeyFile: pgpKeyFileDefault(),
|
||||
nonce: '',
|
||||
};
|
||||
|
||||
export default Component.extend(DEFAULTS, {
|
||||
tagName: '',
|
||||
store: service(),
|
||||
formText: null,
|
||||
fetchOnInit: false,
|
||||
buttonText: 'Submit',
|
||||
thresholdPath: 'required',
|
||||
generateAction: false,
|
||||
layout,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
if (this.fetchOnInit) {
|
||||
this.attemptProgress();
|
||||
}
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
this.onUpdate(this.getProperties(Object.keys(DEFAULTS)));
|
||||
},
|
||||
|
||||
onUpdate() {},
|
||||
onLicenseError() {},
|
||||
onShamirSuccess() {},
|
||||
// can be overridden w/an attr
|
||||
isComplete(data) {
|
||||
return data.complete === true;
|
||||
},
|
||||
|
||||
stopLoading() {
|
||||
this.setProperties({
|
||||
loading: false,
|
||||
errors: [],
|
||||
key: null,
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.setProperties(DEFAULTS);
|
||||
},
|
||||
|
||||
hasProgress: gt('progress', 0),
|
||||
|
||||
actionSuccess(resp) {
|
||||
const { onUpdate, isComplete, onShamirSuccess, thresholdPath } = this;
|
||||
const threshold = get(resp, thresholdPath);
|
||||
const props = {
|
||||
...resp,
|
||||
threshold,
|
||||
};
|
||||
this.stopLoading();
|
||||
// if we have an OTP, but update doesn't include one,
|
||||
// we don't want to null it out
|
||||
if (this.otp && !props.otp) {
|
||||
delete props.otp;
|
||||
}
|
||||
this.setProperties(props);
|
||||
onUpdate(props);
|
||||
if (isComplete(props)) {
|
||||
this.reset();
|
||||
onShamirSuccess(props);
|
||||
}
|
||||
},
|
||||
|
||||
actionError(e) {
|
||||
this.stopLoading();
|
||||
if (e.httpStatus === 400) {
|
||||
this.set('errors', e.errors);
|
||||
} else {
|
||||
// if licensing error, trigger parent method to handle
|
||||
if (e.httpStatus === 500 && e.errors?.join(' ').includes('licensing is in an invalid state')) {
|
||||
this.onLicenseError();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
generateStep: computed('generateWithPGP', 'haveSavedPGPKey', 'pgp_key', function () {
|
||||
const { generateWithPGP, pgp_key, haveSavedPGPKey } = this;
|
||||
if (!generateWithPGP && !pgp_key) {
|
||||
return 'chooseMethod';
|
||||
}
|
||||
if (generateWithPGP) {
|
||||
if (pgp_key && haveSavedPGPKey) {
|
||||
return 'beginGenerationWithPGP';
|
||||
} else {
|
||||
return 'providePGPKey';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}),
|
||||
|
||||
extractData(data) {
|
||||
const isGenerate = this.generateAction;
|
||||
const hasStarted = this.started;
|
||||
const usePGP = this.generateWithPGP;
|
||||
const nonce = this.nonce;
|
||||
|
||||
if (!isGenerate || hasStarted) {
|
||||
if (nonce) {
|
||||
data.nonce = nonce;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
if (usePGP) {
|
||||
return {
|
||||
pgp_key: data.pgp_key,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
attempt: data.attempt,
|
||||
};
|
||||
},
|
||||
|
||||
attemptProgress(data) {
|
||||
const checkStatus = data ? false : true;
|
||||
let action = this.action;
|
||||
action = action && camelize(action);
|
||||
this.set('loading', true);
|
||||
const adapter = this.store.adapterFor('cluster');
|
||||
const method = adapter[action];
|
||||
|
||||
method.call(adapter, data, { checkStatus }).then(
|
||||
(resp) => this.actionSuccess(resp),
|
||||
(...args) => this.actionError(...args)
|
||||
);
|
||||
},
|
||||
|
||||
actions: {
|
||||
reset() {
|
||||
this.reset();
|
||||
this.set('encoded_token', null);
|
||||
this.set('otp', null);
|
||||
},
|
||||
|
||||
onSubmit(data) {
|
||||
if (!data.key) {
|
||||
return;
|
||||
}
|
||||
this.attemptProgress(this.extractData(data));
|
||||
},
|
||||
|
||||
startGenerate(data) {
|
||||
if (this.generateAction) {
|
||||
data.attempt = true;
|
||||
}
|
||||
this.attemptProgress(this.extractData(data));
|
||||
},
|
||||
|
||||
setKey(_, keyFile) {
|
||||
this.set('pgp_key', keyFile.value);
|
||||
this.set('pgpKeyFile', keyFile);
|
||||
},
|
||||
|
||||
savePGPKey() {
|
||||
if (this.pgp_key) {
|
||||
this.set('haveSavedPGPKey', true);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @module ShamirModalFlow
|
||||
* ShamirModalFlow is an extension of the ShamirFlow component that does the Generate Action Token workflow inside of a Modal.
|
||||
* Please note, this is not an extensive list of the required parameters -- please see ShamirFlow for others
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <ShamirModalFlow @onClose={action 'onClose'}>This copy is the main paragraph when the token flow has not started</ShamirModalFlow>
|
||||
* ```
|
||||
* @param {function} onClose - This function will be triggered when the modal intends to be closed
|
||||
*/
|
||||
import { inject as service } from '@ember/service';
|
||||
import ShamirFlow from './shamir-flow';
|
||||
import layout from '../templates/components/shamir-modal-flow';
|
||||
|
||||
export default ShamirFlow.extend({
|
||||
layout,
|
||||
store: service(),
|
||||
onClose: () => {},
|
||||
actions: {
|
||||
onCancelClose() {
|
||||
if (this.encoded_token) {
|
||||
this.send('reset');
|
||||
} else if (this.generateAction && !this.started) {
|
||||
if (this.generateStep !== 'chooseMethod') {
|
||||
this.send('reset');
|
||||
}
|
||||
} else {
|
||||
const adapter = this.store.adapterFor('cluster');
|
||||
adapter.generateDrOperationToken(this.model, { cancel: true });
|
||||
this.send('reset');
|
||||
}
|
||||
this.onClose();
|
||||
},
|
||||
onClose() {
|
||||
this.onClose();
|
||||
},
|
||||
},
|
||||
});
|
||||
135
ui/lib/core/addon/components/shamir/dr-token-flow.hbs
Normal file
135
ui/lib/core/addon/components/shamir/dr-token-flow.hbs
Normal file
@@ -0,0 +1,135 @@
|
||||
<section class="modal-card-body">
|
||||
{{#if this.encodedToken}}
|
||||
<p class="has-bottom-margin-l" data-test-dr-token-flow-step="show-token">
|
||||
Below is the process and the values necessary to generate your operation token. Read the instructions carefully!
|
||||
</p>
|
||||
<div class="has-bottom-margin-m">
|
||||
<div class="has-bottom-margin-xl">
|
||||
<h4 class="field-title">
|
||||
Encoded operation token
|
||||
</h4>
|
||||
<p class="help has-text-grey has-bottom-margin-xs">
|
||||
This is a one-time token that will be used to generate the operation token. Please save it.
|
||||
</p>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
<HoverCopyButton @copyValue={{this.encodedToken}} />
|
||||
<code class="is-word-break" data-test-shamir-encoded-token>{{this.encodedToken}}</code>
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.otp}}
|
||||
<div class="has-bottom-margin-xl">
|
||||
<h4 class="field-title">
|
||||
One time password (OTP)
|
||||
</h4>
|
||||
<p class="help has-text-grey has-bottom-margin-xs">
|
||||
This OTP will be used to decode the generated operation token. Please save it.
|
||||
</p>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
<HoverCopyButton @copyValue={{this.otp}} />
|
||||
<code class="is-word-break">{{this.otp}}</code>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="has-bottom-margin-xl">
|
||||
<h4 class="field-title">
|
||||
DR operation token command
|
||||
</h4>
|
||||
<p class="help has-text-grey has-bottom-margin-xs">
|
||||
{{#if this.otp}}
|
||||
This command contains both the encoded token and the OTP. It should be executed on the secondary cluster in order
|
||||
to generate the operation token.
|
||||
{{else}}
|
||||
This command requires the OTP saved earlier. It should be executed on the secondary cluster in order to generate
|
||||
the operation token.
|
||||
{{/if}}
|
||||
</p>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
{{! template-lint-disable quotes }}
|
||||
{{#let
|
||||
(if
|
||||
this.otp
|
||||
(concat 'vault operator generate-root -dr-token -otp="' this.otp '" -decode="' this.encodedToken '"')
|
||||
(concat 'vault operator generate-root -dr-token -otp="<enter your otp here>" -decode="' this.encodedToken '"')
|
||||
)
|
||||
as |cmd|
|
||||
}}
|
||||
<HoverCopyButton @copyValue={{cmd}} />
|
||||
<code class="is-word-break">{{cmd}}</code>
|
||||
{{/let}}
|
||||
{{! template-lint-enable quotes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="button is-primary" {{on "click" this.onCancelClose}}>
|
||||
Clear & Close
|
||||
</button>
|
||||
</div>
|
||||
{{else if this.started}}
|
||||
<Shamir::Form
|
||||
@action={{@action}}
|
||||
@progress={{this.progress}}
|
||||
@threshold={{this.threshold}}
|
||||
@errors={{this.errors}}
|
||||
@inputLabel="Root key portion"
|
||||
@buttonText="Generate token"
|
||||
@onSubmit={{this.onSubmitKey}}
|
||||
@otp={{this.otp}}
|
||||
@alwaysShowProgress={{true}}
|
||||
data-test-dr-token-flow-step="shamir"
|
||||
>
|
||||
<p>Generate an operation token by entering a portion of the
|
||||
<strong>primary's root key</strong>. Once all portions are entered, the generated token may be used to manage your
|
||||
secondary Disaster Recovery cluster.
|
||||
</p>
|
||||
</Shamir::Form>
|
||||
{{else if this.generateWithPGP}}
|
||||
<ChoosePgpKeyForm
|
||||
@onCancel={{fn (mut this.generateWithPGP) false}}
|
||||
@onSubmit={{this.usePgpKey}}
|
||||
@formText={{this.pgpText.form}}
|
||||
@confirmText={{this.pgpText.confirm}}
|
||||
@buttonText="Generate operation token"
|
||||
data-test-dr-token-flow-step="choose-pgp"
|
||||
/>
|
||||
{{else}}
|
||||
{{! Generate token flow not started }}
|
||||
<form
|
||||
{{on "submit" this.startGenerate}}
|
||||
id="shamir"
|
||||
aria-label="shamir generate form"
|
||||
data-test-dr-token-flow-step="begin"
|
||||
>
|
||||
<MessageError @errors={{this.errors}} />
|
||||
<div class="has-bottom-margin-m" data-test-shamir-modal-body>
|
||||
<p>
|
||||
Updating or promoting this cluster requires an operation token, generated by inputting the root key shares. If
|
||||
you'd like to first encrypt the token with a PGP Key, click "Encrypt with PGP key" below, otherwise we can begin
|
||||
generation of the operation token.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field is-grouped is-flex-center">
|
||||
<div class="control is-flex-row">
|
||||
<button type="button" class="link" {{on "click" (fn (mut this.generateWithPGP) true)}} data-test-use-pgp-key-cta>
|
||||
Provide PGP Key
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<span class="has-side-padding-s">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary" data-test-generate-token-cta>
|
||||
Generate operation token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{/if}}
|
||||
</section>
|
||||
<footer class="modal-card-foot modal-card-foot-outlined">
|
||||
<button type="button" class="button is-secondary" {{on "click" this.onCancelClose}} data-test-shamir-modal-cancel-button>
|
||||
{{if this.encodedToken "Close" "Cancel"}}
|
||||
</button>
|
||||
</footer>
|
||||
126
ui/lib/core/addon/components/shamir/dr-token-flow.js
Normal file
126
ui/lib/core/addon/components/shamir/dr-token-flow.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import ShamirFlowComponent from './flow';
|
||||
|
||||
/**
|
||||
* @module ShamirDrTokenFlowComponent
|
||||
* ShamirDrTokenFlow is an extension of the ShamirFlow component that does the Generate Action Token workflow inside of a Modal.
|
||||
* Please note, this is not an extensive list of the required parameters -- please see ShamirFlow for others
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{this.closeModal}} />
|
||||
* ```
|
||||
* @param {string} action - required kebab-case-string which refers to an action within the cluster adapter
|
||||
* @param {function} onCancel - if provided, function will be triggered on Cancel
|
||||
*/
|
||||
export default class ShamirDrTokenFlowComponent extends ShamirFlowComponent {
|
||||
@tracked generateWithPGP = false; // controls which form shows
|
||||
@tracked savedPgpKey = null;
|
||||
@tracked otp = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
// Fetch status on init
|
||||
this.attemptProgress();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.generateWithPGP = false;
|
||||
this.savedPgpKey = null;
|
||||
this.otp = '';
|
||||
// tracked items on Shamir/Flow
|
||||
this.attemptResponse = null;
|
||||
this.errors = null;
|
||||
}
|
||||
|
||||
// Values calculated from the attempt response
|
||||
get encodedToken() {
|
||||
return this.attemptResponse?.encoded_token;
|
||||
}
|
||||
get started() {
|
||||
return this.attemptResponse?.started;
|
||||
}
|
||||
get nonce() {
|
||||
return this.attemptResponse?.nonce;
|
||||
}
|
||||
get progress() {
|
||||
return this.attemptResponse?.progress;
|
||||
}
|
||||
get threshold() {
|
||||
return this.attemptResponse?.required;
|
||||
}
|
||||
get pgpText() {
|
||||
return {
|
||||
confirm: `Below is the base-64 encoded PGP Key that will be used to encrypt the generated operation token. Next we'll enter portions of the root key to generate an operation token. Click the "Generate operation token" button to proceed.`,
|
||||
form: `Choose a PGP Key from your computer or paste the contents of one in the form below. This key will be used to Encrypt the generated operation token.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Methods which override those in Shamir/Flow
|
||||
extractData(data) {
|
||||
if (this.started) {
|
||||
if (this.nonce) {
|
||||
data.nonce = this.nonce;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
if (this.savedPgpKey) {
|
||||
return {
|
||||
pgp_key: this.savedPgpKey,
|
||||
};
|
||||
}
|
||||
// only if !started
|
||||
return {
|
||||
attempt: data.attempt,
|
||||
};
|
||||
}
|
||||
|
||||
updateProgress(response) {
|
||||
if (response.otp) {
|
||||
// OTP is sticky -- once we get one we don't want to remove it
|
||||
// even if the current response doesn't include one.
|
||||
// See PR #5818
|
||||
this.otp = response.otp;
|
||||
}
|
||||
this.attemptResponse = response;
|
||||
return;
|
||||
}
|
||||
|
||||
@action
|
||||
usePgpKey(keyfile) {
|
||||
this.savedPgpKey = keyfile;
|
||||
this.attemptProgress(this.extractData({ attempt: true }));
|
||||
}
|
||||
|
||||
@action
|
||||
startGenerate(evt) {
|
||||
evt.preventDefault();
|
||||
this.attemptProgress(this.extractData({ attempt: true }));
|
||||
}
|
||||
|
||||
@action
|
||||
async onCancelClose() {
|
||||
if (!this.encodedToken && this.started) {
|
||||
const adapter = this.store.adapterFor('cluster');
|
||||
await adapter.generateDrOperationToken({}, { cancel: true });
|
||||
}
|
||||
this.reset();
|
||||
if (this.args.onCancel) {
|
||||
this.args.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* generate-operation-token response example
|
||||
{
|
||||
"started": true,
|
||||
"nonce": "2dbd10f1-8528-6246-09e7-82b25b8aba63",
|
||||
"progress": 1,
|
||||
"required": 3,
|
||||
"encoded_token": "",
|
||||
"otp": "2vPFYG8gUSW9npwzyvxXMug0",
|
||||
"otp_length": 24,
|
||||
"complete": false
|
||||
}
|
||||
*/
|
||||
9
ui/lib/core/addon/components/shamir/flow.hbs
Normal file
9
ui/lib/core/addon/components/shamir/flow.hbs
Normal file
@@ -0,0 +1,9 @@
|
||||
<Shamir::Form
|
||||
@onSubmit={{this.onSubmitKey}}
|
||||
@progress={{@progress}}
|
||||
@threshold={{@threshold}}
|
||||
@buttonText={{@buttonText}}
|
||||
@inputLabel={{@inputLabel}}
|
||||
@errors={{this.errors}}
|
||||
...attributes
|
||||
/>
|
||||
168
ui/lib/core/addon/components/shamir/flow.js
Normal file
168
ui/lib/core/addon/components/shamir/flow.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { A } from '@ember/array';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { camelize } from '@ember/string';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
/**
|
||||
* @module ShamirFlowComponent
|
||||
* These components are used to manage keeping track of a shamir unseal flow.
|
||||
* This component is generic and can be overwritten for various shamir use cases.
|
||||
* The lifecycle for a Shamir flow is as follows:
|
||||
* 1. Start (optional)
|
||||
* 2. Attempt progress
|
||||
* 3. Check progress
|
||||
* 4. Check complete
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <Shamir::Flow
|
||||
* @action="unseal"
|
||||
* @threshold={{5}}
|
||||
* @progress={{3}}
|
||||
* @onShamirSuccess={{transition-to "vault.cluster"}}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @param {string} action - adapter method name (kebab case) to call on attempt
|
||||
* @param {number} threshold - number of keys required to unlock
|
||||
* @param {number} progress - number of keys given so far for unlock
|
||||
* @param {string} inputLabel - (optional) Label for key input
|
||||
* @param {string} buttonText - (optional) CTA for the form submit button. Defaults to "Submit"
|
||||
* @param {Function} extractData - (optional) modify the payload before the action is called
|
||||
* @param {Function} updateProgress - (optional) call a side effect to check if progress has been made
|
||||
* @param {Function} checkComplete - (optional) custom logic based on adapter response. Should return boolean.
|
||||
* @param {Function} onShamirSuccess - method called when shamir unlock is complete.
|
||||
* @param {Function} onLicenseError - method called when shamir unlock fails due to licensing error
|
||||
*
|
||||
*/
|
||||
export default class ShamirFlowComponent extends Component {
|
||||
@service store;
|
||||
@tracked errors = A();
|
||||
@tracked attemptResponse = null;
|
||||
|
||||
get action() {
|
||||
if (!this.args.action) return '';
|
||||
return camelize(this.args.action);
|
||||
}
|
||||
|
||||
extractData(data) {
|
||||
if (this.args.extractData) {
|
||||
// custom data extraction
|
||||
return this.args.extractData(data);
|
||||
}
|
||||
|
||||
// This method can be overwritten by extended components
|
||||
// to control what data is passed into the method action
|
||||
if (this.attemptResponse?.nonce) {
|
||||
data.nonce = this.attemptResponse.nonce;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Attempt progress. This method assumes the correct data
|
||||
* has already been extracted (use this.extractData to customize)
|
||||
* @param {object} data arbitrary data which will be passed to adapter method
|
||||
* @returns Promise which should resolve unless throwing error to parent.
|
||||
*/
|
||||
async attemptProgress(data) {
|
||||
this.errors = null;
|
||||
const action = this.action;
|
||||
const adapter = this.store.adapterFor('cluster');
|
||||
const method = adapter[action];
|
||||
// Only used for DR token generate
|
||||
const checkStatus = data ? false : true;
|
||||
|
||||
try {
|
||||
const resp = await method.call(adapter, data, { checkStatus });
|
||||
this.updateProgress(resp);
|
||||
this.handleComplete(resp);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e.httpStatus === 400) {
|
||||
this.errors = e.errors;
|
||||
return;
|
||||
} else {
|
||||
// if licensing error, trigger parent method to handle
|
||||
if (e.httpStatus === 500 && e.errors?.join(' ').includes('licensing is in an invalid state')) {
|
||||
this.args.onLicenseError();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. This method gets called after successful unseal attempt.
|
||||
* By default the response will be made available to the component,
|
||||
* but pass in @updateProgress (no params) to trigger any side effects that will
|
||||
* update passed attributes from parent.
|
||||
* @param {payload} response from the adapter method
|
||||
* @returns void
|
||||
*/
|
||||
updateProgress(response) {
|
||||
if (this.args.updateProgress) {
|
||||
this.args.updateProgress();
|
||||
}
|
||||
this.attemptResponse = response;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. checkComplete checks the payload for completeness, then then
|
||||
* takes calls @onShamirSuccess with no arguments if complete.
|
||||
* For custom logic, define @checkComplete which receives the
|
||||
* adapter payload.
|
||||
* @param {payload} response from the adapter method
|
||||
* @returns void
|
||||
*/
|
||||
handleComplete(response) {
|
||||
const isComplete = this.checkComplete(response);
|
||||
if (isComplete) {
|
||||
if (this.args.onShamirSuccess) {
|
||||
this.args.onShamirSuccess();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
checkComplete(response) {
|
||||
if (this.args.checkComplete) {
|
||||
return this.args.checkComplete(response);
|
||||
}
|
||||
return response.complete === true;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.attemptResponse = null;
|
||||
this.errors = null;
|
||||
}
|
||||
|
||||
@action
|
||||
onSubmitKey(data) {
|
||||
this.attemptProgress(this.extractData(data));
|
||||
}
|
||||
}
|
||||
|
||||
/* example unseal response (progress)
|
||||
{
|
||||
"sealed": true,
|
||||
"t": 3,
|
||||
"n": 5,
|
||||
"progress": 2,
|
||||
"version": "0.6.2"
|
||||
}
|
||||
|
||||
example unseal response (finished)
|
||||
{
|
||||
"sealed": false,
|
||||
"t": 3,
|
||||
"n": 5,
|
||||
"progress": 0,
|
||||
"version": "0.6.2",
|
||||
"cluster_name": "vault-cluster-d6ec3c7f",
|
||||
"cluster_id": "3e8b3fec-3749-e056-ba41-b62a63b997e8"
|
||||
}
|
||||
*/
|
||||
48
ui/lib/core/addon/components/shamir/form.hbs
Normal file
48
ui/lib/core/addon/components/shamir/form.hbs
Normal file
@@ -0,0 +1,48 @@
|
||||
<form id="shamir" aria-label="shamir form" {{on "submit" (fn this.onSubmit this.key)}} ...attributes>
|
||||
{{#if @errors}}
|
||||
<div class="has-bottom-margin-m">
|
||||
<MessageError @errors={{@errors}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="has-bottom-margin-m">
|
||||
{{#if @otp}}
|
||||
<Hds::Alert @type="inline" @color="highlight" class="has-bottom-margin-s" data-test-otp-info as |A|>
|
||||
<A.Title>Info</A.Title>
|
||||
<A.Description>
|
||||
Below is the generated OTP. This will be used to encode the generated Operation Token. Make sure to save this, as
|
||||
you will need it later to decode the Operation Token.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
<div class="has-background-gray-100 box has-copy-button" tabindex="-1">
|
||||
<HoverCopyButton @copyValue={{@otp}} />
|
||||
<h4 class="title is-7 is-marginless">
|
||||
One Time Password (otp)
|
||||
</h4>
|
||||
<code class="is-word-break" data-test-otp>{{@otp}}</code>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (has-block)}}
|
||||
{{yield}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="key" class="is-label" data-test-shamir-key-label>
|
||||
{{this.inputLabel}}
|
||||
</label>
|
||||
<div class="control">
|
||||
<Input class="input" @type="password" name="key" @value={{this.key}} autocomplete="off" data-test-shamir-key-input />
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-narrow">
|
||||
<button type="submit" class="button is-primary" disabled={{this.loading}} data-test-shamir-submit>
|
||||
{{this.buttonText}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-flex-v-centered is-flex-end">
|
||||
{{#if this.showProgress}}
|
||||
<ShamirProgress @threshold={{@threshold}} @progress={{@progress}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
61
ui/lib/core/addon/components/shamir/form.js
Normal file
61
ui/lib/core/addon/components/shamir/form.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { action } from '@ember/object';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
/**
|
||||
* @module ShamirFormComponent
|
||||
* These components are used to make progress against a Shamir seal.
|
||||
* Depending on the response, and external polling, the component will show
|
||||
* progress and optional
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <Shamir::Form
|
||||
* @onSubmit={{this.handleKeySubmit}}
|
||||
* @threshold={{cluster.threshold}}
|
||||
* @progress={{cluster.progress}}
|
||||
* @fetchOnInit={{true}}
|
||||
* @onShamirSuccess={{transition-to "vault.cluster"}}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @param {Function} onSubmit - method which handles the action for shamir. Receives { key }
|
||||
* @param {number} threshold - min number of keys required to unlock shamir seal
|
||||
* @param {number} progress - number of keys given so far for unlock
|
||||
* @param {string} buttonText - CTA for the form submit button. Defaults to "Submit"
|
||||
* @param {string} inputLabel - Label for key input. Defaults to "Shamir key portion"
|
||||
* @param {boolean} alwaysShowProgress - determines if the shamir progress should always show, or only when > 0 progress
|
||||
* @param {string} otp - if otp is present, it will show a section describing what to do with it
|
||||
*
|
||||
*/
|
||||
export default class ShamirFormComponent extends Component {
|
||||
@tracked key = '';
|
||||
@tracked loading = false;
|
||||
|
||||
get buttonText() {
|
||||
return this.args.buttonText || 'Submit';
|
||||
}
|
||||
get showProgress() {
|
||||
return this.args.progress > 0 || this.args.alwaysShowProgress;
|
||||
}
|
||||
get inputLabel() {
|
||||
return this.args.inputLabel || 'Shamir key portion';
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.key = '';
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
@action
|
||||
async onSubmit(key, evt) {
|
||||
evt.preventDefault();
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
// Parent handles action and passes in errors if present
|
||||
await this.args.onSubmit({ key });
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShamirModalFlow
|
||||
@action="generate-dr-operation-token"
|
||||
@buttonText="Generate token"
|
||||
@fetchOnInit={{true}}
|
||||
@generateAction={{true}}
|
||||
<Modal
|
||||
@title="Generate operation token"
|
||||
@onClose={{action (mut this.isModalActive) false}}
|
||||
@isActive={{this.isModalActive}}
|
||||
@type="warning"
|
||||
@showCloseButton={{true}}
|
||||
>
|
||||
<p>
|
||||
Updating or promoting this cluster requires an operation token, generated by inputting the root key shares. If you'd like
|
||||
to first encrypt the token with a PGP Key, click "Encrypt with PGP key" below, otherwise we can begin generation of the
|
||||
operation token.
|
||||
</p>
|
||||
</ShamirModalFlow>
|
||||
{{#if this.isModalActive}}
|
||||
{{! Wrapped in if statement so the Shamir constructor fires on modal open }}
|
||||
<Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{action (mut this.isModalActive) false}} />
|
||||
{{! Section & Footer is in child component since the form must do side effects on cancel }}
|
||||
{{/if}}
|
||||
</Modal>
|
||||
@@ -1,219 +0,0 @@
|
||||
<Modal
|
||||
@title="Generate operation token"
|
||||
@onClose={{action "onClose"}}
|
||||
@isActive={{this.isActive}}
|
||||
@type="warning"
|
||||
@showCloseButton={{true}}
|
||||
>
|
||||
<section class="modal-card-body">
|
||||
{{#if this.encoded_token}}
|
||||
<p class="has-bottom-margin-l">
|
||||
Below is the process and the values necessary to generate your operation token. Read the instructions carefully!
|
||||
</p>
|
||||
<div class="has-bottom-margin-m">
|
||||
<div class="has-bottom-margin-xl">
|
||||
<h4 class="field-title">
|
||||
Encoded operation token
|
||||
</h4>
|
||||
<p class="help has-text-grey has-bottom-margin-xs">
|
||||
This is a one-time token that will be used to generate the operation token. Please save it.
|
||||
</p>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
<HoverCopyButton @copyValue={{this.encoded_token}} />
|
||||
<code class="is-word-break" data-test-shamir-encoded-token>{{this.encoded_token}}</code>
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.otp}}
|
||||
<div class="has-bottom-margin-xl">
|
||||
<h4 class="field-title">
|
||||
One time password (OTP)
|
||||
</h4>
|
||||
<p class="help has-text-grey has-bottom-margin-xs">
|
||||
This OTP will be used to decode the generated operation token. Please save it.
|
||||
</p>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
<HoverCopyButton @copyValue={{this.otp}} />
|
||||
<code class="is-word-break">{{this.otp}}</code>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="has-bottom-margin-xl">
|
||||
<h4 class="field-title">
|
||||
DR operation token command
|
||||
</h4>
|
||||
<p class="help has-text-grey has-bottom-margin-xs">
|
||||
{{#if this.otp}}
|
||||
This command contains both the encoded token and the OTP. It should be executed on the secondary cluster in
|
||||
order to generate the operation token.
|
||||
{{else}}
|
||||
This command requires the OTP saved earlier. It should be executed on the secondary cluster in order to
|
||||
generate the operation token.
|
||||
{{/if}}
|
||||
</p>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
{{! template-lint-disable quotes }}
|
||||
{{#let
|
||||
(if
|
||||
this.otp
|
||||
(concat 'vault operator generate-root -dr-token -otp="' this.otp '" -decode="' this.encoded_token '"')
|
||||
(concat
|
||||
'vault operator generate-root -dr-token -otp="<enter your otp here>" -decode="' this.encoded_token '"'
|
||||
)
|
||||
)
|
||||
as |cmd|
|
||||
}}
|
||||
<HoverCopyButton @copyValue={{cmd}} />
|
||||
<code class="is-word-break">{{cmd}}</code>
|
||||
{{/let}}
|
||||
{{! template-lint-enable quotes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="button is-primary" {{action "onCancelClose"}}>
|
||||
Clear & Close
|
||||
</button>
|
||||
</div>
|
||||
{{else if (and this.generateAction (not this.started))}}
|
||||
<form {{action "startGenerate" (hash pgp_key=this.pgp_key) on="submit"}} id="shamir" aria-label="shamir generate form">
|
||||
<MessageError @errors={{this.errors}} />
|
||||
{{#if (eq this.generateStep "chooseMethod")}}
|
||||
<div class="has-bottom-margin-m" data-test-shamir-modal-body>
|
||||
{{yield}}
|
||||
</div>
|
||||
<div class="field is-grouped is-flex-center">
|
||||
<div class="control is-flex-row">
|
||||
<button type="button" class="link" {{action (mut this.generateWithPGP) true}}>
|
||||
Provide PGP Key
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<span class="has-side-padding-s">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary" data-test-generate-token-cta>
|
||||
Generate operation token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (eq this.generateStep "providePGPKey")}}
|
||||
<div class="has-bottom-margin-m">
|
||||
<p>
|
||||
Choose a PGP Key from your computer or paste the contents of one in the form below. This key will be used to
|
||||
Encrypt the generated operation token.
|
||||
</p>
|
||||
<PgpFile @index="" @key={{this.pgpKeyFile}} @onChange={{action "setKey"}} />
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button type="button" class="button" {{action "reset"}}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" disabled={{not this.pgp_key}} class="button is-primary" {{action "savePGPKey"}}>
|
||||
Use PGP Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (eq this.generateStep "beginGenerationWithPGP")}}
|
||||
<div>
|
||||
<p class="has-bottom-margin-m">
|
||||
Below is the base-64 encoded PGP Key that will be used to encrypt the generated operation token. Next we'll
|
||||
enter portions of the root key to generate an operation token. Click the "Generate operation token" button to
|
||||
proceed.
|
||||
</p>
|
||||
<h4 class="field-title has-bottom-padding-m is-fullwidth">
|
||||
PGP Key
|
||||
{{this.pgpKeyFile.filename}}
|
||||
</h4>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
<HoverCopyButton @copyValue={{this.pgp_key}} />
|
||||
<code class="is-word-break">{{this.pgp_key}}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button type="button" class="button" {{action "reset"}}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" disabled={{and (not this.pgp_key)}} class="button is-primary">
|
||||
Generate operation token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</form>
|
||||
{{else}}
|
||||
<form {{action "onSubmit" (hash key=this.key) on="submit"}} id="shamir" aria-label="shamir form">
|
||||
<div class="has-bottom-margin-m">
|
||||
{{#if this.errors}}
|
||||
<div class="box is-shadowless is-marginless no-padding-top is-fullwidth">
|
||||
<MessageError @errors={{this.errors}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="box is-shadowless is-marginless no-padding-top is-fullwidth no-padding-sides" data-test-form-text>
|
||||
{{#if this.otp}}
|
||||
<div class="has-bottom-margin-xxl">
|
||||
<h4 class="field-title">
|
||||
One-time password (OTP)
|
||||
</h4>
|
||||
<p class="help has-text-grey has-bottom-margin-xs">
|
||||
This OTP will be used to decode the generated operation token.
|
||||
<span class="has-text-weight-semibold">Save this</span>, as you will need it later to decode the operation
|
||||
token.
|
||||
</p>
|
||||
<div class="message is-list has-copy-button" tabindex="-1">
|
||||
<HoverCopyButton @copyValue={{this.otp}} />
|
||||
<code class="is-word-break">{{this.otp}}</code>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<p>
|
||||
Generate an operation token by entering a portion of the primary's root key. Once all portions are entered, the
|
||||
generated token may be used to manage your secondary Disaster Recovery cluster.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="key" class="is-label">
|
||||
root key Portion
|
||||
</label>
|
||||
<div class="control">
|
||||
<Input class="input" @type="password" name="key" @value={{this.key}} data-test-shamir-input={{true}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="has-bottom-margin-m">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-narrow">
|
||||
<button type="submit" class="button is-primary" disabled={{this.loading}} data-test-shamir-submit={{true}}>
|
||||
{{if this.generateAction "Generate Token" this.buttonText}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-flex-v-centered is-flex-end">
|
||||
{{#if (or this.started this.hasProgress)}}
|
||||
<ShamirProgress @threshold={{this.threshold}} @progress={{this.progress}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{/if}}
|
||||
</section>
|
||||
<footer class="modal-card-foot modal-card-foot-outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-secondary"
|
||||
onclick={{action "onCancelClose"}}
|
||||
data-test-shamir-modal-cancel-button
|
||||
>
|
||||
{{if this.encoded_token "Close" "Cancel"}}
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
||||
1
ui/lib/core/app/components/choose-pgp-key-form.js
Normal file
1
ui/lib/core/app/components/choose-pgp-key-form.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from 'core/components/choose-pgp-key-form';
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default } from 'core/components/shamir-flow';
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default } from 'core/components/shamir-modal-flow';
|
||||
1
ui/lib/core/app/components/shamir/dr-token-flow.js
Normal file
1
ui/lib/core/app/components/shamir/dr-token-flow.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from 'core/components/shamir/dr-token-flow';
|
||||
1
ui/lib/core/app/components/shamir/flow.js
Normal file
1
ui/lib/core/app/components/shamir/flow.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from 'core/components/shamir/flow';
|
||||
1
ui/lib/core/app/components/shamir/form.js
Normal file
1
ui/lib/core/app/components/shamir/form.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from 'core/components/shamir/form';
|
||||
@@ -40,7 +40,7 @@ module('Acceptance | unseal', function (hooks) {
|
||||
|
||||
// unseal
|
||||
for (const key of unsealKeys) {
|
||||
await fillIn('[data-test-shamir-input]', key);
|
||||
await fillIn('[data-test-shamir-key-input]', key);
|
||||
|
||||
await click('button[type="submit"]');
|
||||
|
||||
|
||||
9
ui/tests/helpers/components/shamir.js
Normal file
9
ui/tests/helpers/components/shamir.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export const SHAMIR_FORM = {
|
||||
input: '[data-test-shamir-key-input]',
|
||||
inputLabel: '[data-test-shamir-key-label]',
|
||||
submitButton: '[data-test-shamir-submit]',
|
||||
otpInfo: '[data-test-otp-info]',
|
||||
otpCode: '[data-test-otp]',
|
||||
progress: '.shamir-progress',
|
||||
error: '[data-test-message-error]',
|
||||
};
|
||||
79
ui/tests/integration/components/choose-pgp-key-form-test.js
Normal file
79
ui/tests/integration/components/choose-pgp-key-form-test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import sinon from 'sinon';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Component | choose-pgp-key-form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.set('onCancel', () => {});
|
||||
this.set('onSubmit', () => {});
|
||||
});
|
||||
|
||||
test('it renders correctly', async function (assert) {
|
||||
await render(
|
||||
hbs`<ChoosePgpKeyForm @onSubmit={{this.onSubmit}} @onCancel={{this.onCancel}} @formText="my custom form text" @buttonText="Do it" />`
|
||||
);
|
||||
|
||||
assert.dom('[data-test-choose-pgp-key-form="begin"]').exists('PGP key selection form exists');
|
||||
assert
|
||||
.dom('[data-test-choose-pgp-key-description]')
|
||||
.hasText('my custom form text', 'uses custom form text');
|
||||
await click('[data-test-text-toggle]');
|
||||
assert.dom('[data-test-use-pgp-key-button]').isDisabled('use pgp button is disabled');
|
||||
await fillIn('[data-test-pgp-file-textarea]', 'base64-pgp-key');
|
||||
assert.dom('[data-test-use-pgp-key-button]').isNotDisabled('use pgp button is no longer disabled');
|
||||
await click('[data-test-use-pgp-key-button]');
|
||||
assert
|
||||
.dom('[data-test-pgp-key-confirm]')
|
||||
.hasText(
|
||||
'Below is the base-64 encoded PGP Key that will be used. Click the "Do it" button to proceed.',
|
||||
'Incorporates button text in confirmation'
|
||||
);
|
||||
assert.dom('[data-test-pgp-key-copy]').hasText('base64-pgp-key', 'Shows PGP key contents');
|
||||
assert.dom('[data-test-confirm-pgp-key-submit]').hasText('Do it', 'uses passed buttonText');
|
||||
await click('[data-test-confirm-pgp-key-submit]');
|
||||
});
|
||||
test('it calls onSubmit correctly', async function (assert) {
|
||||
const submitSpy = sinon.spy();
|
||||
this.set('onSubmit', submitSpy);
|
||||
await render(
|
||||
hbs`<ChoosePgpKeyForm @onSubmit={{this.onSubmit}} @onCancel={{this.onCancel}} @buttonText="Submit" />`
|
||||
);
|
||||
|
||||
assert.dom('[data-test-choose-pgp-key-form="begin"]').exists('PGP key selection form exists');
|
||||
assert
|
||||
.dom('[data-test-choose-pgp-key-description]')
|
||||
.hasText('Choose a PGP Key from your computer or paste the contents of one in the form below.');
|
||||
await click('[data-test-text-toggle]');
|
||||
assert.dom('[data-test-use-pgp-key-button]').isDisabled('use pgp button is disabled');
|
||||
await fillIn('[data-test-pgp-file-textarea]', 'base64-pgp-key');
|
||||
assert.dom('[data-test-use-pgp-key-button]').isNotDisabled('use pgp button is no longer disabled');
|
||||
await click('[data-test-use-pgp-key-button]');
|
||||
assert
|
||||
.dom('[data-test-pgp-key-confirm]')
|
||||
.hasText(
|
||||
'Below is the base-64 encoded PGP Key that will be used. Click the "Submit" button to proceed.',
|
||||
'Confirmation text has buttonText'
|
||||
);
|
||||
assert.dom('[data-test-pgp-key-copy]').hasText('base64-pgp-key', 'Shows PGP key contents');
|
||||
assert.dom('[data-test-confirm-pgp-key-submit]').hasText('Submit', 'uses passed buttonText');
|
||||
await click('[data-test-confirm-pgp-key-submit]');
|
||||
assert.ok(submitSpy.calledOnceWith('base64-pgp-key'));
|
||||
});
|
||||
|
||||
test('it calls cancel on cancel', async function (assert) {
|
||||
const cancelSpy = sinon.spy();
|
||||
this.set('onCancel', cancelSpy);
|
||||
await render(
|
||||
hbs`<ChoosePgpKeyForm @onSubmit={{this.onSubmit}} @onCancel={{this.onCancel}} @buttonText="Submit" />`
|
||||
);
|
||||
|
||||
await click('[data-test-text-toggle]');
|
||||
await fillIn('[data-test-pgp-file-textarea]', 'base64-pgp-key');
|
||||
await click('[data-test-use-pgp-key-cancel]');
|
||||
assert.ok(cancelSpy.calledOnce);
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import { run } from '@ember/runloop';
|
||||
import Service from '@ember/service';
|
||||
import { resolve } from 'rsvp';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
|
||||
const response = {
|
||||
progress: 1,
|
||||
required: 3,
|
||||
complete: false,
|
||||
};
|
||||
|
||||
const adapter = {
|
||||
foo() {
|
||||
return resolve(response);
|
||||
},
|
||||
};
|
||||
|
||||
const storeStub = Service.extend({
|
||||
adapterFor() {
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
|
||||
module('Integration | Component | shamir flow', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.foo = function () {};
|
||||
run(() => {
|
||||
this.owner.unregister('service:store');
|
||||
this.owner.register('service:store', storeStub);
|
||||
this.storeService = this.owner.lookup('service:store');
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await render(hbs`<ShamirFlow @formText="like whoa" />`);
|
||||
|
||||
assert.dom('form [data-test-form-text]').hasText('like whoa', 'renders formText inline');
|
||||
|
||||
await render(hbs`
|
||||
<ShamirFlow @formText="like whoa">
|
||||
<p>whoa again</p>
|
||||
</ShamirFlow>
|
||||
`);
|
||||
|
||||
assert.dom('.shamir-progress').doesNotExist('renders no progress bar for no progress');
|
||||
assert.dom('form [data-test-form-text]').hasText('whoa again', 'renders the block, not formText');
|
||||
|
||||
await render(hbs`
|
||||
<ShamirFlow @progress={{1}} @threshold={{5}} />
|
||||
`);
|
||||
assert.dom('.shamir-progress').hasText('1/5 keys provided', 'displays textual progress');
|
||||
|
||||
this.set('errors', ['first error', 'this is fine']);
|
||||
await render(hbs`
|
||||
<ShamirFlow @errors={{this.errors}} />
|
||||
`);
|
||||
assert.dom('[data-test-message-error]').exists({ count: 2 }, 'renders errors');
|
||||
});
|
||||
|
||||
test('it sends data to the passed action', async function (assert) {
|
||||
this.set('key', 'foo');
|
||||
await render(hbs`
|
||||
<ShamirFlow @key={{this.key}} @action="foo" @thresholdPath="required" />
|
||||
`);
|
||||
await click('[data-test-shamir-submit]');
|
||||
assert
|
||||
.dom('.shamir-progress')
|
||||
.hasText(`${response.progress}/${response.required} keys provided`, 'displays the correct progress');
|
||||
});
|
||||
|
||||
test('it checks onComplete to call onShamirSuccess', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.set('key', 'foo');
|
||||
this.set('onSuccess', function () {
|
||||
assert.ok(true, 'onShamirSuccess called');
|
||||
});
|
||||
|
||||
this.set('checkComplete', function () {
|
||||
assert.ok(true, 'onComplete called');
|
||||
// return true so we trigger success call
|
||||
return true;
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<ShamirFlow @key={{this.key}} @action="foo" @isComplete={{action this.checkComplete}} @onShamirSuccess={{action this.onSuccess}} />
|
||||
`);
|
||||
await click('[data-test-shamir-submit]');
|
||||
});
|
||||
|
||||
test('it fetches progress on init when fetchOnInit is true', async function (assert) {
|
||||
await render(hbs`
|
||||
<ShamirFlow @action="foo" @fetchOnInit={{true}} />
|
||||
`);
|
||||
assert
|
||||
.dom('.shamir-progress')
|
||||
.hasText(`${response.progress}/${response.required} keys provided`, 'displays the correct progress');
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import { module, test, skip } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
|
||||
module('Integration | Component | shamir-modal-flow', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.set('isActive', true);
|
||||
this.set('onClose', sinon.spy());
|
||||
this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', () => {});
|
||||
});
|
||||
|
||||
test('it renders with initial content by default', async function (assert) {
|
||||
await render(hbs`
|
||||
<div id="modal-wormhole"></div>
|
||||
<ShamirModalFlow
|
||||
@action="generate-dr-operation-token"
|
||||
@buttonText="Generate token"
|
||||
@fetchOnInit=true
|
||||
@generateAction=true
|
||||
@buttonText="My CTA"
|
||||
@onClose={{this.onClose}}
|
||||
@isActive={{this.isActive}}
|
||||
>
|
||||
<p>Inner content goes here</p>
|
||||
</ShamirModalFlow>
|
||||
`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-shamir-modal-body]')
|
||||
.hasText('Inner content goes here', 'Template block gets rendered');
|
||||
assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Cancel', 'Shows cancel button');
|
||||
});
|
||||
|
||||
test('Shows correct content when started', async function (assert) {
|
||||
await render(hbs`
|
||||
<div id="modal-wormhole"></div>
|
||||
<ShamirModalFlow
|
||||
@started=true
|
||||
@action="generate-dr-operation-token"
|
||||
@buttonText="Generate token"
|
||||
@fetchOnInit=true
|
||||
@generateAction=true
|
||||
@buttonText="Crazy CTA"
|
||||
@onClose={{this.onClose}}
|
||||
@isActive={{this.isActive}}
|
||||
>
|
||||
<p>Inner content goes here</p>
|
||||
</ShamirModalFlow>
|
||||
`);
|
||||
assert.dom('[data-test-shamir-input]').exists('Asks for root key Portion');
|
||||
assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Cancel', 'Shows cancel button');
|
||||
});
|
||||
|
||||
test('Shows OTP when provided and flow started', async function (assert) {
|
||||
await render(hbs`
|
||||
<div id="modal-wormhole"></div>
|
||||
<ShamirModalFlow
|
||||
@encoded_token="my-encoded-token"
|
||||
@action="generate-dr-operation-token"
|
||||
@buttonText="Generate token"
|
||||
@fetchOnInit=true
|
||||
@generateAction=true
|
||||
@buttonText="Crazy CTA"
|
||||
@onClose={{this.onClose}}
|
||||
@isActive={{this.isActive}}
|
||||
>
|
||||
<p>Inner content goes here</p>
|
||||
</ShamirModalFlow>
|
||||
`);
|
||||
assert.dom('[data-test-shamir-encoded-token]').hasText('my-encoded-token', 'Shows encoded token');
|
||||
assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Close', 'Shows close button');
|
||||
});
|
||||
skip('DR Secondary actions', async function () {
|
||||
// DR Secondaries cannot be tested yet, but once they can
|
||||
// we should add tests for Cancel button functionality
|
||||
});
|
||||
});
|
||||
217
ui/tests/integration/components/shamir/dr-token-flow-test.js
Normal file
217
ui/tests/integration/components/shamir/dr-token-flow-test.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import sinon from 'sinon';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
|
||||
module('Integration | Component | shamir/dr-token-flow', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
test('begin to middle flow works', async function (assert) {
|
||||
assert.expect(15);
|
||||
this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
assert.ok('Check endpoint is queried on init');
|
||||
return {};
|
||||
});
|
||||
this.server.post('/sys/replication/dr/secondary/generate-operation-token/attempt', function (_, req) {
|
||||
const requestBody = JSON.parse(req.requestBody);
|
||||
assert.ok('Starts the token generation');
|
||||
assert.deepEqual(requestBody, { attempt: true });
|
||||
return {
|
||||
started: true,
|
||||
nonce: 'nonce-1234',
|
||||
progress: 0,
|
||||
required: 3,
|
||||
encoded_token: '',
|
||||
otp: 'otp-9876',
|
||||
otp_length: 24,
|
||||
complete: false,
|
||||
};
|
||||
});
|
||||
this.server.post('/sys/replication/dr/secondary/generate-operation-token/update', function (_, req) {
|
||||
const requestBody = JSON.parse(req.requestBody);
|
||||
assert.ok('Makes request at the /update path');
|
||||
assert.deepEqual(requestBody, { key: 'some-key', nonce: 'nonce-1234' });
|
||||
return {
|
||||
started: true,
|
||||
nonce: 'nonce-1234',
|
||||
progress: 1,
|
||||
required: 3,
|
||||
encoded_token: '',
|
||||
otp: '',
|
||||
otp_length: 24,
|
||||
complete: false,
|
||||
};
|
||||
});
|
||||
await render(hbs`<Shamir::DrTokenFlow @action="generate-dr-operation-token" />`);
|
||||
assert.dom('[data-test-dr-token-flow-step="begin"]').exists('First step shows');
|
||||
assert.dom('[data-test-use-pgp-key-cta]').hasText('Provide PGP Key');
|
||||
assert.dom('[data-test-generate-token-cta]').hasText('Generate operation token');
|
||||
await click('[data-test-generate-token-cta]');
|
||||
assert.dom('[data-test-dr-token-flow-step="shamir"]').exists('Shows shamir step after start');
|
||||
assert
|
||||
.dom('.shamir-progress')
|
||||
.hasText('0/3 keys provided', 'progress shows reflecting checkStatus response with defaults');
|
||||
assert.dom('[data-test-otp-info]').exists('OTP info banner shows');
|
||||
assert.dom('[data-test-otp]').hasText('otp-9876', 'Shows OTP in copy banner');
|
||||
// Fill in shamir key and submit
|
||||
await fillIn('[data-test-shamir-key-input]', 'some-key');
|
||||
await click('[data-test-shamir-submit]');
|
||||
assert.dom('.shamir-progress').hasText('1/3 keys provided', 'progress shows reflecting attempt response');
|
||||
assert
|
||||
.dom('[data-test-otp-info]')
|
||||
.exists('OTP info still banner shows even when attempt response does not include it');
|
||||
assert
|
||||
.dom('[data-test-otp]')
|
||||
.hasText('otp-9876', 'Still shows OTP in copy banner when attempt response does not include it');
|
||||
});
|
||||
|
||||
test('middle to finish flow works', async function (assert) {
|
||||
assert.expect(9);
|
||||
this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
assert.ok('Check endpoint is queried on init');
|
||||
return {
|
||||
started: true,
|
||||
nonce: 'nonce-1234',
|
||||
progress: 2,
|
||||
required: 3,
|
||||
encoded_token: '',
|
||||
otp: '',
|
||||
otp_length: 24,
|
||||
complete: false,
|
||||
};
|
||||
});
|
||||
this.server.post('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
assert.notOk('attempt endpoint should not be queried');
|
||||
});
|
||||
this.server.post('/sys/replication/dr/secondary/generate-operation-token/update', function (_, req) {
|
||||
const requestBody = JSON.parse(req.requestBody);
|
||||
assert.ok('Makes request at the /update path');
|
||||
assert.deepEqual(requestBody, { key: 'some-key', nonce: 'nonce-1234' });
|
||||
return {
|
||||
started: true,
|
||||
nonce: 'nonce-1234',
|
||||
progress: 3,
|
||||
required: 3,
|
||||
encoded_token: 'encoded-token-here',
|
||||
otp: '',
|
||||
otp_length: 24,
|
||||
complete: true,
|
||||
};
|
||||
});
|
||||
await render(hbs`<Shamir::DrTokenFlow @action="generate-dr-operation-token" />`);
|
||||
assert.dom('[data-test-dr-token-flow-step="shamir"]').exists('Shows shamir step on load');
|
||||
assert
|
||||
.dom('.shamir-progress')
|
||||
.hasText('2/3 keys provided', 'progress shows reflecting checkStatus response');
|
||||
assert.dom('[data-test-otp-info]').doesNotExist('OTP info banner not shown');
|
||||
assert.dom('[data-test-otp]').doesNotExist('otp-9876', 'OTP copy banner not shown');
|
||||
await fillIn('[data-test-shamir-key-input]', 'some-key');
|
||||
await click('[data-test-shamir-submit]');
|
||||
assert
|
||||
.dom('[data-test-dr-token-flow-step="show-token"]')
|
||||
.exists('updates to show encoded token on complete');
|
||||
assert
|
||||
.dom('[data-test-shamir-encoded-token]')
|
||||
.hasText('encoded-token-here', 'shows encoded token from /update response');
|
||||
});
|
||||
|
||||
test('it works correctly when pgp key chosen', async function (assert) {
|
||||
assert.expect(3);
|
||||
this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
return {};
|
||||
});
|
||||
this.server.post(
|
||||
'/sys/replication/dr/secondary/generate-operation-token/attempt',
|
||||
function (schema, req) {
|
||||
const body = JSON.parse(req.requestBody);
|
||||
assert.deepEqual(body, { pgp_key: 'some-key-here' }, 'correct payload');
|
||||
return {
|
||||
started: true,
|
||||
progress: 1,
|
||||
required: 3,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
);
|
||||
await render(hbs`<Shamir::DrTokenFlow @action="generate-dr-operation-token" />`);
|
||||
await click('[data-test-use-pgp-key-cta]');
|
||||
assert.dom('[data-test-choose-pgp-key-form="begin"]').exists('PGP form shows');
|
||||
await click('[data-test-text-toggle]');
|
||||
await fillIn('[data-test-pgp-file-textarea]', 'some-key-here');
|
||||
await click('[data-test-use-pgp-key-button]');
|
||||
await click('[data-test-confirm-pgp-key-submit]');
|
||||
assert.dom('[data-test-dr-token-flow-step="shamir"]').exists('Renders shamir step after PGP key chosen');
|
||||
});
|
||||
|
||||
test('it cancels correctly when generation not started', async function (assert) {
|
||||
assert.expect(2);
|
||||
const cancelSpy = sinon.spy();
|
||||
this.set('onCancel', cancelSpy);
|
||||
this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
return {};
|
||||
});
|
||||
this.server.delete('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
assert.notOk('delete endpoint should not be queried');
|
||||
return {};
|
||||
});
|
||||
await render(
|
||||
hbs`<Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{this.onCancel}} />`
|
||||
);
|
||||
|
||||
assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Cancel', 'Close button has correct copy');
|
||||
await click('[data-test-shamir-modal-cancel-button]');
|
||||
assert.ok(cancelSpy.calledOnce, 'cancel spy called on click');
|
||||
});
|
||||
test('it cancels correctly when generation has started but not finished', async function (assert) {
|
||||
assert.expect(3);
|
||||
const cancelSpy = sinon.spy();
|
||||
this.set('onCancel', cancelSpy);
|
||||
this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
return {
|
||||
started: true,
|
||||
progress: 1,
|
||||
required: 3,
|
||||
complete: false,
|
||||
};
|
||||
});
|
||||
this.server.delete('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
assert.ok('delete endpoint is queried');
|
||||
return {};
|
||||
});
|
||||
await render(
|
||||
hbs`<Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{this.onCancel}} />`
|
||||
);
|
||||
|
||||
assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Cancel', 'Close button has correct copy');
|
||||
await click('[data-test-shamir-modal-cancel-button]');
|
||||
assert.ok(cancelSpy.calledOnce, 'cancel spy called on click');
|
||||
});
|
||||
test('it closes correctly when generation is completed', async function (assert) {
|
||||
assert.expect(2);
|
||||
const cancelSpy = sinon.spy();
|
||||
this.set('onCancel', cancelSpy);
|
||||
this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
return {
|
||||
started: true,
|
||||
progress: 3,
|
||||
required: 3,
|
||||
complete: true,
|
||||
encoded_token: 'foobar',
|
||||
};
|
||||
});
|
||||
this.server.delete('/sys/replication/dr/secondary/generate-operation-token/attempt', function () {
|
||||
assert.notOk('delete endpoint should not be queried');
|
||||
return {};
|
||||
});
|
||||
await render(
|
||||
hbs`<Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{this.onCancel}} />`
|
||||
);
|
||||
|
||||
assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Close', 'Close button has correct copy');
|
||||
await click('[data-test-shamir-modal-cancel-button]');
|
||||
assert.ok(cancelSpy.calledOnce, 'cancel spy called on click');
|
||||
});
|
||||
});
|
||||
129
ui/tests/integration/components/shamir/flow-test.js
Normal file
129
ui/tests/integration/components/shamir/flow-test.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import sinon from 'sinon';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import Service from '@ember/service';
|
||||
import { run } from '@ember/runloop';
|
||||
import { reject, resolve } from 'rsvp';
|
||||
import { SHAMIR_FORM } from 'vault/tests/helpers/components/shamir';
|
||||
|
||||
const licenseError = { httpStatus: 500, errors: ['failed because licensing is in an invalid state'] };
|
||||
const response = {
|
||||
progress: 1,
|
||||
required: 3,
|
||||
complete: false,
|
||||
};
|
||||
|
||||
const adapter = {
|
||||
foo() {
|
||||
return resolve(response);
|
||||
},
|
||||
responseWithErrors() {
|
||||
return reject({ httpStatus: 400, errors: ['something is wrong', 'seriously wrong'] });
|
||||
},
|
||||
responseWithLicense() {
|
||||
return reject(licenseError);
|
||||
},
|
||||
};
|
||||
|
||||
const storeStub = Service.extend({
|
||||
adapterFor() {
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
|
||||
// Checks that the correct data were passed around happens in the integration test
|
||||
// this one is checking that things happen at the right time
|
||||
module('Integration | Component | shamir/flow', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.keyPart = 'some-key-partition';
|
||||
run(() => {
|
||||
this.owner.unregister('service:store');
|
||||
this.owner.register('service:store', storeStub);
|
||||
this.storeService = this.owner.lookup('service:store');
|
||||
});
|
||||
});
|
||||
|
||||
test('it sends data to the passed action and calls updateProgress', async function (assert) {
|
||||
const updateSpy = sinon.spy();
|
||||
const completeSpy = sinon.spy();
|
||||
this.set('updateProgress', updateSpy);
|
||||
this.set('checkComplete', () => false);
|
||||
this.set('onSuccess', completeSpy);
|
||||
this.set('progress', 0);
|
||||
|
||||
await render(hbs`
|
||||
<Shamir::Flow
|
||||
@action="foo"
|
||||
@threshold={{3}}
|
||||
@progress={{this.progress}}
|
||||
@updateProgress={{this.updateProgress}}
|
||||
@checkComplete={{this.checkComplete}}
|
||||
@onShamirSuccess={{this.onSuccess}}
|
||||
/>`);
|
||||
|
||||
await fillIn(SHAMIR_FORM.input, this.keyPart);
|
||||
await click(SHAMIR_FORM.submitButton);
|
||||
|
||||
assert.ok(completeSpy.notCalled, 'onShamirSuccess was not called');
|
||||
assert.ok(updateSpy.calledOnce, 'updateProgress was called');
|
||||
// Default shamir flow expects the updated values to be passed
|
||||
// in from parent model, so this approximates the update happening
|
||||
// from a side effect of the updateProgress call
|
||||
this.set('progress', 2);
|
||||
// Pretend the next call will mean completion
|
||||
this.set('checkComplete', () => true);
|
||||
await settled();
|
||||
|
||||
await fillIn(SHAMIR_FORM.input, this.keyPart);
|
||||
await click(SHAMIR_FORM.submitButton);
|
||||
|
||||
assert.ok(completeSpy.calledOnce, 'onShamirSuccess was called');
|
||||
assert.ok(updateSpy.calledTwice, 'updateProgress was called again');
|
||||
});
|
||||
|
||||
test('it shows the error when adapter fails with 400 httpStatus', async function (assert) {
|
||||
assert.expect(3);
|
||||
const updateSpy = sinon.spy();
|
||||
const completeSpy = sinon.spy();
|
||||
this.set('updateProgress', updateSpy);
|
||||
this.set('checkComplete', completeSpy);
|
||||
await render(hbs`
|
||||
<Shamir::Flow
|
||||
@action="response-with-errors"
|
||||
@threshold={{3}}
|
||||
@progress={{2}}
|
||||
@updateProgress={{this.updateProgress}}
|
||||
@checkComplete={{this.checkComplete}}
|
||||
/>`);
|
||||
|
||||
await fillIn(SHAMIR_FORM.input, this.keyPart);
|
||||
await click(SHAMIR_FORM.submitButton);
|
||||
assert.dom(SHAMIR_FORM.error).exists({ count: 2 }, 'renders errors');
|
||||
assert.ok(completeSpy.notCalled, 'checkComplete was not called');
|
||||
assert.ok(updateSpy.notCalled, 'updateProgress was not called');
|
||||
});
|
||||
|
||||
test.skip('it throws the error when adapter fails with license error', async function (assert) {
|
||||
assert.expect(2);
|
||||
try {
|
||||
const licenseSpy = sinon.spy();
|
||||
this.set('onLicenseError', licenseSpy);
|
||||
await render(hbs`
|
||||
<Shamir::Flow
|
||||
@action="response-with-license"
|
||||
@threshold={{3}}
|
||||
@progress={{2}}
|
||||
@onLicenseError={{this.onLicenseError}}
|
||||
/>`);
|
||||
await fillIn(SHAMIR_FORM.input, this.keyPart);
|
||||
await click(SHAMIR_FORM.submitButton);
|
||||
assert.ok(licenseSpy.calledOnce, 'license error triggered');
|
||||
} catch (e) {
|
||||
assert.deepEqual(e, licenseError, 'throws the error');
|
||||
}
|
||||
});
|
||||
});
|
||||
81
ui/tests/integration/components/shamir/form-test.js
Normal file
81
ui/tests/integration/components/shamir/form-test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { module, test } from 'qunit';
|
||||
import sinon from 'sinon';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { click, render, settled, typeIn } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { SHAMIR_FORM } from 'vault/tests/helpers/components/shamir';
|
||||
|
||||
module('Integration | Component | shamir/form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.submitSpy = sinon.spy();
|
||||
});
|
||||
|
||||
test('it does calls callback only if key value present', async function (assert) {
|
||||
await render(hbs`
|
||||
<Shamir::Form @onSubmit={{this.submitSpy}} @progress={{0}} @threshold={{3}} />
|
||||
`);
|
||||
assert.dom(SHAMIR_FORM.submitButton).hasText('Submit', 'Submit button has default text');
|
||||
await click(SHAMIR_FORM.submitButton);
|
||||
assert.dom(SHAMIR_FORM.progress).doesNotExist('Hides progress bar if none made');
|
||||
assert.ok(this.submitSpy.notCalled, 'onSubmit was not called');
|
||||
await typeIn(SHAMIR_FORM.input, 'this-is-the-key');
|
||||
assert.dom(SHAMIR_FORM.input).hasValue('this-is-the-key', 'input value set');
|
||||
assert.dom(SHAMIR_FORM.inputLabel).hasText('Shamir key portion', 'label has default text');
|
||||
await click(SHAMIR_FORM.submitButton);
|
||||
assert.ok(
|
||||
this.submitSpy.calledOnceWith({ key: 'this-is-the-key' }),
|
||||
'onSubmit called with correct params'
|
||||
);
|
||||
assert.dom(SHAMIR_FORM.input).hasValue('', 'key value reset after submit');
|
||||
|
||||
await render(hbs`
|
||||
<Shamir::Form @onSubmit={{this.submitSpy}} @progress={{0}} @threshold={{3}} @alwaysShowProgress={{true}} @buttonText="Do the thing" @inputLabel="Unseal key">
|
||||
<div data-test-block-content>Hello</div>
|
||||
</Shamir::Form>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-block-content]').hasText('Hello', 'renders block content');
|
||||
assert.dom(SHAMIR_FORM.submitButton).hasText('Do the thing', 'uses passed button text');
|
||||
assert.dom(SHAMIR_FORM.inputLabel).hasText('Unseal key', 'uses passed inputLabel');
|
||||
assert.dom(SHAMIR_FORM.otpInfo).doesNotExist('no OTP info shown');
|
||||
assert
|
||||
.dom(SHAMIR_FORM.progress)
|
||||
.hasText('0/3 keys provided', 'displays textual progress when alwaysShowProgress=true');
|
||||
});
|
||||
|
||||
test('it shows OTP info if provided', async function (assert) {
|
||||
await render(hbs`
|
||||
<Shamir::Form
|
||||
@onSubmit={{this.submitSpy}}
|
||||
@progress={{2}}
|
||||
@threshold={{4}}
|
||||
@otp="this-is-otp"
|
||||
@inputLabel="Please input key"
|
||||
>
|
||||
<div data-test-block-content>Hello</div>
|
||||
</Shamir::Form>
|
||||
`);
|
||||
|
||||
assert.dom(SHAMIR_FORM.otpInfo).exists('shows OTP info');
|
||||
assert.dom(SHAMIR_FORM.otpCode).hasText('this-is-otp', 'shows OTP code');
|
||||
assert.dom(SHAMIR_FORM.progress).hasText('2/4 keys provided', 'displays textual progress');
|
||||
assert.dom('[data-test-block-content]').hasText('Hello', 'renders block content');
|
||||
});
|
||||
|
||||
test('renders errors provided', async function (assert) {
|
||||
this.set('errors', ['first error', 'this is fine']);
|
||||
await render(hbs`
|
||||
<Shamir::Form
|
||||
@onSubmit={{this.submitSpy}}
|
||||
@errors={{this.errors}}
|
||||
/>
|
||||
`);
|
||||
assert.dom(SHAMIR_FORM.error).exists({ count: 2 }, 'renders errors');
|
||||
|
||||
this.set('errors', []);
|
||||
await settled();
|
||||
assert.dom(SHAMIR_FORM.error).doesNotExist('errors cleared');
|
||||
});
|
||||
});
|
||||
@@ -250,4 +250,64 @@ module('Unit | Adapter | cluster', function (hooks) {
|
||||
);
|
||||
assert.strictEqual(method, 'GET', 'replication:dr secondary:promote method OK');
|
||||
});
|
||||
|
||||
test('cluster generateDrOperationToken', function (assert) {
|
||||
let url, method, options;
|
||||
const adapter = this.owner.factoryFor('adapter:cluster').create({
|
||||
ajax: (...args) => {
|
||||
[url, method, options] = args;
|
||||
return resolve();
|
||||
},
|
||||
});
|
||||
|
||||
// Generate token progress
|
||||
adapter.generateDrOperationToken({ key: 'foo' }, {});
|
||||
assert.strictEqual(
|
||||
url,
|
||||
'/v1/sys/replication/dr/secondary/generate-operation-token/update',
|
||||
'progress url correct'
|
||||
);
|
||||
assert.strictEqual(method, 'POST', 'progress method OK');
|
||||
assert.deepEqual({ data: { key: 'foo' }, unauthenticated: true }, options, 'progress payload OK');
|
||||
|
||||
// CheckStatus / Read generation progress
|
||||
adapter.generateDrOperationToken({ key: 'foo' }, { checkStatus: true });
|
||||
assert.strictEqual(
|
||||
url,
|
||||
'/v1/sys/replication/dr/secondary/generate-operation-token/attempt',
|
||||
'checkStatus url correct'
|
||||
);
|
||||
assert.strictEqual(method, 'GET', 'checkStatus method OK');
|
||||
assert.deepEqual({ data: { key: 'foo' }, unauthenticated: true }, options, 'checkStatus options OK');
|
||||
|
||||
// Cancel generation
|
||||
adapter.generateDrOperationToken({}, { cancel: true });
|
||||
assert.strictEqual(
|
||||
url,
|
||||
'/v1/sys/replication/dr/secondary/generate-operation-token/attempt',
|
||||
'Cancel url correct'
|
||||
);
|
||||
assert.strictEqual(method, 'DELETE', 'cancel method OK');
|
||||
assert.deepEqual({ data: {}, unauthenticated: true }, options, 'cancel options OK');
|
||||
|
||||
// pgp_key
|
||||
adapter.generateDrOperationToken({ pgp_key: 'yes' }, {});
|
||||
assert.strictEqual(
|
||||
url,
|
||||
'/v1/sys/replication/dr/secondary/generate-operation-token/attempt',
|
||||
'pgp_key url correct'
|
||||
);
|
||||
assert.strictEqual(method, 'POST', 'method ok when pgp_key on data');
|
||||
assert.deepEqual({ data: { pgp_key: 'yes' }, unauthenticated: true }, options, 'pgp_key options OK');
|
||||
|
||||
// data.attempt
|
||||
adapter.generateDrOperationToken({ attempt: true }, {});
|
||||
assert.strictEqual(
|
||||
url,
|
||||
'/v1/sys/replication/dr/secondary/generate-operation-token/attempt',
|
||||
'data.attempt url correct'
|
||||
);
|
||||
assert.strictEqual(method, 'POST', 'data.attempt method OK');
|
||||
assert.deepEqual({ data: { attempt: true }, unauthenticated: true }, options, 'data.attempt options OK');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user