PKI role create view (#17263)

* dynamically render the secretlistheader in the parent route.

* start getting form setup even without openAPi working

* add in create and cancel

* making openAPI work

* add default openAPI params

* wip for new component with two radio options a ttl and input

* handle createRecord on pki-roles-form

* remove tooltips and cleanup

* move formfieldgroupsloop back to non addon

* cleanup

* move secretListHeader

* broadcast from radioSelectTtlOrString to parent

* cleanup

* cleanup from pr comments

* more cleanup

* addressing Jordans comments

* use formFieldGroupsLoop move into addon.

* cleanup
This commit is contained in:
Angel Garbarino 2022-09-28 15:11:13 -07:00 committed by GitHub
parent 2136c1f8a3
commit 6771e564d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 418 additions and 44 deletions

View file

@ -20,9 +20,8 @@ export default class PkiIssuerEngineAdapter extends ApplicationAdapter {
return url;
}
async query(store, type, query) {
query(store, type, query) {
const { backend, id } = query;
let response = await this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id));
return response;
return this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id));
}
}

View file

@ -20,9 +20,8 @@ export default class PkiKeyEngineAdapter extends ApplicationAdapter {
return url;
}
async query(store, type, query) {
query(store, type, query) {
const { backend, id } = query;
let response = await this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id));
return response;
return this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id));
}
}

View file

@ -1,3 +1,52 @@
import PkiRoleAdapter from './pki-role';
import ApplicationAdapter from '../application';
import { assign } from '@ember/polyfills';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default class PkiRoleEngineAdapter extends PkiRoleAdapter {}
export default class PkiRoleEngineAdapter extends ApplicationAdapter {
namespace = 'v1';
_urlForRole(backend, id) {
let url = `${this.buildURL()}/${encodePath(backend)}/roles`;
if (id) {
url = url + '/' + encodePath(id);
}
return url;
}
_optionsForQuery(id) {
let data = {};
if (!id) {
data['list'] = true;
}
return { data };
}
createRecord(store, type, snapshot) {
let name = snapshot.attr('name');
let url = this._urlForRole(snapshot.record.backend, name);
return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then(() => {
return {
id: name,
name,
backend: snapshot.record.backend,
};
});
}
fetchByQuery(store, query) {
const { id, backend } = query;
return this.ajax(this._urlForRole(backend, id), 'GET', this._optionsForQuery(id)).then((resp) => {
const data = {
id,
name: id,
backend,
};
return assign({}, resp, data);
});
}
query(store, type, query) {
return this.fetchByQuery(store, query);
}
}

View file

