UI: Improve namespace input UX (#30751)

* yield namespace input and update tests

* add refocus logic

* more tests!
This commit is contained in:
claire bontempo 2025-05-27 16:41:01 -07:00 committed by GitHub
parent bc430dbe60
commit 2d0587f316
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 229 additions and 133 deletions

View File

@ -13,14 +13,7 @@
@onSuccess={{@onSuccess}}
>
<:namespace>
{{#if (has-feature "Namespaces")}}
<Auth::NamespaceInput
@disabled={{@oidcProviderQueryParam}}
@hvdManagedNamespace={{this.flags.hvdManagedNamespaceRoot}}
@namespaceValue={{this.namespaceInput}}
@updateNamespace={{this.handleNamespaceUpdate}}
/>
{{/if}}
{{yield}}
</:namespace>
<:back>

View File

@ -8,9 +8,7 @@ import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { supportedTypes } from 'vault/utils/supported-login-methods';
import { getRelativePath } from 'core/utils/sanitize-path';
import type FlagsService from 'vault/services/flags';
import type VersionService from 'vault/services/version';
import type ClusterModel from 'vault/models/cluster';
import type { UnauthMountsByType } from 'vault/vault/auth/form';
@ -31,23 +29,16 @@ import type { HTMLElementEvent } from 'vault/forms';
* @param {string} canceledMfaAuth - saved auth type from a cancelled mfa verification
* @param {object} cluster - The route model which is the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby
* @param {object} defaultView - The `FormView` (see the interface below) data to render the initial view.
* @param {function} handleNamespaceUpdate - callback task that passes user input to the controller and updates the namespace query param in the url
* @param {object} initialFormState - sets selectedAuthMethod and showAlternateView based on the login form configuration computed in parent component
* @param {string} namespaceQueryParam - namespace query param from the url
* @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider". if present, disables the namespace input
* @param {function} onSuccess - callback after the initial authentication request, if an mfa_requirement exists the parent renders the mfa form otherwise it fires the authSuccess action in the auth controller and handles transitioning to the app
* @param {array} visibleMountTypes - array of auth method types that have mounts with listing_visibility="unauth"
*
* */
interface Args {
alternateView: FormView | null;
cluster: ClusterModel;
defaultView: FormView;
handleNamespaceUpdate: CallableFunction;
initialFormState: { initialAuthType: string; showAlternate: boolean };
namespaceQueryParam: string;
oidcProviderQueryParam: string;
onSuccess: CallableFunction;
visibleMountTypes: string[];
}
@ -58,7 +49,6 @@ interface FormView {
}
export default class AuthFormTemplate extends Component<Args> {
@service declare readonly flags: FlagsService;
@service declare readonly version: VersionService;
supportedAuthTypes: string[];
@ -103,17 +93,6 @@ export default class AuthFormTemplate extends Component<Args> {
return this.args.visibleMountTypes?.includes(this.selectedAuthMethod);
}
get namespaceInput() {
const namespaceQueryParam = this.args.namespaceQueryParam;
if (this.flags.hvdManagedNamespaceRoot) {
// When managed, the user isn't allowed to edit the prefix `admin/`
// so prefill just the relative path in the namespace input
const path = getRelativePath(namespaceQueryParam, this.flags.hvdManagedNamespaceRoot);
return path ? `/${path}` : '';
}
return namespaceQueryParam;
}
@action
setAuthType(authType: string) {
this.selectedAuthMethod = authType;
@ -136,9 +115,4 @@ export default class AuthFormTemplate extends Component<Args> {
handleError(message: string) {
this.errorMessage = message;
}
@action
handleNamespaceUpdate(event: HTMLElementEvent<HTMLInputElement>) {
this.args.handleNamespaceUpdate(event.target.value);
}
}

View File

@ -4,14 +4,14 @@
}}
<div class="background-neutral-50 has-padding-l">
{{#if @hvdManagedNamespace}}
{{#if this.flags.hvdManagedNamespaceRoot}}
<Hds::Form::Field @layout="vertical" disabled={{@disabled}} as |F|>
<F.Label>Namespace</F.Label>
<F.Control>
<Hds::SegmentedGroup class="is-fullwidth" as |SG|>
<SG.TextInput
@type="text"
@value="/{{@hvdManagedNamespace}}"
@value="/{{this.flags.hvdManagedNamespaceRoot}}"
aria-label="Root namespace for H-C-P managed cluster"
class="one-fourth-width"
name="hvd-root-namespace"
@ -19,8 +19,9 @@
data-test-managed-namespace-root
/>
<SG.TextInput
{{on "input" @updateNamespace}}
@value={{@namespaceValue}}
{{on "input" this.handleInput}}
{{did-insert this.maybeRefocus}}
@value={{this.namespaceInput}}
autocomplete="off"
disabled={{@disabled}}
name="namespace"
@ -32,8 +33,9 @@
</Hds::Form::Field>
{{else}}
<Hds::Form::TextInput::Field
{{on "input" @updateNamespace}}
@value={{@namespaceValue}}
{{on "input" this.handleInput}}
{{did-insert this.maybeRefocus}}
@value={{this.namespaceInput}}
autocomplete="off"
disabled={{@disabled}}
name="namespace"

View File

@ -0,0 +1,67 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { getRelativePath } from 'core/utils/sanitize-path';
import { restartableTask, timeout } from 'ember-concurrency';
import { service } from '@ember/service';
import type { HTMLElementEvent } from 'vault/forms';
import type ApiService from 'vault/services/api';
import type FlagsService from 'vault/services/flags';
/**
* @module Auth::NamespaceInput
* Renders the namespace input for the login form. As a user types, the updateNamespace callback fires in the controller to update the query param in the URL.
* When a namespace is updated, the controller sets `shouldRefocusNamespaceInput = true` which refocuses the input after the route refreshes.
* For HVD managed clusters the input prepends the administrative namespace: `admin/`. The input is disabled if the url has and OIDC query param: "?o=someprovider"
*
* @param {boolean} disabled - determines whether or not the namespace input is disabled
* @param {function} handleNamespaceUpdate - fires updateNamespace callback in controller
* @param {string} namespaceQueryParam - namespace query param from the url
* @param {boolean} shouldRefocusNamespaceInput - if true, refocuses the input on `{{did-insert}}`
* */
interface Args {
handleNamespaceUpdate: CallableFunction;
namespaceQueryParam: string;
shouldRefocusNamespaceInput: boolean;
}
export default class AuthNamespaceInput extends Component<Args> {
@service declare readonly api: ApiService;
@service declare readonly flags: FlagsService;
get namespaceInput() {
const namespaceQueryParam = this.args.namespaceQueryParam;
if (this.flags.hvdManagedNamespaceRoot) {
// When managed, the user isn't allowed to edit the prefix `admin/`
// so prefill just the relative path in the namespace input
const path = getRelativePath(namespaceQueryParam, this.flags.hvdManagedNamespaceRoot);
return path ? `/${path}` : '';
}
return namespaceQueryParam;
}
@action
async handleInput(event: HTMLElementEvent<HTMLInputElement>) {
// user has typed something, so input should be refocused
const value = event.target.value;
this.updateNamespace.perform(value);
}
updateNamespace = restartableTask(async (namespace) => {
await timeout(500);
await this.args.handleNamespaceUpdate(namespace);
});
@action
maybeRefocus(element: HTMLElement) {
if (this.args.shouldRefocusNamespaceInput) {
element.focus();
}
}
}

View File

@ -53,13 +53,20 @@
@alternateView={{this.formViews.alternateView}}
@cluster={{@cluster}}
@defaultView={{this.formViews.defaultView}}
@handleNamespaceUpdate={{@onNamespaceUpdate}}
@initialFormState={{this.initialFormState}}
@namespaceQueryParam={{@namespaceQueryParam}}
@oidcProviderQueryParam={{@oidcProviderQueryParam}}
@onSuccess={{this.onAuthResponse}}
@visibleMountTypes={{this.visibleMountTypes}}
/>
>
{{! yielded for accessibility so namespace submits as an input of the <form> element }}
{{#if (has-feature "Namespaces")}}
<Auth::NamespaceInput
@disabled={{if @oidcProviderQueryParam true false}}
@handleNamespaceUpdate={{@onNamespaceUpdate}}
@namespaceQueryParam={{@namespaceQueryParam}}
@shouldRefocusNamespaceInput={{@shouldRefocusNamespaceInput}}
/>
{{/if}}
</Auth::FormTemplate>
{{/if}}
</:content>

View File

@ -107,6 +107,12 @@ export default class AuthPage extends Component<Args> {
@tracked mfaAuthData: MfaAuthData | null = null;
@tracked mfaErrors = '';
get cspError() {
const isStandby = this.args.cluster.standby;
const hasConnectionViolations = this.csp.connectionViolations.length;
return isStandby && hasConnectionViolations ? CSP_ERROR : '';
}
get visibleMountsByType() {
const visibleAuthMounts = this.args.visibleAuthMounts;
if (visibleAuthMounts) {
@ -125,13 +131,7 @@ export default class AuthPage extends Component<Args> {
return Object.keys(this.visibleMountsByType || {});
}
get cspError() {
const isStandby = this.args.cluster.standby;
const hasConnectionViolations = this.csp.connectionViolations.length;
return isStandby && hasConnectionViolations ? CSP_ERROR : '';
}
// FORM STATE GETTERS
// AUTH FORM STATE GETTERS
get formViews() {
const { directLinkData, loginSettings } = this.args;

View File

@ -5,7 +5,7 @@
import { service } from '@ember/service';
import { alias } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
import { task, timeout } from 'ember-concurrency';
import { task } from 'ember-concurrency';
import { sanitizePath } from 'core/utils/sanitize-path';
export default Controller.extend({
@ -22,7 +22,9 @@ export default Controller.extend({
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
redirectTo: alias('vaultController.redirectTo'),
hvdManagedNamespaceRoot: alias('flagsService.hvdManagedNamespaceRoot'),
shouldRefocusNamespaceInput: false,
// Query params
authMount: '',
oidcProvider: '',
unwrapTokenError: '',
@ -36,12 +38,12 @@ export default Controller.extend({
},
updateNamespace: task(function* (value) {
// debounce
yield timeout(500);
const ns = this.fullNamespaceFromInput(value);
this.namespaceService.setNamespace(ns, true);
this.customMessages.fetchMessages();
yield this.customMessages.fetchMessages();
this.set('namespaceQueryParam', ns);
// if user is inputting a namespace, maintain input focus as the param updates
this.set('shouldRefocusNamespaceInput', true);
}).restartable(),
actions: {

View File

@ -120,7 +120,7 @@ export default class AuthRoute extends ClusterRouteBase {
// return a falsy value if the object is empty
return isEmptyValue(resp.auth) ? null : resp.auth;
} catch {
// swallow the error if there's an error fetching mount data (i.e. invalid namespace)
// catch error if there's a problem fetching mount data (i.e. invalid namespace)
return null;
}
}

View File

@ -22,5 +22,6 @@
@onAuthSuccess={{action "authSuccess"}}
@onNamespaceUpdate={{perform this.updateNamespace}}
@visibleAuthMounts={{this.model.visibleAuthMounts}}
@shouldRefocusNamespaceInput={{this.shouldRefocusNamespaceInput}}
/>
{{/if}}

View File

@ -31,10 +31,7 @@ module('Integration | Component | auth | form template', function (hooks) {
this.alternateView = null;
this.defaultView = { view: 'dropdown', tabData: null };
this.handleNamespaceUpdate = sinon.spy();
this.initialFormState = { initialAuthType: 'token', showAlternate: false };
this.namespaceQueryParam = '';
this.oidcProviderQueryParam = '';
this.onSuccess = sinon.spy();
this.visibleMountTypes = null;
@ -44,10 +41,7 @@ module('Integration | Component | auth | form template', function (hooks) {
@alternateView={{this.alternateView}}
@cluster={{this.cluster}}
@defaultView={{this.defaultView}}
@handleNamespaceUpdate={{this.handleNamespaceUpdate}}
@initialFormState={{this.initialFormState}}
@namespaceQueryParam={{this.namespaceQueryParam}}
@oidcProviderQueryParam={{this.oidcProviderQueryParam}}
@onSuccess={{this.onSuccess}}
@visibleMountTypes={{this.visibleMountTypes}}
/>`);
@ -83,6 +77,22 @@ module('Integration | Component | auth | form template', function (hooks) {
authenticateStub.restore();
});
test('dropdown does not include enterprise methods on community versions', async function (assert) {
this.version.type = 'community';
const supported = BASE_LOGIN_METHODS.map((m) => m.type);
const unsupported = ENTERPRISE_LOGIN_METHODS.map((m) => m.type);
assert.expect(supported.length + unsupported.length);
await this.renderComponent();
const dropdownOptions = findAll(`${GENERAL.selectByAttr('auth type')} option`).map((o) => o.value);
supported.forEach((m) => {
assert.true(dropdownOptions.includes(m), `dropdown includes supported method: ${m}`);
});
unsupported.forEach((m) => {
assert.false(dropdownOptions.includes(m), `dropdown does NOT include unsupported method: ${m}`);
});
});
module('listing visibility', function (hooks) {
hooks.beforeEach(function () {
const defaultTabs = {
@ -210,47 +220,14 @@ module('Integration | Component | auth | form template', function (hooks) {
});
});
module('community', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
});
test('it does not render the namespace input on community', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist();
});
test('dropdown does not include enterprise methods', async function (assert) {
const supported = BASE_LOGIN_METHODS.map((m) => m.type);
const unsupported = ENTERPRISE_LOGIN_METHODS.map((m) => m.type);
assert.expect(supported.length + unsupported.length);
await this.renderComponent();
const dropdownOptions = findAll(`${GENERAL.selectByAttr('auth type')} option`).map((o) => o.value);
supported.forEach((m) => {
assert.true(dropdownOptions.includes(m), `dropdown includes supported method: ${m}`);
});
unsupported.forEach((m) => {
assert.false(dropdownOptions.includes(m), `dropdown does NOT include unsupported method: ${m}`);
});
});
});
// tests with "enterprise" in the title are filtered out from CE test runs
// naming the module 'ent' so these tests still run on the CE repo
module('ent', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
this.version.features = ['Namespaces'];
this.namespaceQueryParam = '';
});
test('it does not render the namespace input if version does not include feature', async function (assert) {
this.version.features = [];
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist();
});
// in th ent module to test ALL supported login methods
// iterating in tests should generally be avoided, but purposefully wanted to test the component
// renders as expected as auth types change
@ -289,12 +266,6 @@ module('Integration | Component | auth | form template', function (hooks) {
}
});
test('it disables namespace input when an oidc provider query param exists', async function (assert) {
this.oidcProviderQueryParam = 'myprovider';
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).isDisabled();
});
test('dropdown includes enterprise methods', async function (assert) {
const supported = ALL_LOGIN_METHODS.map((m) => m.type);
assert.expect(supported.length);
@ -305,15 +276,6 @@ module('Integration | Component | auth | form template', function (hooks) {
assert.true(dropdownOptions.includes(m), `dropdown includes supported method: ${m}`);
});
});
test('it sets namespace for hvd managed clusters', async function (assert) {
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.namespaceQueryParam = 'admin/west-coast';
await this.renderComponent();
assert.dom(AUTH_FORM.managedNsRoot).hasValue('/admin');
assert.dom(AUTH_FORM.managedNsRoot).hasAttribute('readonly');
assert.dom(GENERAL.inputByAttr('namespace')).hasValue('/west-coast');
});
});
// AUTH METHOD SPECIFIC TESTS

View File

@ -0,0 +1,85 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { fillIn, find, render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Integration | Component | auth | namespace input', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.disabled = false;
this.oidcProviderQueryParam = '';
this.handleNamespaceUpdate = sinon.spy();
this.shouldRefocusNamespaceInput = false;
this.renderComponent = () => {
return render(hbs`
<Auth::NamespaceInput
@disabled={{this.disabled}}
@handleNamespaceUpdate={{this.handleNamespaceUpdate}}
@namespaceQueryParam={{this.namespaceQueryParam}}
@shouldRefocusNamespaceInput={{this.shouldRefocusNamespaceInput}}
/>`);
};
});
test('it fires @handleNamespaceUpdate callback', async function (assert) {
assert.expect(1);
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('namespace'), 'ns-1');
const [actual] = this.handleNamespaceUpdate.lastCall.args;
assert.strictEqual(actual, 'ns-1', `handleNamespaceUpdate called with: ${actual}`);
});
test('it disables the input if @disabled is true', async function (assert) {
this.disabled = true;
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).isDisabled();
});
test('it does not focus the input if @shouldRefocusNamespaceInput is false', async function (assert) {
await this.renderComponent();
const element = find(GENERAL.inputByAttr('namespace'));
assert.notStrictEqual(document.activeElement, element, 'the namespace input is NOT focused');
});
test('it focuses the input if @shouldRefocusNamespaceInput is true', async function (assert) {
this.shouldRefocusNamespaceInput = true;
await this.renderComponent();
const element = find(GENERAL.inputByAttr('namespace'));
assert.strictEqual(document.activeElement, element, 'the namespace input is focused');
});
module('HVD managed', function (hooks) {
hooks.beforeEach(function () {
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
});
test('it sets namespace', async function (assert) {
this.namespaceQueryParam = 'admin/west-coast';
await this.renderComponent();
assert.dom(AUTH_FORM.managedNsRoot).hasValue('/admin');
assert.dom(AUTH_FORM.managedNsRoot).hasAttribute('readonly');
assert.dom(GENERAL.inputByAttr('namespace')).hasValue('/west-coast');
});
test('it calls onNamespaceUpdate', async function (assert) {
assert.expect(2);
this.namespaceQueryParam = 'admin';
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).hasValue('');
await fillIn(GENERAL.inputByAttr('namespace'), 'ns-1');
const [actual] = this.handleNamespaceUpdate.lastCall.args;
assert.strictEqual(actual, 'ns-1', `handleNamespaceUpdate called with: ${actual}`);
});
});
});

View File

@ -24,6 +24,8 @@ module('Integration | Component | auth | page', function (hooks) {
this.cluster = { id: '1' };
this.directLinkData = null;
this.loginSettings = null;
this.namespaceQueryParam = '';
this.oidcProviderQueryParam = '';
this.onAuthSuccess = sinon.spy();
this.onNamespaceUpdate = sinon.spy();
this.visibleAuthMounts = false;
@ -34,8 +36,8 @@ module('Integration | Component | auth | page', function (hooks) {
@cluster={{this.cluster}}
@directLinkData={{this.directLinkData}}
@loginSettings={{this.loginSettings}}
@namespaceQueryParam={{this.nsQp}}
@oidcProviderQueryParam={{this.providerQp}}
@namespaceQueryParam={{this.namespaceQueryParam}}
@oidcProviderQueryParam={{this.oidcProviderQueryParam}}
@onAuthSuccess={{this.onAuthSuccess}}
@onNamespaceUpdate={{this.onNamespaceUpdate}}
@visibleAuthMounts={{this.visibleAuthMounts}}
@ -58,10 +60,12 @@ module('Integration | Component | auth | page', function (hooks) {
assert.dom(GENERAL.pageError.error).hasText(CSP_ERROR);
});
test('it renders splash logo when oidc provider query param is present', async function (assert) {
this.providerQp = 'myprovider';
test('it renders splash logo and disables namespace input when oidc provider query param is present', async function (assert) {
this.oidcProviderQueryParam = 'myprovider';
this.version.features = ['Namespaces'];
await this.renderComponent();
assert.dom(AUTH_FORM.logo).exists();
assert.dom(GENERAL.inputByAttr('namespace')).isDisabled();
assert
.dom(AUTH_FORM.helpText)
.hasText(
@ -69,14 +73,6 @@ module('Integration | Component | auth | page', function (hooks) {
);
});
test('it disables namespace input when oidc provider query param is present', async function (assert) {
this.providerQp = 'myprovider';
this.version.features = ['Namespaces'];
await this.renderComponent();
assert.dom(AUTH_FORM.logo).exists();
assert.dom(GENERAL.inputByAttr('namespace')).isDisabled();
});
test('it calls onNamespaceUpdate', async function (assert) {
assert.expect(1);
this.version.features = ['Namespaces'];
@ -86,21 +82,28 @@ module('Integration | Component | auth | page', function (hooks) {
assert.strictEqual(actual, 'mynamespace', `onNamespaceUpdate called with: ${actual}`);
});
test('it calls onNamespaceUpdate for HVD managed clusters', async function (assert) {
assert.expect(2);
test('it passes query param to namespace input', async function (assert) {
this.version.features = ['Namespaces'];
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.nsQp = 'admin';
this.namespaceQueryParam = 'ns-1';
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).hasValue('');
await fillIn(GENERAL.inputByAttr('namespace'), 'mynamespace');
const [actual] = this.onNamespaceUpdate.lastCall.args;
assert.strictEqual(actual, 'mynamespace', `onNamespaceUpdate called with: ${actual}`);
assert.dom(GENERAL.inputByAttr('namespace')).hasValue(this.namespaceQueryParam);
});
// DIRECT LINK tests (without any listing visibility)
test('it selects type in the dropdown if direct link is just type', async function (assert) {
test('it does not render the namespace input on community', async function (assert) {
this.version.type = 'community';
this.version.features = [];
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist();
});
test('it does not render the namespace input on enterprise without the "Namespaces" feature', async function (assert) {
this.version.type = 'enterprise';
this.version.features = [];
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist();
});
test('it selects type in the dropdown if direct link just has type', async function (assert) {
this.directLinkData = { type: 'oidc' };
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');