Address a11y issues in browser-based console UI (#26872)

This commit is contained in:
Melanie Sumner
2024-05-16 11:39:13 -05:00
committed by GitHub
parent 3e0d934f47
commit 530b26608e
17 changed files with 104 additions and 59 deletions

3
changelog/26872.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:bug
ui: Resolved accessibility issues with Web REPL. Associated label and help text with input, added a conditional to show the console/ui-panel only when toggled open, added keyboard focus trap.
```

View File

@@ -17,16 +17,17 @@ export default Component.extend({
actions: {
handleKeyUp(event) {
const keyCode = event.keyCode;
const val = event.target.value;
switch (keyCode) {
case keys.ENTER:
this.onExecuteCommand(event.target.value);
this.onExecuteCommand(val);
break;
case keys.UP:
case keys.DOWN:
this.onShiftCommand(keyCode);
break;
default:
this.onValueUpdate(event.target.value);
this.onValueUpdate(val);
}
},
fullscreen() {

View File

@@ -49,7 +49,14 @@
<LinkStatus @status={{this.currentCluster.cluster.hcpLinkStatus}} />
{{yield}}
<div data-test-console-panel class={{if this.console.isOpen "panel-open"}}>
<Console::UiPanel @isFullscreen={{this.consoleFullscreen}} />
{{#if this.console.isOpen}}
<Console::UiPanel
@isFullscreen={{this.consoleFullscreen}}
{{focus-trap
focusTrapOptions=(hash initialFocus="#console-input" clickOutsideDeactivates=true onDeactivate=this.closeConsole)
}}
/>
{{/if}}
</div>
</Frame.Main>
</Hds::AppFrame>

View File

@@ -14,7 +14,9 @@ $console-close-height: 35px;
overflow: auto;
position: fixed;
bottom: 0;
transition: min-height $speed $easing, transform $speed ease-in;
transition:
min-height $speed $easing,
transform $speed ease-in;
will-change: transform, min-height;
-webkit-overflow-scrolling: touch;
z-index: 199;
@@ -118,11 +120,11 @@ $console-close-height: 35px;
.panel-open .console-ui-panel {
box-shadow: $box-shadow-highest;
min-height: 400px;
min-height: 425px;
}
.main--console-open {
padding-bottom: 400px;
padding-bottom: 425px;
}
.panel-open .console-ui-panel.fullscreen {

View File

@@ -10,19 +10,20 @@
<Chevron />
{{/if}}
<input
aria-label="command input"
aria-label="web R.E.P.L."
aria-describedby="namespace-reminder"
onkeyup={{action "handleKeyUp"}}
value={{this.value}}
autocomplete="off"
spellcheck="false"
id="console-input"
/>
<Hds::Button
class="hds-side-nav__icon-button"
{{on "click" (action "fullscreen")}}
{{hds-tooltip (if this.isFullscreen "minimize" "maximize")}}
data-test-tool-tip-trigger
data-test-dismiss-console-button
@icon={{if this.isFullscreen "minimize" "maximize"}}
@text={{if this.isFullscreen "Minimize" "Maximize"}}
@text={{if this.isFullscreen "Minimize window" "Maximize window"}}
@isIconOnly={{true}}
/>
</div>

View File

@@ -3,17 +3,19 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="console-close-button">
<Hds::Button
class="hds-side-nav__icon-button"
{{on "click" (action "closeConsole")}}
data-test-console-panel-close
@text="Close console"
@icon="x"
@isIconOnly={{true}}
/>
</div>
<div class="console-ui-panel-content">
<div class="content has-bottom-margin-s">
<div class="console-close-button">
<Hds::Button
class="hds-side-nav__icon-button"
{{on "click" (action "closeConsole")}}
data-test-console-panel-close
@text="Close console"
@icon="x"
@isIconOnly={{true}}
/>
</div>
</div>
<div class="content has-bottom-margin-l">
<p class="console-ui-panel-intro is-font-mono has-bottom-margin-s">
The Vault Web REPL provides an easy way to execute common Vault CLI commands, such as write, read, delete, and list. It
@@ -22,14 +24,18 @@
<Hds::Link::Inline @href={{doc-link "/vault/docs/command/web"}}>HashiCorp Developer site</Hds::Link::Inline>.
</p>
<p class="console-ui-panel-intro is-font-mono has-bottom-margin-s">Examples:</p>
<p class="console-ui-panel-intro is-font-mono">→ Write secrets to kv v1: write &lt;mount&gt;/my-secret foo=bar</p>
<p class="console-ui-panel-intro is-font-mono">→ List kv v1 secret keys: list &lt;mount&gt;/</p>
<p class="console-ui-panel-intro is-font-mono">→ Read a kv v1 secret: read &lt;mount&gt;/my-secret</p>
<p class="console-ui-panel-intro is-font-mono">→ Mount a kv v2 secret engine: write sys/mounts/&lt;mount&gt; type=kv
options=version=2</p>
<p class="console-ui-panel-intro is-font-mono">→ Read a kv v2 secret: kv-get &lt;mount&gt;/secret-path</p>
<p class="console-ui-panel-intro is-font-mono">→ Read a kv v2 secret's metadata: kv-get &lt;mount&gt;/secret-path
-metadata</p>
<p class="console-ui-panel-intro is-font-mono">
<span aria-hidden="true">→ </span>Write secrets to kv v1: write &lt;mount&gt;/my-secret foo=bar</p>
<p class="console-ui-panel-intro is-font-mono">
<span aria-hidden="true">→ </span>List kv v1 secret keys: list &lt;mount&gt;/</p>
<p class="console-ui-panel-intro is-font-mono">
<span aria-hidden="true">→ </span>Read a kv v1 secret: read &lt;mount&gt;/my-secret</p>
<p class="console-ui-panel-intro is-font-mono">
<span aria-hidden="true">→ </span>Mount a kv v2 secret engine: write sys/mounts/&lt;mount&gt; type=kv options=version=2</p>
<p class="console-ui-panel-intro is-font-mono">
<span aria-hidden="true">→ </span>Read a kv v2 secret: kv-get &lt;mount&gt;/secret-path</p>
<p class="console-ui-panel-intro is-font-mono">
<span aria-hidden="true">→ </span>Read a kv v2 secret's metadata: kv-get &lt;mount&gt;/secret-path-metadata</p>
</div>
<Console::OutputLog @outputLog={{this.cliLog}} />
<Console::CommandInput

View File

@@ -5,11 +5,11 @@
{{#if this.showMessage}}
{{#if (has-block)}}
<p class="namespace-reminder">
<p class="namespace-reminder" id="namespace-reminder">
{{yield (hash namespace=this.namespace)}}
</p>
{{else}}
<p class="namespace-reminder">
<p class="namespace-reminder" id="namespace-reminder">
This
{{@noun}}
will be

View File

@@ -25,8 +25,6 @@ module('Acceptance | console', function (hooks) {
assert.expect(6);
await enginesPage.visit();
await settled();
await consoleComponent.toggle();
await settled();
const ids = [uuidv4(), uuidv4(), uuidv4()];
for (const id of ids) {
const inputString = `write sys/mounts/console-route-${id} type=kv`;
@@ -59,7 +57,7 @@ module('Acceptance | console', function (hooks) {
test('fullscreen command expands the cli panel', async function (assert) {
await consoleComponent.toggle();
await settled();
await consoleComponent.runCommands('fullscreen');
await consoleComponent.runCommands('fullscreen', false);
await settled();
const consoleEle = document.querySelector('[data-test-component="console/ui-panel"]');
// wait for the CSS transition to finish
@@ -74,7 +72,7 @@ module('Acceptance | console', function (hooks) {
test('array output is correctly formatted', async function (assert) {
await consoleComponent.toggle();
await settled();
await consoleComponent.runCommands('read -field=policies /auth/token/lookup-self');
await consoleComponent.runCommands('read -field=policies /auth/token/lookup-self', false);
await settled();
const consoleOut = document.querySelector('.console-ui-output>pre');
// wait for the CSS transition to finish
@@ -86,7 +84,7 @@ module('Acceptance | console', function (hooks) {
test('number output is correctly formatted', async function (assert) {
await consoleComponent.toggle();
await settled();
await consoleComponent.runCommands('read -field=creation_time /auth/token/lookup-self');
await consoleComponent.runCommands('read -field=creation_time /auth/token/lookup-self', false);
await settled();
const consoleOut = document.querySelector('.console-ui-output>pre');
// wait for the CSS transition to finish
@@ -97,7 +95,7 @@ module('Acceptance | console', function (hooks) {
test('boolean output is correctly formatted', async function (assert) {
await consoleComponent.toggle();
await settled();
await consoleComponent.runCommands('read -field=orphan /auth/token/lookup-self');
await consoleComponent.runCommands('read -field=orphan /auth/token/lookup-self', false);
await settled();
const consoleOut = document.querySelector('.console-ui-output>pre');
// have to wrap in a later so that we can wait for the CSS transition to finish

View File

@@ -111,6 +111,7 @@ module('Acceptance | pki workflow', function (hooks) {
allow_subdomains=true \
max_ttl="720h"`,
]);
await runCmd([`write ${this.mountPath}/root/generate/internal common_name="Hashicorp Test"`]);
const pki_admin_policy = adminPolicy(this.mountPath, 'roles');
const pki_reader_policy = readerPolicy(this.mountPath, 'roles');

