UI/obscure secret on input (#11284)

* new font and add as font-family to be used in masked-input

* clean up logic

* refactor for displayOnly

* start cert masking

* work on certificates

* upload cert work

* fix global styling

* fix styling for class no longer used

* make mask by default and remove option

* glimmerize start and certificate on LDAP a file field

* glimmerize actions

* first part of glimmerizing text-file still need to do some clean up

* not doing awesome over here

* getting ready to un-glimmer

* unglimmerize

* remove placeholder based on conversations with design

* clean up text-file

* cleanup

* fix class bindings

* handle class binding

* set up for test

* fix elementId

* track down index

* update masked-input test

* add more to the masked-input test

* test-file test

* fix broken test

* clear old style

* clean up

* remove pgp key masked font, this really needs to be refactored to text-file component

* changelog

* cover other certificate view

* add allowCopy

* address some pr styling comments

* improve test coverage

* fix some issues

* add attr.options.masked
This commit is contained in:
Angel Garbarino 2021-04-22 08:58:37 -06:00 committed by GitHub
parent 29d91d09ff
commit 2e35e9578c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 339 additions and 244 deletions

3
changelog/11284.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Obscure secret values on input and displayOnly fields like certificates.
```

View File

@ -1,84 +1,84 @@
import Component from '@ember/component'; import Component from '@glimmer/component';
import { set } from '@ember/object'; import { set } from '@ember/object';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { guidFor } from '@ember/object/internals';
export default Component.extend({ /**
'data-test-component': 'text-file', * @module TextFile
classNames: ['box', 'is-fullwidth', 'is-marginless', 'is-shadowless'], * `TextFile` components are file upload components where you can either toggle to upload a file or enter text.
classNameBindings: ['inputOnly:is-paddingless'], *
* @example
* <TextFile
* @inputOnly={{true}}
* @helpText="help text"
* @file={{object}}
* @onChange={{action "someOnChangeFunction"}}
* @label={{"string"}}
* />
*
* @param [inputOnly] {bool} - When true, only the file input will be rendered
* @param [helpText] {string} - Text underneath label.
* @param file {object} - * Object in the shape of:
* {
* value: 'file contents here',
* fileName: 'nameOfFile.txt',
* enterAsText: boolean ability to enter as text
* }
* @param [onChange=Function.prototype] {Function|action} - A function to call when the value of the input changes.
* @param [label=null] {string} - Text to use as the label for the file input. If null, a default will be rendered.
*/
/* export default class TextFile extends Component {
* @public fileHelpText = 'Select a file from your computer';
* @param Object textareaHelpText = 'Enter the value as text';
* Object in the shape of: elementId = guidFor(this);
* { index = '';
* value: 'file contents here',
* fileName: 'nameOfFile.txt',
* enterAsText: bool
* }
*/
file: null,
index: null, @tracked file = null;
onChange: () => {}, @tracked showValue = false;
/* get inputOnly() {
* @public return this.args.inputOnly || false;
* @param Boolean }
* When true, only the file input will be rendered get label() {
*/ return this.args.label || null;
inputOnly: false, }
/*
* @public
* @param String
* Text to use as the label for the file input
* If null, a default will be rendered
*/
label: null,
/*
* @public
* @param String
* Text to use as help under the file input
* If null, a default will be rendered
*/
fileHelpText: 'Select a file from your computer',
/*
* @public
* @param String
* Text to use as help under the textarea in text-input mode
* If null, a default will be rendered
*/
textareaHelpText: 'Enter the value as text',
readFile(file) { readFile(file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => this.setFile(reader.result, file.name); reader.onload = () => this.setFile(reader.result, file.name);
reader.readAsText(file); reader.readAsText(file);
}, }
setFile(contents, filename) { setFile(contents, filename) {
this.onChange(this.index, { value: contents, fileName: filename }); this.args.onChange(this.index, { value: contents, fileName: filename });
}, }
actions: { @action
pickedFile(e) { pickedFile(e) {
const { files } = e.target; e.preventDefault();
if (!files.length) { const { files } = e.target;
return; if (!files.length) {
} return;
for (let i = 0, len = files.length; i < len; i++) { }
this.readFile(files[i]); for (let i = 0, len = files.length; i < len; i++) {
} this.readFile(files[i]);
}, }
updateData(e) { }
const file = this.file; @action
set(file, 'value', e.target.value); updateData(e) {
this.onChange(this.index, this.file); e.preventDefault();
}, let file = this.args.file;
clearFile() { set(file, 'value', e.target.value);
this.onChange(this.index, { value: '' }); this.args.onChange(this.index, file);
}, }
}, @action
}); clearFile() {
this.args.onChange(this.index, { value: '' });
}
@action
toggleMask() {
this.showValue = !this.showValue;
}
}

View File

@ -9,7 +9,7 @@ export default AuthConfig.extend({
useOpenAPI: true, useOpenAPI: true,
certificate: attr({ certificate: attr({
label: 'Certificate', label: 'Certificate',
editType: 'textarea', editType: 'file',
}), }),
fieldGroups: computed('newFields', function() { fieldGroups: computed('newFields', function() {
let groups = [ let groups = [

View File

@ -143,6 +143,7 @@ export default Certificate.extend({
csr: attr('string', { csr: attr('string', {
editType: 'textarea', editType: 'textarea',
label: 'CSR', label: 'CSR',
masked: true,
}), }),
expiration: attr(), expiration: attr(),

View File

@ -63,14 +63,20 @@ export default Model.extend({
defaultValue: false, defaultValue: false,
}), }),
certificate: attr('string'), certificate: attr('string', {
masked: true,
}),
issuingCa: attr('string', { issuingCa: attr('string', {
label: 'Issuing CA', label: 'Issuing CA',
masked: true,
}), }),
caChain: attr('string', { caChain: attr('string', {
label: 'CA chain', label: 'CA chain',
masked: true,
}),
privateKey: attr('string', {
masked: true,
}), }),
privateKey: attr('string'),
privateKeyType: attr('string'), privateKeyType: attr('string'),
serialNumber: attr('string'), serialNumber: attr('string'),

View File

@ -1,3 +1,12 @@
@import 'ember-basic-dropdown'; @import 'ember-basic-dropdown';
@import 'ember-power-select'; @import 'ember-power-select';
@import './core'; @import './core';
@mixin font-face($name) {
@font-face {
font-family: $name;
src: url('/ui/fonts/#{$name}.woff2') format('woff2'), url('/ui/fonts/#{$name}.woff') format('woff');
}
}
@include font-face('obscure');

View File

@ -1,13 +1,12 @@
.masked-font {
color: $ui-gray-300;
}
.masked-input { .masked-input {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.masked-input.masked.display-only,
.masked-input:not(.masked) {
align-items: start;
}
.has-label .masked-input { .has-label .masked-input {
padding-top: $spacing-s; padding-top: $spacing-s;
} }
@ -91,10 +90,6 @@
color: $grey-light; color: $grey-light;
} }
.masked-input:not(.masked) .masked-input-toggle {
color: $blue;
}
.masked-input .input:focus + .masked-input-toggle { .masked-input .input:focus + .masked-input-toggle {
background: rgba($white, 0.95); background: rgba($white, 0.95);
} }

View File

@ -0,0 +1,14 @@
.text-file {
.has-icon-right {
display: flex;
width: 97%;
.textarea {
line-height: inherit;
}
}
.button.masked-input-toggle,
.button.copy-button {
display: flex;
}
}

View File

@ -99,6 +99,7 @@
@import './components/splash-page'; @import './components/splash-page';
@import './components/status-menu'; @import './components/status-menu';
@import './components/tabs'; @import './components/tabs';
@import './components/text-file';
@import './components/token-expire-warning'; @import './components/token-expire-warning';
@import './components/toolbar'; @import './components/toolbar';
@import './components/tool-tip'; @import './components/tool-tip';

View File

@ -11,6 +11,13 @@ label {
} }
} }
.masked-font {
font-family: obscure;
font-size: $size-medium;
letter-spacing: 2px;
color: $ui-gray-300;
}
.label { .label {
color: $grey-darker; color: $grey-darker;
text-transform: uppercase; text-transform: uppercase;

View File

@ -9,7 +9,27 @@
</h2> </h2>
{{#if (or model.certificate model.csr)}} {{#if (or model.certificate model.csr)}}
{{#each model.attrs as |attr|}} {{#each model.attrs as |attr|}}
{{info-table-row data-test-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} {{#if attr.options.masked}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}}>
<MaskedInput
@value={{get model attr.name}}
@displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
{{else if (eq attr.name "expiration")}}
{{info-table-row
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')
}}
{{else}}
{{info-table-row
data-test-table-row
label=(capitalize (or attr.options.label (humanize (dasherize attr.name))))
value=(get model attr.name)
}}
{{/if}}
{{/each}} {{/each}}
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control"> <div class="control">
@ -70,7 +90,17 @@
data-test-warning data-test-warning
/> />
{{#each model.attrs as |attr|}} {{#each model.attrs as |attr|}}
{{info-table-row data-test-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} {{#if attr.options.masked}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}}>
<MaskedInput
@value={{get model attr.name}}
@displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
{{else}}
{{info-table-row data-test-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}}
{{/if}}
{{/each}} {{/each}}
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control"> <div class="control">

View File

@ -49,12 +49,14 @@
(eq attr.name "secretKey") (eq attr.name "secretKey")
(eq attr.name "securityToken") (eq attr.name "securityToken")
(eq attr.name "privateKey") (eq attr.name "privateKey")
attr.options.masked
)}} )}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}}> <InfoTableRow @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}}
@name={{attr.name}} @name={{attr.name}}
@displayOnly={{true}} @displayOnly={{true}}
@allowCopy={{true}}
/> />
</InfoTableRow> </InfoTableRow>
{{else}} {{else}}

View File

@ -15,7 +15,17 @@
{{#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 @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{stringify (get model attr.name)}} />
{{else}} {{else}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}} /> {{#if attr.options.masked}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}}>
<MaskedInput
@value={{get model attr.name}}
@displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
{{else}}
<InfoTableRow @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{get model attr.name}} />
{{/if}}
{{/if}} {{/if}}
{{/each}} {{/each}}
</div> </div>

View File

@ -1,13 +1,13 @@
{{#unless inputOnly}} {{#unless this.inputOnly}}
<div class="level is-mobile"> <div class="level is-mobile">
<div class="level-left"> <div class="level-left">
<label class="is-label" data-test-text-label=true> <label class="is-label" data-test-text-label=true>
{{#if label}} {{#if this.label}}
{{label}} {{this.label}}
{{#if helpText}} {{#if @helpText}}
<InfoTooltip> <InfoTooltip>
<span data-test-help-text> <span data-test-help-text>
{{helpText}} {{@helpText}}
</span> </span>
</InfoTooltip> </InfoTooltip>
{{/if}} {{/if}}
@ -20,50 +20,57 @@
<div class="control is-flex"> <div class="control is-flex">
<input <input
data-test-text-toggle=true data-test-text-toggle=true
id={{concat "useText-" elementId}} id={{concat "useText-" this.elementId}}
type="checkbox" type="checkbox"
name={{concat "useText-" elementId}} name={{concat "useText-" this.elementId}}
class="switch is-rounded is-success is-small" class="switch is-rounded is-success is-small"
checked={{file.enterAsText}} checked={{@file.enterAsText}}
onchange={{action (toggle "enterAsText" file)}} {{on 'change' (toggle "enterAsText" @file)}}
/> />
<label for={{concat "useText-" elementId}}> <label for={{concat "useText-" this.elementId}}>
Enter as text Enter as text
</label> </label>
</div> </div>
</div> </div>
</div> </div>
{{/unless}} {{/unless}}
<div class="field"> <div class="field text-file box is-fullwidth is-marginless is-shadowless {{if this.inputOnly "is-paddingless"}}" data-test-component="text-file">
{{#if file.enterAsText}} {{#if @file.enterAsText}}
<div class="control"> <div class="control has-icon-right">
<textarea <textarea
class="textarea" class="textarea {{unless showValue "masked-font"}}"
oninput={{action "updateData"}} {{on 'input' this.updateData}}
data-test-text-file-textarea=true data-test-text-file-textarea=true
>{{file.value}}</textarea> >{{@file.value}}</textarea>
<button
{{on 'click' this.toggleMask}}
type="button"
class="{{if (eq value "") "has-text-grey"}} masked-input-toggle button {{if displayOnly "is-compact"}}"
data-test-button>
<Icon @glyph={{if showValue "visibility-show" "visibility-hide"}} aria-hidden="true" />
</button>
</div> </div>
<p class="help has-text-grey"> <p class="help has-text-grey">
{{textareaHelpText}} {{this.textareaHelpText}}
</p> </p>
{{else}} {{else}}
<div class="control is-expanded"> <div class="control is-expanded">
<div class="file has-name is-fullwidth"> <div class="file has-name is-fullwidth">
<label class="file-label"> <label class="file-label">
<input class="file-input" type="file" onchange={{action "pickedFile"}} data-test-text-file-input=true> <input class="file-input" type="file" {{on 'change' this.pickedFile}} data-test-text-file-input=true>
<span class="file-cta button"> <span class="file-cta button">
<Icon @glyph="upload" class="has-light-grey-text" /> <Icon @glyph="upload" class="has-light-grey-text" />
Choose a file… Choose a file…
</span> </span>
<span class="file-name has-text-grey-dark" data-test-text-file-input-label=true> <span class="file-name has-text-grey-dark" data-test-text-file-input-label=true>
{{#if file.fileName}} {{#if @file.fileName}}
{{file.fileName}} {{@file.fileName}}
{{else}} {{else}}
No file chosen No file chosen
{{/if}} {{/if}}
</span> </span>
{{#if file.fileName}} {{#if @file.fileName}}
<button type="button" class="file-delete-button" {{action 'clearFile'}} data-test-text-clear=true> <button type="button" class="file-delete-button" {{on 'click' this.clearFile}} data-test-text-clear=true>
<Icon @glyph="cancel-circle-outline" /> <Icon @glyph="cancel-circle-outline" />
</button> </button>
{{/if}} {{/if}}
@ -71,7 +78,7 @@
</div> </div>
</div> </div>
<p class="help has-text-grey"> <p class="help has-text-grey">
{{fileHelpText}} {{this.fileHelpText}}
</p> </p>
{{/if}} {{/if}}
</div> </div>

View File

@ -5,13 +5,20 @@
Public key Public key
</label> </label>
<div class="control"> <div class="control">
<Textarea @name="publicKey" @id="publicKey" class="textarea" @value={{model.publicKey}} @readonly={{true}} data-test-ssh-input="public-key" /> <MaskedInput
@name="publickey"
@id="publicKey"
@value={{model.publicKey}}
@displayOnly={{true}}
@allowCopy={{true}}
data-test-ssh-input="public-key"
/>
</div> </div>
</div> </div>
</div> </div>
<div class="field is-grouped-split box is-fullwidth is-bottomless"> <div class="field is-grouped-split box is-fullwidth is-bottomless">
<div class="control"> <div class="control">
<CopyButton @clipboardTarget="#publicKey" @class="button is-primary" @buttonType="button" @success={{action (set-flash-message "Public Key copied!")}}> <CopyButton @clipboardText={{model.publicKey}} @class="button is-primary" @buttonType="button" @success={{action (set-flash-message "Public Key copied!")}}>
Copy Copy
</CopyButton> </CopyButton>
</div> </div>

View File

@ -46,7 +46,7 @@
</div> </div>
</div> </div>
{{#if showFileUpload}} {{#if showFileUpload}}
<TextFile @inputOnly={{true}} @index="" @file={{file}} @onChange={{action "setPolicyFromFile"}} /> <TextFile @inputOnly={{true}} @file={{file}} @onChange={{action "setPolicyFromFile"}} />
{{else}} {{else}}
<IvyCodemirror @value={{model.policy}} @id="policy" @valueUpdated={{action (mut model.policy)}} @options={{hash <IvyCodemirror @value={{model.policy}} @id="policy" @valueUpdated={{action (mut model.policy)}} @options={{hash
lineNumbers=true lineNumbers=true

View File

@ -67,6 +67,7 @@ module.exports = function(environment) {
ENV.contentSecurityPolicy = { ENV.contentSecurityPolicy = {
'connect-src': ["'self'"], 'connect-src': ["'self'"],
'img-src': ["'self'", 'data:'], 'img-src': ["'self'", 'data:'],
'font-src': ["'self'"],
'form-action': ["'none'"], 'form-action': ["'none'"],
'script-src': ["'self'"], 'script-src': ["'self'"],
'style-src': ["'unsafe-inline'", "'self'"], 'style-src': ["'unsafe-inline'", "'self'"],

View File

@ -1,5 +1,4 @@
import Component from '@ember/component'; import Component from '@ember/component';
import { computed } from '@ember/object';
import autosize from 'autosize'; import autosize from 'autosize';
import layout from '../templates/components/masked-input'; import layout from '../templates/components/masked-input';
@ -10,24 +9,21 @@ import layout from '../templates/components/masked-input';
* @example * @example
* <MaskedInput * <MaskedInput
* @value={{attr.options.defaultValue}} * @value={{attr.options.defaultValue}}
* @placeholder="secret"
* @allowCopy={{true}} * @allowCopy={{true}}
* @onChange={{action "someAction"}} * @onChange={{action "someAction"}}
* /> * />
* *
* @param [value] {String} - The value to display in the input. * @param [value] {String} - The value to display in the input.
* @param [placeholder=value] {String} - The placeholder to display before the user has entered any input.
* @param [allowCopy=null] {bool} - Whether or not the input should render with a copy button. * @param [allowCopy=null] {bool} - Whether or not the input should render with a copy button.
* @param [displayOnly=false] {bool} - Whether or not to display the value as a display only `pre` element or as an input. * @param [displayOnly=false] {bool} - Whether or not to display the value as a display only `pre` element or as an input.
* @param [onChange=Function.prototype] {Function|action} - A function to call when the value of the input changes. * @param [onChange=Function.prototype] {Function|action} - A function to call when the value of the input changes.
* * @param [isCertificate=false] {bool} - If certificate display the label and icons differently.
* *
*/ */
export default Component.extend({ export default Component.extend({
layout, layout,
value: null, value: null,
placeholder: 'value', showValue: false,
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
autosize(this.element.querySelector('textarea')); autosize(this.element.querySelector('textarea'));
@ -40,30 +36,12 @@ export default Component.extend({
this._super(...arguments); this._super(...arguments);
autosize.destroy(this.element.querySelector('textarea')); autosize.destroy(this.element.querySelector('textarea'));
}, },
shouldObscure: computed('isMasked', 'isFocused', 'value', function() {
if (this.value === '') {
return false;
}
if (this.isFocused === true) {
return false;
}
return this.isMasked;
}),
displayValue: computed('shouldObscure', 'value', function() {
if (this.shouldObscure) {
return '■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■';
} else {
return this.value;
}
}),
isMasked: true,
isFocused: false,
displayOnly: false, displayOnly: false,
onKeyDown() {}, onKeyDown() {},
onChange() {}, onChange() {},
actions: { actions: {
toggleMask() { toggleMask() {
this.toggleProperty('isMasked'); this.toggleProperty('showValue');
}, },
updateValue(e) { updateValue(e) {
let value = e.target.value; let value = e.target.value;

View File

@ -4,6 +4,12 @@ import { format, parseISO } from 'date-fns';
export function dateFormat([date, style]) { export function dateFormat([date, style]) {
// 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
let number = typeof date === 'string' ? parseISO(date) : date; let number = typeof date === 'string' ? parseISO(date) : date;
if (!number) {
return;
}
if (number.toString().length === 10) {
number = new Date(number * 1000);
}
return format(number, style); return format(number, style);
} }

View File

@ -110,12 +110,9 @@
{{else if (eq attr.options.editType "file")}} {{else if (eq attr.options.editType "file")}}
{{!-- File Input --}} {{!-- File Input --}}
{{text-file {{text-file
index="" helpText=attr.options.helpText
fileHelpText=attr.options.helpText
textareaHelpText=attr.options.helpText
file=file file=file
onChange=(action "setFile") onChange=(action "setFile")
warning=attr.options.warning
label=labelString label=labelString
}} }}
{{else if (eq attr.options.editType "ttl")}} {{else if (eq attr.options.editType "ttl")}}
@ -175,8 +172,11 @@
}} }}
{{else if (eq attr.options.sensitive true)}} {{else if (eq attr.options.sensitive true)}}
{{!-- Masked Input --}} {{!-- Masked Input --}}
<MaskedInput @value={{or (get model valuePath) attr.options.defaultValue}} @placeholder="" @allowCopy="true" <MaskedInput
@onChange={{action (action "setAndBroadcast" valuePath)}} @maskWhileTyping={{if (eq attr.name "bindpass") true}}/> @value={{or (get model valuePath) attr.options.defaultValue}}
@allowCopy="true"
@onChange={{action (action "setAndBroadcast" valuePath)}}
/>
{{else if (or (eq attr.type "number") (eq attr.type "string"))}} {{else if (or (eq attr.type "number") (eq attr.type "string"))}}
<div class="control"> <div class="control">
{{#if (eq attr.options.editType "textarea")}} {{#if (eq attr.options.editType "textarea")}}

View File

@ -1,35 +1,41 @@
<div class="masked-input {{if shouldObscure "masked"}} {{if displayOnly "display-only"}} {{if allowCopy "allow-copy"}}"
<div class="masked-input {{if displayOnly "display-only"}} {{if allowCopy "allow-copy"}}"
data-test-masked-input data-test-field> data-test-masked-input data-test-field>
{{#if displayOnly}} {{#if displayOnly}}
<pre class="masked-value display-only is-word-break">{{displayValue}}</pre> <pre class="masked-value display-only is-word-break {{unless showValue "masked-font"}}">{{unless showValue (truncate value 20) value}}</pre>
{{else if maskWhileTyping}} {{else if inputField}}
<input <input
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
type={{if isMasked "password" "text"}}
value={{value}} value={{value}}
class="input {{unless showValue "masked-font"}}"
onchange={{action "updateValue"}} onchange={{action "updateValue"}}
class="input"
data-test-input data-test-input
/> />
{{else}} {{else}}
<textarea class="input masked-value" rows=1 wrap="off" placeholder={{placeholder}} <textarea
onfocus={{action (mut isFocused) true}} onblur={{action (mut isFocused) false}} onkeydown={{action onKeyDown}} class="input masked-value {{unless showValue "masked-font"}}"
onchange={{action "updateValue"}} value={{readonly displayValue}} data-test-textarea /> rows=1 wrap="off"
onkeydown={{action onKeyDown}}
onchange={{action "updateValue"}}
value={{value}}
data-test-textarea
/>
{{/if}} {{/if}}
{{#if allowCopy}} {{#if allowCopy}}
<CopyButton <CopyButton
@clipboardText={{value}} @clipboardText={{value}}
@success={{action (set-flash-message 'Data copied!')}} @success={{action (set-flash-message 'Data copied!')}}
class="copy-button button {{if displayOnly "is-compact"}}" class="copy-button button {{if displayOnly "is-compact"}}"
data-test-copy-button> data-test-copy-button>
<Icon @glyph="copy-action" aria-hidden="Copy value" /> <Icon @glyph="copy-action" aria-hidden="Copy value" />
</CopyButton> </CopyButton>
{{/if}} {{/if}}
<button {{action "toggleMask"}} <button
onclick={{action "toggleMask"}}
type="button" type="button"
class="{{if (eq value "") "has-text-grey"}} masked-input-toggle button {{if displayOnly "is-compact"}}" class="{{if (eq value "") "has-text-grey"}} masked-input-toggle button"
data-test-button> data-test-button>
<Icon @glyph={{if shouldObscure "visibility-hide" "visibility-show"}} aria-hidden="true" /> <Icon @glyph={{if showValue "visibility-show" "visibility-hide"}} aria-hidden="true" />
</button> </button>
</div> </div>

View File

@ -19,10 +19,13 @@
</Toolbar> </Toolbar>
{{#if model}} {{#if model}}
<FieldGroupShow @model={{model}} /> <FieldGroupShow @model={{model}} />
<InfoTableRow <InfoTableRow @label="CA PEM" @value={{model.ca.caPem}}>
@label="CA PEM" <MaskedInput
@value={{model.ca.caPem}} @value={{model.ca.caPem}}
/> @displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
{{else}} {{else}}
<EmptyState <EmptyState
@title="No configuration for this secrets engine" @title="No configuration for this secrets engine"

View File

@ -51,10 +51,13 @@
</ToolbarActions> </ToolbarActions>
</Toolbar> </Toolbar>
<div class="box is-shadowless is-fullwidth is-sideless"> <div class="box is-shadowless is-fullwidth is-sideless">
<InfoTableRow <InfoTableRow @label="Serial number" @value={{model.id}}>
@label="Serial number" <MaskedInput
@value={{model.id}} @value={{model.id}}
/> @displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
<InfoTableRow <InfoTableRow
@label="Private key" @label="Private key"
@value={{model.privateKey}} @value={{model.privateKey}}
@ -74,17 +77,24 @@
/> />
</div> </div>
</InfoTableRow> </InfoTableRow>
<InfoTableRow <InfoTableRow @label="Certificate" @value={{model.certificate}}>
@label="Certificate" <MaskedInput
@value={{model.certificate}} @value={{model.certificate}}
/> @displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
<InfoTableRow <InfoTableRow
@label="CA Chain" @label="CA Chain"
@value={{model.caChain}} @value={{model.caChain}}
> >
<div class="is-block"> <div class="is-block">
{{#each model.caChain as |chain|}} {{#each model.caChain as |chain|}}
<code class="is-block is-word-break has-text-black">{{chain}}</code> <MaskedInput
@value={{chain}}
@displayOnly={{true}}
@allowCopy={{true}}
/>
{{/each}} {{/each}}
</div> </div>
</InfoTableRow> </InfoTableRow>

BIN
ui/public/fonts/obscure.woff (Stored with Git LFS) Normal file

Binary file not shown.

BIN
ui/public/fonts/obscure.woff2 (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -1,10 +1,9 @@
import { currentRouteName, settled } from '@ember/test-helpers'; import { currentRouteName, settled, click } from '@ember/test-helpers';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
import editPage from 'vault/tests/pages/secrets/backend/pki/edit-role'; import editPage from 'vault/tests/pages/secrets/backend/pki/edit-role';
import listPage from 'vault/tests/pages/secrets/backend/list'; import listPage from 'vault/tests/pages/secrets/backend/list';
import generatePage from 'vault/tests/pages/secrets/backend/pki/generate-cert'; import generatePage from 'vault/tests/pages/secrets/backend/pki/generate-cert';
import showPage from 'vault/tests/pages/secrets/backend/pki/show';
import configPage from 'vault/tests/pages/settings/configure-secret-backends/pki/section-cert'; import configPage from 'vault/tests/pages/settings/configure-secret-backends/pki/section-cert';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend'; import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
import authPage from 'vault/tests/pages/auth'; import authPage from 'vault/tests/pages/auth';
@ -56,7 +55,11 @@ elRplAzrMF4=
await settled(); await settled();
await generatePage.issueCert('foo'); await generatePage.issueCert('foo');
await settled(); await settled();
assert.ok(generatePage.hasCert, 'displays the cert'); let countMaskedFonts = document.querySelectorAll('.masked-font').length;
assert.equal(countMaskedFonts, 3); // certificate, issuing ca, and private key
let firstUnMaskButton = document.querySelectorAll('.masked-input-toggle')[0];
await click(firstUnMaskButton);
assert.dom('.masked-value').hasTextContaining('-----BEGIN CERTIFICATE-----');
await settled(); await settled();
await generatePage.back(); await generatePage.back();
await settled(); await settled();
@ -68,7 +71,9 @@ elRplAzrMF4=
await settled(); await settled();
await generatePage.sign('common', CSR); await generatePage.sign('common', CSR);
await settled(); await settled();
assert.ok(generatePage.hasCert, 'displays the cert'); let firstUnMaskButton = document.querySelectorAll('.masked-input-toggle')[0];
await click(firstUnMaskButton);
assert.dom('.masked-value').hasTextContaining('-----BEGIN CERTIFICATE-----');
}); });
test('it views a cert', async function(assert) { test('it views a cert', async function(assert) {
@ -82,6 +87,8 @@ elRplAzrMF4=
await listPage.secrets.objectAt(0).click(); await listPage.secrets.objectAt(0).click();
await settled(); await settled();
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'navigates to the show page'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'navigates to the show page');
assert.ok(showPage.hasCert, 'shows the cert'); let firstUnMaskButton = document.querySelectorAll('.masked-input-toggle')[0];
await click(firstUnMaskButton);
assert.dom('.masked-value').hasTextContaining('-----BEGIN CERTIFICATE-----');
}); });
}); });

View File

@ -1,4 +1,4 @@
import { currentRouteName, settled } from '@ember/test-helpers'; import { currentRouteName, settled, click } from '@ember/test-helpers';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
import page from 'vault/tests/pages/settings/configure-secret-backends/pki/section-cert'; import page from 'vault/tests/pages/settings/configure-secret-backends/pki/section-cert';
@ -112,7 +112,8 @@ BXUV2Uwtxf+QCphnlht9muX2fsLIzDJea0JipWj1uf2H8OZsjE8=
await page.form.generateCA('Intermediate CA', 'intermediate'); await page.form.generateCA('Intermediate CA', 'intermediate');
await settled(); await settled();
// cache csr // cache csr
csrVal = page.form.csr; await click('.masked-input-toggle');
csrVal = document.querySelector('.masked-value').innerText;
await page.form.back(); await page.form.back();
await settled(); await settled();
await page.visit({ backend: rootPath }); await page.visit({ backend: rootPath });
@ -121,7 +122,8 @@ BXUV2Uwtxf+QCphnlht9muX2fsLIzDJea0JipWj1uf2H8OZsjE8=
await settled(); await settled();
await page.form.csrField(csrVal).submit(); await page.form.csrField(csrVal).submit();
await settled(); await settled();
intermediateCert = page.form.certificate; await click('.masked-input-toggle');
intermediateCert = document.querySelector('[data-test-masked-input]').innerText;
await page.form.back(); await page.form.back();
await settled(); await settled();
await page.visit({ backend: intermediatePath }); await page.visit({ backend: intermediatePath });

View File

@ -28,7 +28,7 @@ const setupWrapping = async () => {
module('Acceptance | wrapped_token query param functionality', function(hooks) { module('Acceptance | wrapped_token query param functionality', function(hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
test('it authenticates you if the query param is present meep', async function(assert) { test('it authenticates you if the query param is present', async function(assert) {
let token = await setupWrapping(); let token = await setupWrapping();
await auth.visit({ wrapped_token: token }); await auth.visit({ wrapped_token: token });
await settled(); await settled();

View File

@ -1,7 +1,7 @@
import EmberObject from '@ember/object'; import EmberObject from '@ember/object';
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, click, fillIn } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import { create } from 'ember-cli-page-object'; import { create } from 'ember-cli-page-object';
import sinon from 'sinon'; import sinon from 'sinon';
@ -102,6 +102,11 @@ module('Integration | Component | form field', function(hooks) {
test('it renders: editType file', async function(assert) { test('it renders: editType file', async function(assert) {
await setup.call(this, createAttr('foo', 'string', { editType: 'file' })); await setup.call(this, createAttr('foo', 'string', { editType: 'file' }));
assert.ok(component.hasTextFile, 'renders the text-file component'); assert.ok(component.hasTextFile, 'renders the text-file component');
await click('[data-test-text-toggle="true"]');
await fillIn('[data-test-text-file-textarea="true"]', 'hello world');
assert.dom('[data-test-text-file-textarea="true"]').hasClass('masked-font');
await click('[data-test-button]');
assert.dom('[data-test-text-file-textarea="true"]').doesNotHaveClass('masked-font');
}); });
test('it renders: editType ttl', async function(assert) { test('it renders: editType ttl', async function(assert) {

View File

@ -1,6 +1,6 @@
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, focus } from '@ember/test-helpers';
import { create } from 'ember-cli-page-object'; import { create } from 'ember-cli-page-object';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import maskedInput from 'vault/tests/pages/components/masked-input'; import maskedInput from 'vault/tests/pages/components/masked-input';
@ -10,14 +10,9 @@ const component = create(maskedInput);
module('Integration | Component | masked input', function(hooks) { module('Integration | Component | masked input', function(hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
const hasClass = (classString = '', classToFind) => {
return classString.split(' ').includes(classToFind);
};
test('it renders', async function(assert) { test('it renders', async function(assert) {
await render(hbs`{{masked-input}}`); await render(hbs`{{masked-input}}`);
assert.dom('[data-test-masked-input]').exists('shows expiration beacon');
assert.ok(hasClass(component.wrapperClass, 'masked'));
}); });
test('it renders a textarea', async function(assert) { test('it renders a textarea', async function(assert) {
@ -26,14 +21,17 @@ module('Integration | Component | masked input', function(hooks) {
assert.ok(component.textareaIsPresent); assert.ok(component.textareaIsPresent);
}); });
test('it renders an input with type password when maskWhileTyping is true', async function(assert) { test('it renders an input with obscure font', async function(assert) {
await render(hbs`{{masked-input maskWhileTyping=true}}`); await render(hbs`{{masked-input}}`);
assert.ok(component.inputIsPresent);
assert.equal( assert.dom('[data-test-textarea]').hasClass('masked-font', 'loading class with correct font');
this.element.querySelector('input').getAttribute('type'), });
'password',
'default type equals password' test('it renders obscure font when displayOnly', async function(assert) {
); this.set('value', 'value');
await render(hbs`{{masked-input displayOnly=true value=value}}`);
assert.dom('.masked-value').hasClass('masked-font', 'loading class with correct font');
}); });
test('it does not render a textarea when displayOnly is true', async function(assert) { test('it does not render a textarea when displayOnly is true', async function(assert) {
@ -54,36 +52,12 @@ module('Integration | Component | masked input', function(hooks) {
assert.notOk(component.copyButtonIsPresent); assert.notOk(component.copyButtonIsPresent);
}); });
test('it unmasks text on focus', async function(assert) {
this.set('value', 'value');
await render(hbs`{{masked-input value=value}}`);
assert.ok(hasClass(component.wrapperClass, 'masked'));
await component.focusField();
assert.notOk(hasClass(component.wrapperClass, 'masked'));
});
test('it remasks text on blur', async function(assert) {
this.set('value', 'value');
await render(hbs`{{masked-input value=value}}`);
assert.ok(hasClass(component.wrapperClass, 'masked'));
await component.focusField();
await component.blurField();
assert.ok(hasClass(component.wrapperClass, 'masked'));
});
test('it unmasks text when button is clicked', async function(assert) { test('it unmasks text when button is clicked', async function(assert) {
this.set('value', 'value'); this.set('value', 'value');
await render(hbs`{{masked-input value=value}}`); await render(hbs`{{masked-input value=value}}`);
assert.ok(hasClass(component.wrapperClass, 'masked'));
await component.toggleMasked(); await component.toggleMasked();
assert.notOk(hasClass(component.wrapperClass, 'masked')); assert.dom('.masked-value').doesNotHaveClass('masked-font');
}); });
test('it remasks text when button is clicked', async function(assert) { test('it remasks text when button is clicked', async function(assert) {
@ -93,18 +67,25 @@ module('Integration | Component | masked input', function(hooks) {
await component.toggleMasked(); await component.toggleMasked();
await component.toggleMasked(); await component.toggleMasked();
assert.ok(hasClass(component.wrapperClass, 'masked')); assert.dom('.masked-value').hasClass('masked-font');
}); });
test('it changes type to text when unmasked button is clicked', async function(assert) { test('it shortens long outputs when displayOnly and masked', async function(assert) {
this.set('value', 'value'); this.set('value', '123456789-123456789-123456789');
await render(hbs`{{masked-input value=value maskWhileTyping=true}}`); await render(hbs`{{masked-input value=value displayOnly=true}}`);
await component.toggleMasked(); let maskedValue = document.querySelector('.masked-value').innerText;
assert.equal(maskedValue.length, 20);
assert.equal( await component.toggleMasked();
this.element.querySelector('input').getAttribute('type'), let unMaskedValue = document.querySelector('.masked-value').innerText;
'text', assert.equal(unMaskedValue.length, this.value.length);
'when unmasked type changes to text' });
);
test('it does not unmask text on focus', async function(assert) {
this.set('value', '123456789-123456789-123456789');
await render(hbs`{{masked-input value=value}}`);
assert.dom('.masked-value').hasClass('masked-font');
await focus('.masked-value');
assert.dom('.masked-value').hasClass('masked-font');
}); });
}); });

View File

@ -34,4 +34,12 @@ module('Integration | Helper | date-format', function(hooks) {
.dom('[data-test-date-format]') .dom('[data-test-date-format]')
.includesText(todayString, 'it renders the a date if passed in as a string'); .includesText(todayString, 'it renders the a date if passed in as a string');
}); });
test('it supports ten digit dates', async function(assert) {
let tenDigitDate = 1621785298;
this.set('tenDigitDate', tenDigitDate);
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');
});
}); });

View File

@ -1,17 +1,7 @@
import { attribute, clickable, fillable, isPresent } from 'ember-cli-page-object'; import { clickable, isPresent } from 'ember-cli-page-object';
import { focus, blur } from '@ember/test-helpers';
export default { export default {
wrapperClass: attribute('class', '[data-test-masked-input]'),
enterText: fillable('[data-test-textarea]'),
textareaIsPresent: isPresent('[data-test-textarea]'), textareaIsPresent: isPresent('[data-test-textarea]'),
inputIsPresent: isPresent('[data-test-input]'),
copyButtonIsPresent: isPresent('[data-test-copy-button]'), copyButtonIsPresent: isPresent('[data-test-copy-button]'),
toggleMasked: clickable('[data-test-button]'), toggleMasked: clickable('[data-test-button]'),
async focusField() {
return focus('[data-test-textarea]');
},
async blurField() {
return blur('[data-test-textarea]');
},
}; };

View File

@ -32,7 +32,7 @@ type UIConfig struct {
// NewUIConfig creates a new UI config // NewUIConfig creates a new UI config
func NewUIConfig(enabled bool, physicalStorage physical.Backend, barrierStorage logical.Storage) *UIConfig { func NewUIConfig(enabled bool, physicalStorage physical.Backend, barrierStorage logical.Storage) *UIConfig {
defaultHeaders := http.Header{} defaultHeaders := http.Header{}
defaultHeaders.Set("Content-Security-Policy", "default-src 'none'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'unsafe-inline' 'self'; form-action 'none'; frame-ancestors 'none'") defaultHeaders.Set("Content-Security-Policy", "default-src 'none'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'unsafe-inline' 'self'; form-action 'none'; frame-ancestors 'none'; font-src 'self'")
defaultHeaders.Set("Service-Worker-Allowed", "/") defaultHeaders.Set("Service-Worker-Allowed", "/")
return &UIConfig{ return &UIConfig{