PKI role non-default options (#17393)

* 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

* hide tooltips

* pass through sub text to stringArray

* Add conditional for keybits and keyType

* set defaults for keyBits ... 🤮

* fix some small issues

* more info form field typ

* show only label and subText

* wip context switch 🤮

* fix dontShowLabel

* getting css grid setup

* more on flex groups

* adding the second chunk to key usage

* serialize the post for key_usage

* finish for ext_key_usage

* clean up

* fix snack_case issue

* commit for working state, next trying to remove form-field-group-loops because it's causing issues.

* remove usage of formfieldgroupsloop because of issues with css grid and conditionals

* clean up

* remove string-list helpText changes for tooltip removal because that should be it's own pr.

* clarification from design and backend.

* small cleanup

* pull key_usage and ext_key_usage out of the model and into a component

* clean up

* clean up

* restructure css grid:

* clean up

* broke some things

* fix error when roles list returned 404

* claires feedback

* cleanup

* clean up
This commit is contained in:
Angel Garbarino 2022-10-12 11:56:05 -07:00 committed by GitHub
parent adc23f0a77
commit 0dbfa4bf66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 511 additions and 44 deletions

View File

@ -12,6 +12,7 @@ export default class PkiRoleEngineAdapter extends ApplicationAdapter {
}
return url;
}
_optionsForQuery(id) {
let data = {};
if (!id) {
@ -21,8 +22,8 @@ export default class PkiRoleEngineAdapter extends ApplicationAdapter {
}
createRecord(store, type, snapshot) {
let name = snapshot.attr('name');
let url = this._urlForRole(snapshot.record.backend, name);
const name = snapshot.attr('name');
const url = this._urlForRole(snapshot.record.backend, name);
return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then(() => {
return {
@ -35,6 +36,7 @@ export default class PkiRoleEngineAdapter extends ApplicationAdapter {
fetchByQuery(store, query) {
const { id, backend } = query;
return this.ajax(this._urlForRole(backend, id), 'GET', this._optionsForQuery(id)).then((resp) => {
const data = {
id,

View File

@ -13,6 +13,7 @@ const validations = {
export default class PkiRoleEngineModel extends Model {
@attr('string', { readOnly: true }) backend;
/* Overriding OpenApi default options */
@attr('string', {
label: 'Role name',
fieldValue: 'name',
@ -41,7 +42,7 @@ export default class PkiRoleEngineModel extends Model {
'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,
defaultValue: '30s', // type in API is duration which accepts both an integer and string e.g. 30 || '30s'
defaultValue: '30s', // The API type is "duration" which accepts both an integer and string e.g. 30 || '30s'
})
notBeforeDuration;
@ -72,11 +73,173 @@ export default class PkiRoleEngineModel extends Model {
noStore;
@attr('boolean', {
label: 'Basic constraints valid for non CA.',
label: 'Basic constraints valid for non-CA',
subText: 'Mark Basic Constraints valid when issuing non-CA certificates.',
editType: 'boolean',
})
addBasicConstraints;
/* End of overriding default options */
/* Overriding OpenApi Domain handling options */
@attr({
label: 'Allowed domains',
subText: 'Specifies the domains this role is allowed to issue certificates for. Add one item per row.',
editType: 'stringArray',
hideFormSection: true,
})
allowedDomains;
@attr('boolean', {
label: 'Allow templates in allowed domains',
})
allowedDomainsTemplate;
/* End of overriding Domain handling options */
/* Overriding OpenApi Key parameters options */
@attr('string', {
label: 'Key type',
possibleValues: ['rsa', 'ec', 'ed25519', 'any'],
defaultValue: 'rsa',
})
keyType;
@attr('string', {
label: 'Key bits',
})
keyBits;
// "possibleValues" for the field "keyBits" depends on the value of the selected "keyType"
get keyBitsConditional() {
const keyBitOptions = {
rsa: [2048, 3072, 4096],
ec: [256, 224, 384, 521],
ed25519: [0],
any: [0],
};
const attrs = expandAttributeMeta(this, ['keyBits']);
attrs[0].options['possibleValues'] = keyBitOptions[this.keyType];
return attrs[0];
}
@attr('number', {
label: 'Signature bits',
subText: `Only applicable for key_type 'RSA'. Ignore for other key types.`,
defaultValue: 0,
possibleValues: [
{
value: 0,
displayName: 'Defaults to 0',
},
{
value: 256,
displayName: '256 for SHA-2-256',
},
{
value: 384,
displayName: '384 for SHA-2-384',
},
{
value: 512,
displayName: '512 for SHA-2-5124',
},
],
})
signatureBits;
/* End of overriding Key parameters options */
/* Overriding API Policy identifier option */
@attr({
label: 'Policy identifiers',
subText: 'A comma-separated string or list of policy object identifiers (OIDs). Add one per row. ',
editType: 'stringArray',
hideFormSection: true,
})
policyIdentifiers;
/* End of overriding Policy identifier options */
/* Overriding OpenApi SAN options */
@attr('boolean', {
label: 'Allow IP SANs',
subText: 'Specifies if clients can request IP Subject Alternative Names.',
editType: 'boolean',
defaultValue: true,
})
allowIpSans;
@attr({
label: 'URI Subject Alternative Names (URI SANs)',
subText: 'Defines allowed URI Subject Alternative Names. Add one item per row',
editType: 'stringArray',
docLink: '/docs/concepts/policies',
hideFormSection: true,
})
allowedUriSans;
@attr('boolean', {
label: 'Allow URI SANs template',
subText: 'If true, the URI SANs above may contain templates, as with ACL Path Templating.',
editType: 'boolean',
docLink: '/docs/concepts/policies',
})
allowUriSansTemplate;
@attr({
label: 'Other SANs',
subText: 'Defines allowed custom OID/UTF8-string SANs. Add one item per row.',
editType: 'stringArray',
hideFormSection: true,
})
allowedOtherSans;
/* End of overriding SAN options */
/* Overriding OpenApi Additional subject field options */
@attr({
label: 'Allowed serial numbers',
subText:
'A list of allowed serial numbers to be requested during certificate issuance. Shell-style globbing is supported. If empty, custom-specified serial numbers will be forbidden.',
editType: 'stringArray',
hideFormSection: true,
})
allowedSerialNumbers;
@attr('boolean', {
label: 'Require common name',
subText: 'If set to false, common name will be optional when generating a certificate.',
defaultValue: true,
})
requireCn;
@attr('boolean', {
label: 'Use CSR common name',
subText:
'When used with the CSR signing endpoint, the common name in the CSR will be used instead of taken from the JSON data.',
defaultValue: true,
})
useCsrCommonName;
@attr('boolean', {
label: 'Use CSR SANs',
subText:
'When used with the CSR signing endpoint, the subject alternate names in the CSR will be used instead of taken from the JSON data.',
defaultValue: true,
})
useCsrSans;
@attr({
label: 'Organization Units (OU)',
subText:
'A list of allowed serial numbers to be requested during certificate issuance. Shell-style globbing is supported. If empty, custom-specified serial numbers will be forbidden.',
hideFormSection: true,
})
ou;
@attr({ hideFormSection: true }) organization;
@attr({ hideFormSection: true }) country;
@attr({ hideFormSection: true }) locality;
@attr({ hideFormSection: true }) province;
@attr({ hideFormSection: true }) streetAddress;
@attr({ hideFormSection: true }) postalCode;
/* End of overriding Additional subject field options */
// 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() {
@ -85,6 +248,7 @@ export default class PkiRoleEngineModel extends Model {
getHelpUrl(backend) {
return `/v1/${backend}/roles/example?help=1`;
}
@lazyCapabilities(apiPath`${'backend'}/roles/${'id'}`, 'backend', 'id') updatePath;
get canDelete() {
return this.updatePath.get('canCreate');
@ -110,18 +274,36 @@ export default class PkiRoleEngineModel extends Model {
return this.signVerbatimPath.get('canUpdate');
}
// Form Fields not hidden in toggle options
_attributeMeta = null;
get formFields() {
if (!this._attributeMeta) {
this._attributeMeta = expandAttributeMeta(this, ['name', 'clientType', 'redirectUris']);
}
return this._attributeMeta;
_fieldToAttrsGroups = null;
// Gets header/footer copy for specific toggle groups.
get fieldGroupsInfo() {
return {
'Domain handling': {
footer: {
text: 'These options can interact intricately with one another. For more information,',
docText: 'learn more here.',
docLink: '/api-docs/secret/pki#allowed_domains',
},
},
'Key parameters': {
header: {
text: `These are the parameters for generating or validating the certificate's key material.`,
},
},
'Subject Alternative Name (SAN) Options': {
header: {
text: `Subject Alternative Names (SANs) are identities (domains, IP addresses, and URIs) Vault attaches to the requested certificates.`,
},
},
'Additional subject fields': {
header: {
text: `Additional identity metadata Vault can attach to the requested certificates.`,
},
},
};
}
// Form fields hidden behind toggle options
_fieldToAttrsGroups = null;
// ARG TODO: I removed 'allowedDomains' but I'm fairly certain it needs to be somewhere. Confirm with design.
get fieldGroups() {
if (!this._fieldToAttrsGroups) {
this._fieldToAttrsGroups = fieldToAttrs(this, [
@ -140,34 +322,34 @@ export default class PkiRoleEngineModel extends Model {
{
'Domain handling': [
'allowedDomains',
'allowedDomainTemplate',
'allowedDomainsTemplate',
'allowBareDomains',
'allowSubdomains',
'allowGlobDomains',
'allowWildcardCertificates',
'allowLocalhost',
'allowLocalhost', // default: true (returned true by OpenApi)
'allowAnyName',
'enforceHostnames',
'enforceHostnames', // default: true (returned true by OpenApi)
],
},
{
'Key parameters': ['keyType', 'keyBits', 'signatureBits'],
},
{
'Key usage': [
'DigitalSignature', // ARG TODO: capitalized in the docs, but should confirm
'KeyAgreement',
'KeyEncipherment',
'extKeyUsage', // ARG TODO: takes a list, but we have these as checkboxes from the options on the golang site: https://pkg.go.dev/crypto/x509#ExtKeyUsage
],
'Key usage': ['keyUsage', 'extKeyUsage'],
},
{ 'Policy identifiers': ['policyIdentifiers'] },
{
'Subject Alternative Name (SAN) Options': ['allowIpSans', 'allowedUriSans', 'allowedOtherSans'],
'Subject Alternative Name (SAN) Options': [
'allowIpSans',
'allowedUriSans',
'allowUriSansTemplate',
'allowedOtherSans',
],
},
{
'Additional subject fields': [
'allowed_serial_numbers',
'allowedSerialNumbers',
'requireCn',
'useCsrCommonName',
'useCsrSans',

View File

@ -1,3 +1,3 @@
import RoleSerializer from '../role';
import ApplicationSerializer from '../application';
export default class PkiRoleEngineSerializer extends RoleSerializer {}
export default class PkiRoleEngineSerializer extends ApplicationSerializer {}

View File

@ -101,6 +101,24 @@
display: flex;
justify-content: space-between !important;
}
/* CSS GRID */
// grid container
.is-grid {
display: grid;
}
.is-grid-3-columns {
grid-template-columns: repeat(3, 1fr);
}
.is-medium-height {
height: $medium-height;
}
// grid items
.is-grid-column-span-3 {
grid-column-end: span 3;
}
/* END OF CSS GRID */
.has-default-border {
border: 1px solid $grey !important;
}

View File

@ -91,3 +91,6 @@ $speed-slow: $speed * 2;
// Wizard
$wizard-progress-bar-height: 6px;
$wizard-progress-check-size: 16px;
// Div height
$medium-height: 125px;

View File

@ -0,0 +1,17 @@
import Component from '@glimmer/component';
/**
* @module FormFieldGroupsLoop
* FormFieldGroupsLoop components loop through the "groups" set on a model and display them either as default or behind toggle components.
*
* @example
* ```js
<FormFieldGroupsLoop @model={{this.model}} @mode={{if @model.isNew "create" "update"}}/>
* ```
* @param {class} model - The routes model class.
* @param {string} mode - "create" or "update" used to hide the name form field. TODO: not ideal, would prefer to disable it to follow new design patterns.
* @param {function} [modelValidations] - Passed through to formField.
* @param {boolean} [showHelpText] - Passed through to formField.
*/
/* eslint ember/no-empty-glimmer-component-classes: 'warn' */
export default class FormFieldGroupsLoop extends Component {}

View File

@ -9,7 +9,7 @@
)
)
}}
{{#unless (eq @attr.type "object")}}
{{#if (not (eq @attr.type "object"))}}
<FormFieldLabel
for={{@attr.name}}
@label={{this.labelString}}
@ -17,7 +17,7 @@
@subText={{@attr.options.subText}}
@docLink={{@attr.options.docLink}}
/>
{{/unless}}
{{/if}}
{{/unless}}
{{#if @attr.options.possibleValues}}
{{#if (eq @attr.options.editType "radio")}}
@ -71,7 +71,7 @@
<label for={{@attr.name}} class="is-label">
{{this.labelString}}
{{#if @attr.options.helpText}}
{{#if (and this.showHelpText @attr.options.helpText)}}
<InfoTooltip>{{@attr.options.helpText}}</InfoTooltip>
{{/if}}
</label>
@ -204,10 +204,12 @@
data-test-input={{@attr.name}}
@label={{this.labelString}}
@warning={{@attr.options.warning}}
@helpText={{@attr.options.helpText}}
@helpText={{if this.showHelpText @attr.options.helpText}}
@inputValue={{get @model this.valuePath}}
@onChange={{this.setAndBroadcast}}
@attrName={{@attr.name}}
@subText={{@attr.options.subText}}
@hideFormSection={{@attr.options.hideFormSection}}
/>
{{else if (eq @attr.options.sensitive true)}}
{{! Masked Input }}

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. List of editTypes:boolean, file, json, kv, optionalText, mountAccessor, password, radio, regex, searchSelect, stringArray,textarea, ttl, yield.
* 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
* },

View File

@ -1,5 +1,5 @@
<div
class="field string-list form-section"
class={{concat "field string-list" (if @hideFormSection "" " form-section")}}
data-test-component="string-list"
{{did-insert this.autoSize}}
{{did-update this.autoSizeUpdate}}
@ -7,12 +7,17 @@
...attributes
>
{{#if @label}}
<label class="title is-label" data-test-string-list-label="true">
<label class="is-label" data-test-string-list-label="true">
{{@label}}
{{#if this.helpText}}
<InfoTooltip>{{this.helpText}}</InfoTooltip>
{{/if}}
</label>
{{#if @subText}}
<p class="sub-text">
{{@subText}}
</p>
{{/if}}
{{/if}}
{{#if @warning}}
<AlertBanner @type="warning" @message={{@warning}} />

View File

@ -18,6 +18,8 @@ import { set } from '@ember/object';
* @param {string} helpText - Text displayed as a tooltip.
* @param {string} type=array - Optional type for inputValue.
* @param {string} attrName - We use this to check the type so we can modify the tooltip content.
* @param {string} subText - Text below the label.
* @param {boolean} hideFormSection - If true do not add form-section class on surrounding div.
*/
export default class StringList extends Component {

View File

@ -0,0 +1,78 @@
{{#let (camelize (concat "show" @group)) as |prop|}}
<ToggleButton
@isOpen={{get @model prop}}
@openLabel={{concat "Hide " @group}}
@closedLabel={{@group}}
@onClick={{fn (mut (get @model prop))}}
class="is-block"
data-test-toggle-group={{@group}}
/>
{{#if (get @model prop)}}
<div class="box is-tall is-marginless">
<FormFieldLabel
for="keyUsageLabel"
@label="Key usage"
@subText="Specifies the default key usage constraint on the issued certificate. To specify no default key_usage constraints, uncheck every item in this list."
/>
<div class="is-grid is-grid-3-columns is-medium-height">
{{! KEY USAGE SECTION }}
{{#each-in this.keyUsageFields as |name attr|}}
<div class="field">
<div class="b-checkbox">
<input
type="checkbox"
id={{name}}
class="styled"
checked={{attr.value}}
onchange={{fn this.checkboxChange "keyUsage"}}
data-test-input={{name}}
/>
<label for={{name}} class="is-label">
{{attr.label}}
</label>
</div>
</div>
{{/each-in}}
</div>
<div class="has-top-margin-s">
<FormFieldLabel
for="ExtKeyUsageLabel"
@label="Extended key usage"
@subText="Specifies the default key usage constraint on the issued certificate. To specify no default ext_key_usage constraints, uncheck every item in this list."
/>
</div>
{{! EXT KEY USAGE SECTION }}
<div class="is-grid is-grid-3-columns is-medium-height">
{{#each-in this.extKeyUsageFields as |name attr|}}
<div class="field">
<div class="b-checkbox">
<input
type="checkbox"
id={{name}}
class="styled"
checked={{attr.value}}
onchange={{fn this.checkboxChange "extKeyUsage"}}
data-test-input={{name}}
/>
<label for={{name}} class="is-label">
{{attr.label}}
</label>
</div>
</div>
{{/each-in}}
</div>
<div class="has-top-margin-xxl">
<StringList
data-test-input="extKeyUsageOids"
@label="Extended key usage oids"
@inputValue={{get @model "extKeyUsageOids"}}
@onChange={{this.onStringListChange}}
@attrName="extKeyUsageOids"
@subText="A list of extended key usage oids. Add one item per row."
@showHelpText={{false}}
@hideFormSection={{true}}
/>
</div>
</div>
{{/if}}
{{/let}}

View File

@ -0,0 +1,93 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
/**
* @module PkiKeyUsage
* PkiKeyUsage components are used to build out the toggle options for PKI's role create/update key_usage, ext_key_usage and ext_key_usage_oids model params.
* Instead of having the user search on the following goLang pages for these options we present them in checkbox form and manually add them to the params as an array of strings.
* key_usage options: https://pkg.go.dev/crypto/x509#KeyUsage
* ext_key_usage options (not all are include on purpose): https://pkg.go.dev/crypto/x509#ExtKeyUsage
* @example
* ```js
* <PkiKeyUsage @model={@model} @group={group}/>
* ```
* @param {class} model - The pki/pki-role-engine model.
* @param {string} group - The name of the group created in the model. In this case, it's the "Key usage" group.
*/
const KEY_USAGE_FIELDS = {
DigitalSignature: {
label: 'Digital Signature',
value: true,
},
ContentCommitment: { label: 'Content Commitment' },
CrlSign: { label: 'CRL Sign' },
KeyAgreement: {
label: 'Key Agreement',
value: true,
},
DataEncipherment: { label: 'Data Encipherment' },
EncipherOnly: { label: 'Encipher Only' },
KeyEncipherment: {
label: 'Key Encipherment',
value: true,
},
CertSign: { label: 'Cert Sign' },
DecipherOnly: { label: 'Decipher Only' },
};
const EXT_KEY_USAGE_FIELDS = {
Any: { label: 'Any' },
EmailProtection: { label: 'Email Protection' },
TimeStamping: { label: 'Time Stamping' },
ServerAuth: { label: 'Server Auth' },
IpsecEndSystem: { label: 'IPSEC End System' },
OcspSigning: { label: 'OCSP Signing' },
ClientAuth: { label: 'Client Auth' },
IpsecTunnel: { label: 'IPSEC Tunnel' },
IpsecUser: { label: 'IPSEC User' },
CodeSigning: { label: 'Code Signing' },
};
export default class PkiKeyUsage extends Component {
constructor() {
super(...arguments);
this.keyUsageFields = {};
this.extKeyUsageFields = {};
Object.assign(this.keyUsageFields, KEY_USAGE_FIELDS);
Object.assign(this.extKeyUsageFields, EXT_KEY_USAGE_FIELDS);
}
@action onStringListChange(value) {
this.args.model.set('extKeyUsageOids', value);
}
_amendList(checkboxName, value, type) {
let keyUsageList = this.args.model.keyUsage;
let extKeyUsageList = this.args.model.extKeyUsage;
/* Process:
1. We first check if the checkbox change is coming from the checkbox options of key_usage or ext_key_usage.
// Param key_usage || ext_key_usage accept a comma separated string and an array of strings. E.g. "DigitalSignature,KeyAgreement,KeyEncipherment" || [“DigitalSignature”,“KeyAgreement”,“KeyEncipherment”]
2. Then we convert the string to an array if it's not already an array (e.g. it's already been converted). This makes it easier to add or remove items.
3. Then if the value of checkbox is "true" we add it to the arrayList, otherwise remove it.
*/
if (type === 'keyUsage') {
let keyUsageListArray = Array.isArray(keyUsageList) ? keyUsageList : keyUsageList.split(',');
return value ? keyUsageListArray.addObject(checkboxName) : keyUsageListArray.removeObject(checkboxName);
} else {
// because there is no default on init for ext_key_usage property (set normally by OpenAPI) we define it as an empty array if it is undefined.
let extKeyUsageListArray = !extKeyUsageList ? [] : extKeyUsageList;
return value
? extKeyUsageListArray.addObject(checkboxName)
: extKeyUsageListArray.removeObject(checkboxName);
}
}
@action checkboxChange(type) {
const checkboxName = event.target.id;
const value = event.target['checked'];
this.args.model.set(type, this._amendList(checkboxName, value, type));
}
}

View File

@ -31,15 +31,79 @@
<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>
{{#each @model.fieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
{{! DEFAULT VIEW }}
{{#if (eq group "default")}}
{{#each fields as |attr|}}
<FormField
data-test-field={{true}}
@attr={{attr}}
@model={{@model}}
@modelValidations={{this.modelValidations}}
@showHelpText={{false}}
>
<RadioSelectTtlOrString @attr={{attr}} @model={{@model}} />
</FormField>
{{/each}}
{{else if (eq group "Key usage")}}
<PkiKeyUsage @model={{@model}} @group={{group}} />
{{else}}
{{! Groups hidden behind Toggles }}
{{#let (camelize (concat "show" group)) as |prop|}}
<ToggleButton
@isOpen={{get @model prop}}
@openLabel={{concat "Hide " group}}
@closedLabel={{group}}
@onClick={{fn (mut (get @model prop))}}
class="is-block"
data-test-toggle-group={{group}}
/>
{{#if (get @model prop)}}
<div class="box is-marginless">
{{#let (get @model.fieldGroupsInfo group) as |groupInfo|}}
{{! Header }}
{{#if groupInfo.header}}
<div class="has-bottom-margin-s">
<FormFieldLabel
for={{groupInfo.header.name}}
@label={{groupInfo.header.label}}
@subText={{groupInfo.header.text}}
@docLink={{groupInfo.header.docLink}}
/>
</div>
{{/if}}
{{! Fields }}
{{#each fields as |attr|}}
<FormField
data-test-field={{true}}
@attr={{if (eq attr.name "keyBits") @model.keyBitsConditional attr}}
@model={{@model}}
@modelValidations={{@modelValidations}}
@showHelpText={{false}}
>
{{yield attr}}
</FormField>
{{/each}}
{{! Footer }}
{{#if groupInfo.footer}}
<p class="sub-text">
<Icon @name="info" />
{{groupInfo.footer.text}}
{{#if groupInfo.footer.docLink}}
<DocLink @path={{groupInfo.footer.docLink}}>
{{groupInfo.footer.docText}}
</DocLink>
{{/if}}
</p>
{{/if}}
{{/let}}
</div>
{{/if}}
{{/let}}
{{/if}}
{{/each-in}}
{{/each}}
</div>
<div class="has-top-padding-s">
<button type="submit" class="button is-primary {{if this.save.isRunning 'is-loading'}}" disabled={{this.save.isRunning}}>

View File

@ -19,7 +19,7 @@ export default class RolesIndexRoute extends Route {
})
.catch((err) => {
if (err.httpStatus === 404) {
return [];
return { parentModel: this.modelFor('roles') };
} else {
throw err;
}

View File

@ -0,0 +1 @@
export { default } from 'pki/components/pki-key-usage';