UI/ PKI UI Redesign (#12541)

* installs node-forge

* correctly displays and formats cert metadata

* removes labels

* uses helper in hbs file

* adds named arg to helper

* pki-ca-cert displays common name, issue & expiry date

* alphabetizes some attrs

* adds test for date helper
This commit is contained in:
claire bontempo 2021-10-04 14:31:36 -07:00 committed by GitHub
parent b84100d4a0
commit 42ae96ed1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 35465 additions and 48 deletions

3
changelog/12541.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
ui: parse and display pki cert metadata
```

View file

@ -1,3 +1,4 @@
import { parsePkiCert } from '../helpers/parse-pki-cert';
import ApplicationAdapter from './application'; import ApplicationAdapter from './application';
export default ApplicationAdapter.extend({ export default ApplicationAdapter.extend({
@ -41,7 +42,14 @@ export default ApplicationAdapter.extend({
} }
response.id = snapshot.id; response.id = snapshot.id;
response.modelName = type.modelName; response.modelName = type.modelName;
// only parse if certificate is attached to response
if (response.data && response.data.certificate) {
const caCertMetadata = parsePkiCert([response.data]);
const transformedResponse = { ...response, ...caCertMetadata };
store.pushPayload(type.modelName, transformedResponse);
} else {
store.pushPayload(type.modelName, response); store.pushPayload(type.modelName, response);
}
}); });
}, },

View file

@ -0,0 +1,20 @@
import { helper } from '@ember/component/helper';
import { pki } from 'node-forge';
export function parsePkiCert([model]) {
// model has to be the responseJSON from PKI serializer
if (!model.certificate) {
return;
}
const cert = pki.certificateFromPem(model.certificate);
const commonName = cert.subject.getField('CN') ? cert.subject.getField('CN').value : null;
const issueDate = cert.validity.notBefore;
const expiryDate = cert.validity.notAfter;
return {
common_name: commonName,
issue_date: issueDate,
expiry_date: expiryDate,
};
}
export default helper(parsePkiCert);

View file

@ -4,12 +4,15 @@ import { computed } from '@ember/object';
import Certificate from './pki-certificate'; import Certificate from './pki-certificate';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
// TODO: alphabetize attrs
export default Certificate.extend({ export default Certificate.extend({
DISPLAY_FIELDS: computed(function() { DISPLAY_FIELDS: computed(function() {
return [ return [
'csr', 'csr',
'certificate', 'certificate',
'expiration', 'commonName',
'issueDate',
'expiryDate',
'issuingCa', 'issuingCa',
'caChain', 'caChain',
'privateKey', 'privateKey',
@ -17,30 +20,33 @@ export default Certificate.extend({
'serialNumber', 'serialNumber',
]; ];
}), }),
addBasicConstraints: attr('boolean', {
label: 'Add a Basic Constraints extension with CA: true',
helpText:
'Only needed as a workaround in some compatibility scenarios with Active Directory Certificate Services',
}),
backend: attr('string', { backend: attr('string', {
readOnly: true, readOnly: true,
}), }),
caType: attr('string', { caType: attr('string', {
possibleValues: ['root', 'intermediate'], possibleValues: ['root', 'intermediate'],
defaultValue: 'root', defaultValue: 'root',
label: 'CA Type', label: 'CA Type',
readOnly: true, readOnly: true,
}), }),
uploadPemBundle: attr('boolean', { commonName: attr('string'),
label: 'Upload PEM bundle', expiryDate: attr('string', {
readOnly: true, label: 'Expiration date',
}), }),
issueDate: attr('string'),
pemBundle: attr('string', { pemBundle: attr('string', {
label: 'PEM bundle', label: 'PEM bundle',
editType: 'file', editType: 'file',
}), }),
addBasicConstraints: attr('boolean', { uploadPemBundle: attr('boolean', {
label: 'Add a Basic Constraints extension with CA: true', label: 'Upload PEM bundle',
helpText: readOnly: true,
'Only needed as a workaround in some compatibility scenarios with Active Directory Certificate Services',
}), }),
fieldDefinition: computed('caType', 'uploadPemBundle', function() { fieldDefinition: computed('caType', 'uploadPemBundle', function() {
const type = this.caType; const type = this.caType;
const isUpload = this.uploadPemBundle; const isUpload = this.uploadPemBundle;
@ -92,7 +98,6 @@ export default Certificate.extend({
return groups; return groups;
}), }),
type: attr('string', { type: attr('string', {
possibleValues: ['internal', 'exported'], possibleValues: ['internal', 'exported'],
defaultValue: 'internal', defaultValue: 'internal',
@ -145,7 +150,6 @@ export default Certificate.extend({
label: 'CSR', label: 'CSR',
masked: true, masked: true,
}), }),
expiration: attr(),
deletePath: lazyCapabilities(apiPath`${'backend'}/root`, 'backend'), deletePath: lazyCapabilities(apiPath`${'backend'}/root`, 'backend'),
canDeleteRoot: and('deletePath.canDelete', 'deletePath.canSudo'), canDeleteRoot: and('deletePath.canDelete', 'deletePath.canSudo'),

View file

@ -17,27 +17,30 @@ export default Model.extend({
DISPLAY_FIELDS: computed(function() { DISPLAY_FIELDS: computed(function() {
return [ return [
'certificate', 'certificate',
'commonName',
'issuingCa', 'issuingCa',
'caChain', 'caChain',
'privateKey', 'privateKey',
'privateKeyType', 'privateKeyType',
'serialNumber',
'revocationTime', 'revocationTime',
'issueDate',
'expiryDate',
'serialNumber',
]; ];
}), }),
commonName: attr('string'),
expiryDate: attr('string', {
label: 'Expiration date',
}),
issueDate: attr('string'),
role: attr('object', { role: attr('object', {
readOnly: true, readOnly: true,
}), }),
revocationTime: attr('number'), revocationTime: attr('number'),
commonName: attr('string', {
label: 'Common Name',
}),
altNames: attr('string', { altNames: attr('string', {
label: 'DNS/Email Subject Alternative Names (SANs)', label: 'DNS/Email Subject Alternative Names (SANs)',
}), }),
ipSans: attr('string', { ipSans: attr('string', {
label: 'IP Subject Alternative Names (SANs)', label: 'IP Subject Alternative Names (SANs)',
}), }),
@ -47,22 +50,18 @@ export default Model.extend({
helpText: helpText:
'The format is the same as OpenSSL: <oid>;<type>:<value> where the only current valid type is UTF8', 'The format is the same as OpenSSL: <oid>;<type>:<value> where the only current valid type is UTF8',
}), }),
ttl: attr({ ttl: attr({
label: 'TTL', label: 'TTL',
editType: 'ttl', editType: 'ttl',
}), }),
format: attr('string', { format: attr('string', {
defaultValue: 'pem', defaultValue: 'pem',
possibleValues: ['pem', 'der', 'pem_bundle'], possibleValues: ['pem', 'der', 'pem_bundle'],
}), }),
excludeCnFromSans: attr('boolean', { excludeCnFromSans: attr('boolean', {
label: 'Exclude Common Name from Subject Alternative Names (SANs)', label: 'Exclude Common Name from Subject Alternative Names (SANs)',
defaultValue: false, defaultValue: false,
}), }),
certificate: attr('string', { certificate: attr('string', {
masked: true, masked: true,
}), }),

View file

@ -2,6 +2,7 @@ import RESTSerializer from '@ember-data/serializer/rest';
import { isNone, isBlank } from '@ember/utils'; import { isNone, isBlank } from '@ember/utils';
import { assign } from '@ember/polyfills'; import { assign } from '@ember/polyfills';
import { decamelize } from '@ember/string'; import { decamelize } from '@ember/string';
import { parsePkiCert } from '../helpers/parse-pki-cert';
export default RESTSerializer.extend({ export default RESTSerializer.extend({
keyForAttribute: function(attr) { keyForAttribute: function(attr) {
@ -41,7 +42,14 @@ export default RESTSerializer.extend({
normalizeResponse(store, primaryModelClass, payload, id, requestType) { normalizeResponse(store, primaryModelClass, payload, id, requestType) {
const responseJSON = this.normalizeItems(payload); const responseJSON = this.normalizeItems(payload);
const { modelName } = primaryModelClass; const { modelName } = primaryModelClass;
let transformedPayload = { [modelName]: responseJSON }; let transformedPayload, certMetadata;
// hits cert/list endpoint first which returns an array, only want to parse if response is not an array
if (!Array.isArray(responseJSON)) {
certMetadata = parsePkiCert([responseJSON]);
transformedPayload = { [modelName]: { ...certMetadata, ...responseJSON } };
} else {
transformedPayload = { [modelName]: responseJSON };
}
return this._super(store, primaryModelClass, transformedPayload, id, requestType); return this._super(store, primaryModelClass, transformedPayload, id, requestType);
}, },

View file

@ -23,6 +23,8 @@
.column { .column {
align-self: center; align-self: center;
padding-left: 0px; padding-left: 0px;
padding: $spacing-m;
&.info-table-row-edit { &.info-table-row-edit {
padding-bottom: 0.3rem; padding-bottom: 0.3rem;
padding-top: 0.3rem; padding-top: 0.3rem;

View file

@ -10,25 +10,23 @@
{{#if (or model.certificate model.csr)}} {{#if (or model.certificate model.csr)}}
{{#each model.attrs as |attr|}} {{#each model.attrs as |attr|}}
{{#if attr.options.masked}} {{#if attr.options.masked}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}}> <InfoTableRow data-test-table-row
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get model attr.name}}>
<MaskedInput <MaskedInput
@value={{get model attr.name}} @value={{get model attr.name}}
@displayOnly={{true}} @displayOnly={{true}}
@allowCopy={{true}} @allowCopy={{true}}
/> />
</InfoTableRow> </InfoTableRow>
{{else if (eq attr.name "expiration")}} {{else if (and (get model attr.name) (or (eq attr.name "issueDate") (eq attr.name "expiryDate")))}}
{{info-table-row <InfoTableRow data-test-table-row={{value}}
data-test-table-row @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) @value={{date-format (get model attr.name) 'MMM dd, yyyy hh:mm:ss a' isFormatted=true}}/>
value=(date-format (get model attr.name) 'MMM dd, yyyy hh:mm:ss a')
}}
{{else}} {{else}}
{{info-table-row <InfoTableRow data-test-table-row={{value}}
data-test-table-row @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) @value={{get model attr.name}}/>
value=(get model attr.name)
}}
{{/if}} {{/if}}
{{/each}} {{/each}}
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
@ -94,15 +92,24 @@
/> />
{{#each model.attrs as |attr|}} {{#each model.attrs as |attr|}}
{{#if attr.options.masked}} {{#if attr.options.masked}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}}> <InfoTableRow
data-test-table-row={{value}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get model attr.name}}>
<MaskedInput <MaskedInput
@value={{get model attr.name}} @value={{get model attr.name}}
@displayOnly={{true}} @displayOnly={{true}}
@allowCopy={{true}} @allowCopy={{true}}
/> />
</InfoTableRow> </InfoTableRow>
{{else if (and (get model attr.name) (or (eq attr.name "issueDate") (eq attr.name "expiryDate")))}}
<InfoTableRow data-test-table-row={{value}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{date-format (get model attr.name) 'MMM dd, yyyy hh:mm:ss a' isFormatted=true}}/>
{{else}} {{else}}
{{info-table-row data-test-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} <InfoTableRow data-test-table-row={{value}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get model attr.name}}/>
{{/if}} {{/if}}
{{/each}} {{/each}}
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">

View file

@ -13,22 +13,33 @@
<MessageError @model={{model}} /> <MessageError @model={{model}} />
{{#each model.attrs as |attr|}} {{#each model.attrs as |attr|}}
{{#if (eq attr.type "object")}} {{#if (eq attr.type "object")}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{stringify (get model attr.name)}} /> <InfoTableRow data-test-table-row
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{stringify (get model attr.name)}} />
{{else}} {{else}}
{{#if attr.options.masked}} {{#if attr.options.masked}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}}> <InfoTableRow data-test-table-row
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get model attr.name}}>
<MaskedInput <MaskedInput
@value={{get model attr.name}} @value={{get model attr.name}}
@displayOnly={{true}} @displayOnly={{true}}
@allowCopy={{true}} @allowCopy={{true}}
/> />
</InfoTableRow> </InfoTableRow>
{{else if (and (get model attr.name) (or (eq attr.name "issueDate") (eq attr.name "expiryDate")))}}
<InfoTableRow data-test-table-row
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{date-format (get model attr.name) 'MMM dd, yyyy hh:mm:ss a' isFormatted=true}} />
{{else}} {{else}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}} /> <InfoTableRow data-test-table-row
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get model attr.name}} />
{{/if}} {{/if}}
{{/if}} {{/if}}
{{/each}} {{/each}}
</div> </div>
<div class="field is-grouped is-grouped-split box is-fullwidth is-bottomless"> <div class="field is-grouped is-grouped-split box is-fullwidth is-bottomless">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">

View file

@ -1,8 +1,11 @@
import { helper } from '@ember/component/helper'; import { helper } from '@ember/component/helper';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
export function dateFormat([date, style]) { export function dateFormat([date, style], { isFormatted = false }) {
// see format breaking in upgrade to date-fns 2.x https://github.com/date-fns/date-fns/blob/master/CHANGELOG.md#changed-5 // see format breaking in upgrade to date-fns 2.x https://github.com/date-fns/date-fns/blob/master/CHANGELOG.md#changed-5
if (isFormatted) {
return format(new Date(date), style);
}
let number = typeof date === 'string' ? parseISO(date) : date; let number = typeof date === 'string' ? parseISO(date) : date;
if (!number) { if (!number) {
return; return;

35326
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -134,6 +134,7 @@
"ivy-codemirror": "IvyApp/ivy-codemirror#fb09333c5144da47e14a9e6260f80577d5408374", "ivy-codemirror": "IvyApp/ivy-codemirror#fb09333c5144da47e14a9e6260f80577d5408374",
"jsonlint": "^1.6.3", "jsonlint": "^1.6.3",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"node-forge": "^0.10.0",
"node-sass": "^4.10.0", "node-sass": "^4.10.0",
"normalize.css": "4.1.1", "normalize.css": "4.1.1",
"pretender": "^3.4.3", "pretender": "^3.4.3",

View file

@ -105,7 +105,9 @@ module('Acceptance | Enterprise | Transform secrets', function(hooks) {
await clickTrigger('#allowed_roles'); await clickTrigger('#allowed_roles');
await settled(); await settled();
await typeInSearch(roleName); await typeInSearch(roleName);
await settled();
await selectChoose('#allowed_roles', '.ember-power-select-option', 0); await selectChoose('#allowed_roles', '.ember-power-select-option', 0);
await settled();
await transformationsPage.submit(); await transformationsPage.submit();
await settled(); await settled();
assert.equal( assert.equal(

View file

@ -76,14 +76,18 @@ BXUV2Uwtxf+QCphnlht9muX2fsLIzDJea0JipWj1uf2H8OZsjE8=
await page.form.generateCA(); await page.form.generateCA();
await settled(); await settled();
assert.ok(page.form.rows.length > 0, 'shows all of the rows');
// TODO come back and figure out why not working after upgrade. I see it, it's a timing issue. assert.ok(page.form.commonNameIsPresent, 'the common name displays');
// assert.ok(page.form.certificateIsPresent, 'the certificate is included'); assert.ok(page.form.issueDateIsPresent, 'the issue date displays');
assert.ok(page.form.expiryDateIsPresent, 'the expiration date displays');
assert
.dom('[data-test-value-div="Certificate"] [data-test-masked-input]')
.exists('certificate is present');
await page.form.back(); await page.form.back();
await settled();
await page.form.generateCA(); await page.form.generateCA();
await settled(); await settled();
assert.ok( assert.ok(
page.flash.latestMessage.includes('You tried to generate a new root CA'), page.flash.latestMessage.includes('You tried to generate a new root CA'),
'shows warning message' 'shows warning message'

View file

@ -1,6 +1,7 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit'; import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers'; import { render } from '@ember/test-helpers';
import { format } from 'date-fns';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
module('Integration | Helper | date-format', function(hooks) { module('Integration | Helper | date-format', function(hooks) {
@ -42,4 +43,14 @@ module('Integration | Helper | date-format', function(hooks) {
await render(hbs`<p data-test-date-format>Date: {{date-format tenDigitDate "MM/dd/yyyy"}}</p>`); await render(hbs`<p data-test-date-format>Date: {{date-format tenDigitDate "MM/dd/yyyy"}}</p>`);
assert.dom('[data-test-date-format]').includesText('05/23/2021'); assert.dom('[data-test-date-format]').includesText('05/23/2021');
}); });
test('it supports already formatted dates', async function(assert) {
let formattedDate = new Date();
this.set('formattedDate', formattedDate);
await render(
hbs`<p data-test-date-format>Date: {{date-format formattedDate 'MMMM dd, yyyy hh:mm:ss a' isFormatted=true}}</p>`
);
assert.dom('[data-test-date-format]').includesText(format(formattedDate, 'MMMM dd, yyyy hh:mm:ss a'));
});
}); });

View file

@ -27,12 +27,15 @@ export default {
csr: text('[data-test-row-value="CSR"]', { normalize: false }), csr: text('[data-test-row-value="CSR"]', { normalize: false }),
csrField: fillable('[data-test-input="csr"]'), csrField: fillable('[data-test-input="csr"]'),
certificate: text('[data-test-row-value="Certificate"]', { normalize: false }), certificate: text('[data-test-row-value="Certificate"]', { normalize: false }),
certificateIsPresent: isPresent('[data-test-row-value="Certificate"]'), commonNameIsPresent: isPresent('[data-test-row-value="Common name"]'),
uploadCert: clickable('[data-test-input="uploadPemBundle"]'), uploadCert: clickable('[data-test-input="uploadPemBundle"]'),
enterCertAsText: clickable('[data-test-text-toggle]'), enterCertAsText: clickable('[data-test-text-toggle]'),
pemBundle: fillable('[data-test-text-file-textarea="true"]'), pemBundle: fillable('[data-test-text-file-textarea="true"]'),
commonName: fillable('[data-test-input="commonName"]'), commonName: fillable('[data-test-input="commonName"]'),
issueDateIsPresent: text('[data-test-row-value="Issue date"]'),
expiryDateIsPresent: text('[data-test-row-value="Expiration date"]'),
async generateCA(commonName = 'PKI CA', type = 'root') { async generateCA(commonName = 'PKI CA', type = 'root') {
if (type === 'intermediate') { if (type === 'intermediate') {
return await this.replaceCA() return await this.replaceCA()

View file

@ -13701,6 +13701,11 @@ node-fetch@^2.6.1:
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
node-forge@^0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==
node-gyp@^3.8.0: node-gyp@^3.8.0:
version "3.8.0" version "3.8.0"
resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c"