@ -6,25 +6,77 @@ import { withModelValidations } from 'vault/decorators/model-validations';
import fieldToAttrs from 'vault/utils/field-to-attrs';
const validations = {
name: [
{ type: 'presence', message: 'Name is required.' },
{
type: 'containsWhiteSpace',
message: 'Name cannot contain whitespace.',
},
],
name: [{ type: 'presence', message: 'Name is required.' }],
};
@withModelValidations(validations)
export default class PkiRoleEngineModel extends Model {
@attr('string', { readOnly: true }) backend;
@attr('string', {
label: 'Role name',
fieldValue: 'id',
readOnly: true,
fieldValue: 'name',
})
name;
@attr('string', {
label: 'Issuer reference',
defaultValue: 'default',
subText:
'Specifies the issuer that will be used to create certificates with this role. To find this, run [command]. By default, we will use the mounts default issuer.',
})
issuerRef;
@attr({
label: 'Not valid after',
subText:
'The time after which this certificate will no longer be valid. This can be a TTL (a range of time from now) or a specific date. If no TTL is set, the system uses "default" or the value of max_ttl, whichever is shorter. Alternatively, you can set the not_after date below.',
editType: 'yield',
})
customTtl;
@attr({
label: 'Backdate validity',
helperTextEnabled:
'Also called the not_before_duration property. Allows certificates to be valid for a certain time period before now. This is useful to correct clock misalignment on various systems when setting up your CA.',
editType: 'ttl',
hideToggle: true,
})
notBeforeDuration;
@attr({
label: 'Max TTL',
helperTextDisabled:
'The maximum Time-To-Live of certificates generated by this role. If not set, the system max lease TTL will be used.',
editType: 'ttl',
})
maxTtl;
@attr('boolean', {
label: 'Generate lease with certificate',
subText:
'Specifies if certificates issued/signed against this role will have Vault leases attached to them.',
editType: 'boolean',
docLink: '/api-docs/secret/pki#create-update-role',
})
generateLease;
@attr('boolean', {
label: 'Do not store certificates in storage backend',
subText:
'This can improve performance when issuing large numbers of certificates. However, certificates issued in this way cannot be enumerated or revoked.',
editType: 'boolean',
docLink: '/api-docs/secret/pki#create-update-role',
})
noStore;
@attr('boolean', {
label: 'Basic constraints valid for non CA.',
subText: 'Mark Basic Constraints valid when issuing non-CA certificates.',
editType: 'boolean',
})
addBasicConstraints;
// must be a getter so it can be added to the prototype needed in the pathHelp service on the line here: if (newModel.merged || modelProto.useOpenAPI !== true) {
get useOpenAPI() {
return true;
@ -72,7 +124,18 @@ export default class PkiRoleEngineModel extends Model {
get fieldGroups() {
if (!this._fieldToAttrsGroups) {
this._fieldToAttrsGroups = fieldToAttrs(this, [
{ default: ['name'] },
{
default: [
'name',
'issuerRef',
'customTtl',
'notBeforeDuration',
'maxTtl',
'generateLease',
'noStore',
'addBasicConstraints',
],
},
{
'Domain handling': [
'allowedDomains',

View file

@ -213,6 +213,9 @@
.has-top-margin-xxl {
margin-top: $spacing-xxl;
}
.has-top-margin-negative-s {
margin-top: (-1 * $spacing-s);
}
.has-left-margin-xxs {
margin-left: $spacing-xxs;
}

View file

@ -4,7 +4,15 @@
{{#each fields as |attr|}}
{{! template-lint-configure simple-unless "warn" }}
{{#unless (and (not-eq @mode "create") (eq attr.name "name"))}}
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
<FormField
data-test-field={{true}}
@attr={{attr}}
@model={{@model}}
@modelValidations={{@modelValidations}}
@showHelpText={{@showHelpText}}
>
{{yield attr}}
</FormField>
{{/unless}}
{{/each}}
{{else}}
@ -20,7 +28,15 @@
{{#if (get @model prop)}}
<div class="box is-marginless">
{{#each fields as |attr|}}
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
<FormField
data-test-field={{true}}
@attr={{attr}}
@model={{@model}}
@modelValidations={{@modelValidations}}
@showHelpText={{@showHelpText}}
>
{{yield attr}}
</FormField>
{{/each}}
</div>
{{/if}}

View file

@ -13,7 +13,7 @@
<FormFieldLabel
for={{@attr.name}}
@label={{this.labelString}}
@helpText={{@attr.options.helpText}}
@helpText={{(if this.showHelpText @attr.options.helpText)}}
@subText={{@attr.options.subText}}
@docLink={{@attr.options.docLink}}
/>
@ -321,7 +321,6 @@
onchange={{this.onChangeWithEvent}}
data-test-input={{@attr.name}}
/>
<label for={{@attr.name}} class="is-label">
{{this.labelString}}
{{#if (and this.showHelpText @attr.options.helpText)}}
@ -329,7 +328,14 @@
{{/if}}
</label>
{{#if @attr.options.subText}}
<p class="sub-text">{{@attr.options.subText}}</p>
<p class="sub-text">
{{@attr.options.subText}}
{{#if @attr.options.docLink}}
<DocLink @path={{@attr.options.docLink}}>
Learn more here.
</DocLink>
{{/if}}
</p>
{{/if}}
</div>
{{else if (eq @attr.type "object")}}
@ -339,5 +345,7 @@
@valueUpdated={{fn this.codemirrorUpdated false}}
@helpText={{@attr.options.helpText}}
/>
{{else if (eq @attr.options.editType "yield")}}
{{yield}}
{{/if}}
</div>

View file

@ -21,7 +21,7 @@ import { dasherize } from 'vault/helpers/dasherize';
* label: "Foo", // custom label to be shown, otherwise attr.name will be displayed
* defaultValue: "", // default value to display if model value is not present
* fieldValue: "bar", // used for value lookup on model over attr.name
* editType: "ttl", type of field to use -- example boolean, searchSelect, etc.
* editType: "ttl", type of field to use. List of editTypes:boolean, file, json, kv, optionalText, mountAccessor, password, radio, regex, searchSelect, stringArray,textarea, ttl, yield.
* helpText: "This will be in a tooltip",
* readOnly: true
* },
@ -58,7 +58,7 @@ export default class FormFieldComponent extends Component {
return this.args.disabled || false;
}
get showHelpText() {
return this.args.showHelpText || true;
return this.args.showHelpText === false ? false : true;
}
get subText() {
return this.args.subText || '';

View file

@ -0,0 +1,48 @@
<div class="column is-narrow is-flex-center has-text-grey has-right-margin-s has-top-margin-negative-s">
<RadioButton
class="radio"
name="ttl"
@value="ttl"
@onChange={{this.onRadioButtonChange}}
@groupValue={{this.groupValue}}
/>
<label class="has-left-margin-xs">
<TtlPicker2
data-test-input="ttl"
@onChange={{this.setAndBroadcastTtl}}
@label="TTL"
@helperTextEnabled={{@attr.options.helperTextEnabled}}
@description={{@attr.helpText}}
@time={{this.ttlTime}}
@unit="d"
@hideToggle={{true}}
/>
</label>
</div>
<div class="column is-narrow is-flex-center has-text-grey has-right-margin-s">
<RadioButton
class="radio"
name="not_after"
@value="specificDate"
@onChange={{this.onRadioButtonChange}}
@groupValue={{this.groupValue}}
/>
<label class="has-left-margin-xs">
<span class="ttl-picker-label is-large">Specific date</span><br />
<p class="sub-text">
This value format should be given in UTC format YYYY-MM-ddTHH:MM:SSZ.
</p>
{{#if (eq this.groupValue "specificDate")}}
<input
data-test-input="not_after"
id="not_after"
autocomplete="off"
spellcheck="false"
value={{this.notAfter}}
{{on "input" this.setAndBroadcastInput}}
class="input"
maxLength="21"
/>
{{/if}}
</label>
</div>

View file

@ -0,0 +1,47 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
/**
* @module RadioSelectTtlOrString
* `RadioSelectTtlOrString` components are yielded out within the formField component when the editType on the model is yield.
* The component is two radio buttons, where the first option is a TTL, and the second option is an input field without a title.
* This component is used in the PKI engine inside various forms.
*
* @example
* ```js
* {{#each @model.fields as |attr|}}
* <RadioSelectTtlOrString @attr={{attr}} @model={{this.model}} />
* {{/each}}
* ```
* @param {Model} model - Ember Data model that `attr` is defined on.
* @param {Object} attr - Usually derived from ember model `attributes` lookup, and all members of `attr.options` are optional.
*/
export default class RadioSelectTtlOrString extends Component {
@tracked groupValue = 'ttl';
@tracked ttlTime;
@tracked notAfter;
@action onRadioButtonChange(selection) {
this.groupValue = selection;
// Clear the previous selection if they have clicked the other radio button.
if (selection === 'specificDate') {
this.args.model.set('ttl', '');
this.ttlTime = ''; //clear out the form field
}
if (selection === 'tll') {
this.args.model.set('notAfter', '');
this.notAfter = ''; //clear out the form field
}
}
@action setAndBroadcastTtl(value) {
let valueToSet = value.enabled === true ? `${value.seconds}s` : 0;
this.args.model.set('ttl', `${valueToSet}`);
}
@action setAndBroadcastInput(event) {
this.args.model.set('notAfter', event.target.value);
}
}

View file

@ -0,0 +1 @@
export { default } from 'core/components/form-field-groups-loop';

View file

@ -0,0 +1 @@
export { default } from 'core/components/radio-select-ttl-or-string';

View file

@ -0,0 +1,60 @@
<PageHeader as |p|>
<p.top>
<KeyValueHeader
@root={{hash label="role" text="role" path="vault.cluster.secrets.backend.pki.roles.index"}}
@isEngine={{true}}
>
<li>
<span class="sep">
/
</span>
<LinkTo @route="roles.index">
{{@model.backend}}
</LinkTo>
</li>
</KeyValueHeader>
</p.top>
<p.levelLeft>
<h1 class="title is-3">
{{#if @model.isNew}}
Create a PKI role
{{else}}
Edit a
{{@model.id}}
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
{{! ARG TODO write a test for namespace reminder }}
<NamespaceReminder @mode={{if @model.isNew "create" "update"}} @noun="PKI role" />
<FormFieldGroupsLoop
@model={{@model}}
@mode={{if @model.isNew "create" "update"}}
@modelValidations={{@modelValidations}}
@showHelpText={{false}}
as |attr|
>
<RadioSelectTtlOrString @model={{@model}} @attr={{attr}} />
</FormFieldGroupsLoop>
</div>
<div class="has-top-padding-s">
<button type="submit" class="button is-primary {{if this.save.isRunning 'is-loading'}}" disabled={{this.save.isRunning}}>
{{if @model.isNew "Create" "Update"}}
</button>
<button type="button" class="button has-left-margin-s" disabled={{this.save.isRunning}} {{on "click" this.cancel}}>
Cancel
</button>
{{#if this.modelValidations.targets.errors}}
<AlertInline @type="danger" @message={{join ", " this.modelValidations.targets.errors}} @paddingTop={{true}} />
{{/if}}
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormAlert}} @mimicRefresh={{true}} />
</div>
{{/if}}
</div>
</form>

View file

@ -0,0 +1,56 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
/**
* @module PkiRoleForm
* PkiRoleForm components are used to create and update PKI roles.
*
* @example
* ```js
* <PkiRoleForm @model={{this.model}}/>
* ```
* @callback onCancel
* @callback onSave
* @param {Object} model - Pki-role-engine model.
* @param {onCancel} onCancel - Callback triggered when cancel button is clicked.
* @param {onSave} onSave - Callback triggered on save success.
*/
export default class PkiRoleForm extends Component {
@service store;
@service flashMessages;
@tracked errorBanner;
@tracked invalidFormAlert;
@tracked modelValidations;
@task
*save(event) {
event.preventDefault();
try {
const { isValid, state, invalidFormMessage } = this.args.model.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
if (isValid) {
const { isNew, name } = this.args.model;
yield this.args.model.save();
this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the role ${name}.`);
this.args.onSave();
}
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.errorBanner = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
@action
cancel() {
const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
this.args.model[method]();
this.args.onCancel();
}
}

View file

@ -1,3 +0,0 @@
import Route from '@ember/routing/route';
export default class PkiRolesRoute extends Route {}

View file

@ -0,0 +1,18 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class PkiRolesCreateRoute extends Route {
@service store;
@service secretMountPath;
@service pathHelp;
beforeModel() {
return this.pathHelp.getNewModel('pki/pki-role-engine', 'pki');
}
model() {
return this.store.createRecord('pki/pki-role-engine', {
backend: this.secretMountPath.currentPath,
});
}
}

View file

@ -6,12 +6,17 @@ export default class RolesIndexRoute extends Route {
@service secretMountPath;
@service pathHelp;
model() {
// the pathHelp service is needed for adding openAPI to the model
this.pathHelp.getNewModel('pki/pki-role-engine', 'pki');
beforeModel() {
// Must call this promise before the model hook otherwise it doesn't add OpenApi to record.
return this.pathHelp.getNewModel('pki/pki-role-engine', 'pki');
}
model() {
return this.store
.query('pki/pki-role-engine', { backend: this.secretMountPath.currentPath })
.then((roleModel) => {
return { roleModel, parentModel: this.modelFor('roles') };
})
.catch((err) => {
if (err.httpStatus === 404) {
return [];

View file

@ -1,11 +0,0 @@
<SecretListHeader
@model={{this.model}}
@backendCrumb={{hash
label=this.model.id
text=this.model.id
path="vault.cluster.secrets.backend.list-root"
model=this.model.id
}}
@isEngine={{true}}
/>
{{outlet}}

View file

@ -0,0 +1,5 @@
<PkiRoleForm
@model={{this.model}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.roles.index"}}
@onSave={{transition-to "vault.cluster.secrets.backend.pki.roles.role.details" this.model.id}}
/>

View file

@ -1,3 +1,13 @@
<SecretListHeader
@model={{this.model.parentModel}}
@backendCrumb={{hash
label=this.model.parentModel.id
text=this.model.parentModel.id
path="vault.cluster.secrets.backend.list-root"
model=this.model.parentModel.id
}}
@isEngine={{true}}
/>
<Toolbar>
<ToolbarActions>
<ToolbarLink @type="add" @params={{array "roles.create"}}>
@ -6,8 +16,8 @@
</ToolbarActions>
</Toolbar>
{{#if (gt this.model.length 0)}}
{{#each this.model as |pkiRole|}}
{{#if (gt this.model.roleModel.length 0)}}
{{#each this.model.roleModel as |pkiRole|}}
<LinkedBlock class="list-item-row" @params={{array "roles.role.details" pkiRole.id}} @linkPrefix={{this.mountPoint}}>
<div class="level is-mobile">
<div class="level-left">