View File

@@ -25,7 +25,12 @@ const visit = async (url) => {
const consoleComponent = create(consoleClass);
const wrappedAuth = async () => {
await consoleComponent.runCommands(`write -field=token auth/token/create policies=default -wrap-ttl=5m`);
await consoleComponent.toggle();
await settled();
await consoleComponent.runCommands(
`write -field=token auth/token/create policies=default -wrap-ttl=5m`,
false
);
await settled();
// because of flaky test, trying to capture the token using a dom selector instead of the page object
const token = document.querySelector('[data-test-component="console/log-text"] pre').textContent;

View File

@@ -7,7 +7,7 @@ import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { create } from 'ember-cli-page-object';
import { fillIn } from '@ember/test-helpers';
import { fillIn, settled } from '@ember/test-helpers';
import { v4 as uuidv4 } from 'uuid';
import enablePage from 'vault/tests/pages/settings/auth/enable';
@@ -59,6 +59,8 @@ module('Acceptance | settings/auth/configure/section', function (hooks) {
for (const type of ['aws', 'azure', 'gcp', 'github', 'kubernetes']) {
test(`it shows tabs for auth method: ${type}`, async function (assert) {
const path = `${type}-showtab-${this.uid}`;
await cli.toggle();
await settled();
await cli.consoleInput(`write sys/auth/${path} type=${type}`);
await cli.enter();
await indexPage.visit({ path });

View File

@@ -153,12 +153,16 @@ module('Acceptance | settings/mount-secret-backend', function (hooks) {
capabilities = ["read"]
}
`;
await consoleComponent.runCommands([
// delete any previous mount with same name
`delete sys/mounts/${enginePath}`,
`write sys/policies/acl/kv-v2-degrade policy=${btoa(V2_POLICY)}`,
'write -field=client_token auth/token/create policies=kv-v2-degrade',
]);
await consoleComponent.toggle();
await consoleComponent.runCommands(
[
// delete any previous mount with same name
`delete sys/mounts/${enginePath}`,
`write sys/policies/acl/kv-v2-degrade policy=${btoa(V2_POLICY)}`,
'write -field=client_token auth/token/create policies=kv-v2-degrade',
],
false
);
await settled();
const userToken = consoleComponent.lastLogOutput;
await logout.visit();

View File

@@ -14,7 +14,12 @@ import consoleClass from 'vault/tests/pages/components/console/ui-panel';
const consoleComponent = create(consoleClass);
const wrappedAuth = async () => {
await consoleComponent.runCommands(`write -field=token auth/token/create policies=default -wrap-ttl=3m`);
await consoleComponent.toggle();
await settled();
await consoleComponent.runCommands(
`write -field=token auth/token/create policies=default -wrap-ttl=3m`,
false
);
await settled();
return consoleComponent.lastLogOutput;
};

View File

@@ -1,10 +1,11 @@
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import { create } from 'ember-cli-page-object';
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import { create } from 'ember-cli-page-object';
/**
* Helper functions to run common commands in the consoleComponent during tests.
* Please note that a user must be logged in during the test context for the commands to run.
@@ -45,8 +46,10 @@ export async function runCmd(commands, throwErrors = true) {
if (!Array.isArray(commands)) {
commands = [commands];
}
await cc.runCommands(commands);
await cc.toggle();
await cc.runCommands(commands, false);
const lastOutput = cc.lastLogOutput;
await cc.toggle();
if (throwErrors && lastOutput.includes('Error')) {
throw new Error(`Error occurred while running commands: "${commands.join('; ')}" - ${lastOutput}`);
}

View File

@@ -23,20 +23,21 @@ module('Integration | Component | console/ui panel', function (hooks) {
test('it clears console input on enter', async function (assert) {
await render(hbs`{{console/ui-panel}}`);
await component.runCommands('list this/thing/here');
await component.runCommands('list this/thing/here', false);
await settled();
assert.strictEqual(component.consoleInputValue, '', 'empties input field on enter');
});
test('it clears the log when using clear command', async function (assert) {
await render(hbs`{{console/ui-panel}}`);
await component.runCommands(['list this/thing/here', 'list this/other/thing', 'read another/thing']);
await component.runCommands(
['list this/thing/here', 'list this/other/thing', 'read another/thing'],
false
);
await settled();
assert.notEqual(component.logOutput, '', 'there is output in the log');
await component.runCommands('clear');
await component.runCommands('clear', false);
await settled();
await component.up();
await settled();
@@ -51,7 +52,7 @@ module('Integration | Component | console/ui panel', function (hooks) {
test('it adds command to history on enter', async function (assert) {
await render(hbs`{{console/ui-panel}}`);
await component.runCommands('list this/thing/here');
await component.runCommands('list this/thing/here', false);
await settled();
await component.up();
await settled();
@@ -67,8 +68,7 @@ module('Integration | Component | console/ui panel', function (hooks) {
test('it cycles through history with more than one command', async function (assert) {
await render(hbs`{{console/ui-panel}}`);
await component.runCommands(['list this/thing/here', 'read that/thing/there', 'qwerty']);
await component.runCommands(['list this/thing/here', 'read that/thing/there', 'qwerty'], false);
await settled();
await component.up();
await settled();

View File

@@ -35,7 +35,7 @@ module('Integration | Component | sidebar-frame', function (hooks) {
assert.dom('[data-test-sidebar-nav]').doesNotExist('Sidebar is hidden');
});
test('it should render link status, console ui panel and yield block for app content', async function (assert) {
test('it should render link status, console ui panel container and yield block for app content', async function (assert) {
const currentCluster = this.owner.lookup('service:currentCluster');
currentCluster.setCluster({ hcpLinkStatus: 'connected' });
const version = this.owner.lookup('service:version');
@@ -50,7 +50,7 @@ module('Integration | Component | sidebar-frame', function (hooks) {
`);
assert.dom('[data-test-link-status]').exists('Link status component renders');
assert.dom('[data-test-component="console/ui-panel"]').exists('Console UI panel renders');
assert.dom('[data-test-console-panel]').exists('Console UI panel container renders');
assert.dom('.page-container').exists('Block yields for app content');
});

View File

@@ -11,6 +11,7 @@ import keys from 'core/utils/key-codes';
export default {
toggle: clickable('[data-test-console-toggle]'),
dismissConsole: clickable(['data-test-dismiss-console-button']),
consoleInput: fillable('[data-test-component="console/command-input"] input'),
consoleInputValue: value('[data-test-component="console/command-input"] input'),
logOutput: text('[data-test-component="console/output-log"]'),
@@ -54,12 +55,18 @@ export default {
eventProperties: { keyCode: keys.ENTER },
}),
hasInput: isPresent('[data-test-component="console/command-input"] input'),
runCommands: async function (commands) {
runCommands: async function (commands, shouldToggle = true) {
const toExecute = Array.isArray(commands) ? commands : [commands];
if (shouldToggle) {
await this.toggle(); // toggle the console open
}
for (const command of toExecute) {
await this.consoleInput(command);
await this.enter();
await settled();
}
if (shouldToggle) {
await this.toggle(); // toggle it closed
}
},
};