UI: Update template helpers and cleanup component args (#30580)

* make displayName a global helper

* rename authTabTypes to visibleMountsByType

* remove superfluous arg

* move all of mount displaying to component

* rename hasMountData to isVisibleMount, update comment
This commit is contained in:
claire bontempo 2025-05-12 11:13:24 -07:00 committed by GitHub
parent 87f1d18e51
commit e8c196aa62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 112 additions and 109 deletions

View file

@ -40,22 +40,16 @@
<:authSelectOptions>
<div class="has-bottom-margin-m">
{{#if this.showCustomAuthOptions}}
{{! URL contains "with" query param and it references a visible mount }}
{{! treat it as a "preferred" mount and hide all other tabs }}
{{#if @directLinkData.hasMountData}}
{{#let @directLinkData as |mount|}}
<Auth::SingleMount
@description={{mount.description}}
@path={{mount.path}}
@shouldRenderPath={{not-eq @selectedAuthMethod "token"}}
@type={{this.displayName mount.type}}
/>
{{/let}}
{{#if @directLinkData.isVisibleMount}}
{{! URL contains a "with" query param that references a mount with listing_visibility="unauth" }}
{{! Treat it as a "preferred" mount and hide all other tabs }}
<Auth::MountsDisplay
@mounts={{array @directLinkData}}
@shouldRenderPath={{not-eq @selectedAuthMethod "token"}}
/>
{{else}}
<Auth::Tabs
@authTabData={{@authTabData}}
@authTabTypes={{this.authTabTypes}}
@displayNameHelper={{this.displayName}}
@authTabData={{@visibleMountsByType}}
@handleTabClick={{this.setAuthType}}
@selectedAuthMethod={{this.selectedAuthMethod}}
/>
@ -72,7 +66,7 @@
<F.Options>
{{#each this.availableMethodTypes as |type|}}
<option selected={{eq this.selectedAuthMethod type}} value={{type}}>
{{this.displayName type}}
{{auth-display-name type}}
</option>
{{/each}}
</F.Options>

View file

@ -7,7 +7,7 @@ import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { ALL_LOGIN_METHODS, supportedTypes } from 'vault/utils/supported-login-methods';
import { supportedTypes } from 'vault/utils/supported-login-methods';
import { getRelativePath } from 'core/utils/sanitize-path';
import type AuthService from 'vault/vault/services/auth';
@ -15,7 +15,7 @@ import type FlagsService from 'vault/services/flags';
import type Store from '@ember-data/store';
import type VersionService from 'vault/services/version';
import type ClusterModel from 'vault/models/cluster';
import type { AuthTabData, AuthTabMountData } from 'vault/vault/auth/form';
import type { UnauthMountsByType, AuthTabMountData } from 'vault/vault/auth/form';
import type { HTMLElementEvent } from 'vault/forms';
/**
@ -29,26 +29,26 @@ import type { HTMLElementEvent } from 'vault/forms';
* dynamically renders the corresponding form.
*
*
* @param {object} authTabData - auth methods to render as tabs, contains mount data for any mounts with listing_visibility="unauth"
* @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 {function} handleNamespaceUpdate - callback task that passes user input to the controller and updates the namespace query param in the url
* @param {boolean} hasVisibleAuthMounts - whether or not any mounts have been tuned with listing_visibility="unauth"
* @param {string} namespaceQueryParam - namespace query param from the url
* @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 {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} directLinkData - mount data built from the "with" query param. If param is a mount path and maps to a visible mount, the login form defaults to this mount. Otherwise the form preselects the passed auth type.
* @param {function} handleNamespaceUpdate - callback task that passes user input to the controller and updates the namespace query param in the url
* @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 {object} visibleMountsByType - auth methods to render as tabs, contains mount data for any mounts with listing_visibility="unauth"
*
* */
interface Args {
authTabData: AuthTabData;
cluster: ClusterModel;
handleNamespaceUpdate: CallableFunction;
hasVisibleAuthMounts: boolean;
namespaceQueryParam: string;
onSuccess: CallableFunction;
canceledMfaAuth: string;
directLinkData: (AuthTabMountData & { hasMountData: boolean }) | null;
cluster: ClusterModel;
directLinkData: (AuthTabMountData & { isVisibleMount: boolean }) | null;
handleNamespaceUpdate: CallableFunction;
namespaceQueryParam: string;
oidcProviderQueryParam: string;
onSuccess: CallableFunction;
visibleMountsByType: UnauthMountsByType;
}
export default class AuthFormTemplate extends Component<Args> {
@ -64,13 +64,8 @@ export default class AuthFormTemplate extends Component<Args> {
@tracked selectedAuthMethod = '';
@tracked errorMessage = '';
displayName = (type: string) => {
const displayName = ALL_LOGIN_METHODS?.find((t) => t.type === type)?.displayName;
return displayName || type;
};
get authTabTypes() {
const visibleMounts = this.args.authTabData;
const visibleMounts = this.args.visibleMountsByType;
return visibleMounts ? Object.keys(visibleMounts) : [];
}
@ -111,7 +106,7 @@ export default class AuthFormTemplate extends Component<Args> {
// This getter determines whether to render an alternative view (e.g., tabs or a preferred mount).
// If `true`, the "Sign in with other methods →" link is shown.
get showCustomAuthOptions() {
const hasLoginCustomization = this.args?.directLinkData?.hasMountData || this.args.hasVisibleAuthMounts;
const hasLoginCustomization = this.args?.directLinkData?.isVisibleMount || this.args.visibleMountsByType;
// Show if customization exists and the user has NOT clicked "Sign in with other methods →"
return hasLoginCustomization && !this.showOtherMethods;
}
@ -123,12 +118,12 @@ export default class AuthFormTemplate extends Component<Args> {
this.setAuthType(this.preselectedType);
} else {
// if nothing has been preselected, select first tab or set to 'token'
const authType = this.args.hasVisibleAuthMounts ? (this.authTabTypes[0] as string) : 'token';
const authType = this.args.visibleMountsByType ? (this.authTabTypes[0] as string) : 'token';
this.setAuthType(authType);
}
// DETERMINES INITIAL RENDER: custom selection (direct link or tabs) vs dropdown
if (this.args.hasVisibleAuthMounts) {
if (this.args.visibleMountsByType) {
// render tabs if selectedAuthMethod is one, otherwise render dropdown (i.e. showOtherMethods = false)
this.showOtherMethods = this.authTabTypes.includes(this.selectedAuthMethod) ? false : true;
} else {

View file

@ -0,0 +1,33 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (gt @mounts.length 1)}}
{{! render dropdown of mount paths }}
<Hds::Form::Select::Field name="path" data-test-select="path" as |F|>
<F.Label>Mount path</F.Label>
<F.Options>
{{#each @mounts as |mount|}}
<option value={{mount.path}}>{{mount.path}}</option>
{{/each}}
</F.Options>
</Hds::Form::Select::Field>
{{else}}
{{! render a single mount path }}
{{#let (get @mounts "0") as |mount|}}
{{#unless @hideType}}
<Hds::Text::Body @tag="p" @weight="semibold" data-test-auth-method={{mount.type}}>
{{auth-display-name mount.type}}
</Hds::Text::Body>
{{/unless}}
{{#if mount.description}}
<Hds::Text::Body @tag="p" @color="faint" data-test-description>{{mount.description}}</Hds::Text::Body>
{{/if}}
{{! the token auth method does't support custom paths so no need to render an input }}
{{#if @shouldRenderPath}}
{{! path is hidden so it is submitted with FormData but does not clutter the login form }}
<input type="hidden" id="path" name="path" value={{mount.path}} data-test-input="path" />
{{/if}}
{{/let}}
{{/if}}

View file

@ -50,15 +50,14 @@
/>
{{else}}
<Auth::FormTemplate
@authTabData={{this.authTabData}}
@canceledMfaAuth={{this.canceledMfaAuth}}
@cluster={{@cluster}}
@directLinkData={{@directLinkData}}
@handleNamespaceUpdate={{@onNamespaceUpdate}}
@hasVisibleAuthMounts={{if @visibleAuthMounts true false}}
@namespaceQueryParam={{@namespaceQueryParam}}
@oidcProviderQueryParam={{@oidcProviderQueryParam}}
@onSuccess={{this.onAuthResponse}}
@canceledMfaAuth={{this.canceledMfaAuth}}
@directLinkData={{@directLinkData}}
@visibleMountsByType={{this.visibleMountsByType}}
/>
{{/if}}
</:content>

View file

@ -30,7 +30,7 @@ import { action } from '@ember/object';
* @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider"
* @param {function} onAuthSuccess - callback task in controller that receives the auth response (after MFA, if enabled) when login is successful
* @param {function} onNamespaceUpdate - callback task that passes user input to the controller to update the login namespace in the url query params
* @param {object} visibleAuthMounts - keys are auth methods to render as tabs, values is an array of mount data for mounts with listing_visibility="unauth"
* @param {object} visibleAuthMounts - mount paths with listing_visibility="unauth", keys are the mount path and value is it's mount data such as "type" or "description," if it exists
* */
export const CSP_ERROR =
@ -43,7 +43,7 @@ export default class AuthPage extends Component {
@tracked mfaAuthData;
@tracked mfaErrors = '';
get authTabData() {
get visibleMountsByType() {
const visibleAuthMounts = this.args.visibleAuthMounts;
if (visibleAuthMounts) {
const authMounts = visibleAuthMounts;

View file

@ -1,16 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if @type}}
<Hds::Text::Body @tag="p" @weight="semibold" data-test-auth-method={{@type}}>{{@type}}</Hds::Text::Body>
{{/if}}
{{#if @description}}
<Hds::Text::Body @tag="p" @color="faint">{{@description}} data-test-description</Hds::Text::Body>
{{/if}}
{{! the token auth method does't support custom paths so no need to render an input }}
{{#if @shouldRenderPath}}
{{! path is hidden so it is submitted with FormData but does not clutter the login form }}
<input type="hidden" id="path" name="path" value={{@path}} data-test-input="path" />
{{/if}}

View file

@ -5,32 +5,18 @@
<Hds::Tabs @onClickTab={{this.onClickTab}} @selectedTabIndex={{this.selectedTabIndex}} as |T|>
{{#each-in @authTabData as |methodType mounts|}}
<T.Tab data-test-auth-tab={{methodType}}>{{@displayNameHelper methodType}}</T.Tab>
<T.Tab data-test-auth-tab={{methodType}}>{{auth-display-name methodType}}</T.Tab>
<T.Panel>
<div class="has-top-padding-m">
{{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown.
However, for accessibility, we only want to render form inputs relevant to the selected method.
By wrapping the elements in this conditional, it only renders them when the tab is selected. }}
{{#if (eq @selectedAuthMethod methodType)}}
{{#if (gt mounts.length 1)}}
{{! render dropdown of mount paths }}
<Hds::Form::Select::Field name="path" data-test-select="path" as |F|>
<F.Label>Mount path</F.Label>
<F.Options>
{{#each mounts as |mount|}}
<option value={{mount.path}}>{{mount.path}}</option>
{{/each}}
</F.Options>
</Hds::Form::Select::Field>
{{else}}
{{#let (get mounts "0") as |mount|}}
<Auth::SingleMount
@description={{mount.description}}
@path={{mount.path}}
@shouldRenderPath={{not-eq @selectedAuthMethod "token"}}
/>
{{/let}}
{{/if}}
<Auth::MountsDisplay
@mounts={{mounts}}
@shouldRenderPath={{not-eq @selectedAuthMethod "token"}}
@hideType={{true}}
/>
{{/if}}
</div>
</T.Panel>

View file

@ -6,24 +6,27 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import type { AuthTabData } from 'vault/vault/auth/form';
import type { UnauthMountsByType } from 'vault/vault/auth/form';
interface Args {
authTabData: AuthTabData;
authTabTypes: string[];
authTabData: UnauthMountsByType;
handleTabClick: CallableFunction;
selectedAuthMethod: string;
}
export default class AuthTabs extends Component<Args> {
get tabTypes() {
return this.args.authTabData ? Object.keys(this.args.authTabData) : [];
}
get selectedTabIndex() {
const index = this.args.authTabTypes.indexOf(this.args.selectedAuthMethod);
const index = this.tabTypes.indexOf(this.args.selectedAuthMethod);
// negative index means the selected method isn't a tab, default to first tab
return index < 0 ? 0 : index;
}
@action
onClickTab(_event: Event, idx: number) {
this.args.handleTabClick(this.args.authTabTypes[idx]);
this.args.handleTabClick(this.tabTypes[idx]);
}
}

View file

@ -0,0 +1,11 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { ALL_LOGIN_METHODS } from 'vault/utils/supported-login-methods';
export default function authDisplayName(methodType: string) {
const displayName = ALL_LOGIN_METHODS?.find((t) => t.type === methodType)?.displayName;
return displayName || methodType;
}

View file

@ -106,11 +106,11 @@ export default class AuthRoute extends ClusterRouteBase {
*/
getMountOrTypeData(authMount, visibleAuthMounts) {
if (visibleAuthMounts?.[authMount]) {
return { path: authMount, ...visibleAuthMounts[authMount], hasMountData: true };
return { path: authMount, ...visibleAuthMounts[authMount], isVisibleMount: true };
}
const types = supportedTypes(this.version.isEnterprise);
if (types.includes(sanitizePath(authMount))) {
return { type: authMount, hasMountData: false };
return { type: authMount, isVisibleMount: false };
}
// `type` is necessary because it determines which login fields to render.
// If we can't safely glean it from the query param, ignore it and return null

View file

@ -103,8 +103,8 @@ module('Acceptance | auth login form', function (hooks) {
test('it renders preferred mount view if "with" query param is a mount path with listing_visibility="unauth"', async function (assert) {
await visit('/vault/auth?with=my-oidc%2F');
await waitFor(AUTH_FORM.preferredMethod('OIDC'));
assert.dom(AUTH_FORM.preferredMethod('OIDC')).hasText('OIDC', 'it renders mount type');
await waitFor(AUTH_FORM.preferredMethod('oidc'));
assert.dom(AUTH_FORM.preferredMethod('oidc')).hasText('OIDC', 'it renders mount type');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
@ -122,7 +122,7 @@ module('Acceptance | auth login form', function (hooks) {
assert
.dom(AUTH_FORM.tabBtn('oidc'))
.hasAttribute('aria-selected', 'true', 'it selects tab matching query param');
assert.dom(AUTH_FORM.preferredMethod('OIDC')).doesNotExist('it does not render single mount view');
assert.dom(AUTH_FORM.preferredMethod('oidc')).doesNotExist('it does not render single mount view');
assert.dom(GENERAL.backButton).doesNotExist();
});

View file

@ -7,7 +7,7 @@ export const AUTH_FORM = {
selectMethod: '[data-test-select="auth type"]',
form: '[data-test-auth-form]',
login: '[data-test-auth-submit]',
preferredMethod: (displayName: string) => `p[data-test-auth-method="${displayName}"]`, // display name => i.e "OIDC" not "oidc"
preferredMethod: (method: string) => `p[data-test-auth-method="${method}"]`,
tabs: '[data-test-auth-tab]',
tabBtn: (method: string) => `[data-test-auth-tab="${method}"] button`, // method is all lowercased
description: '[data-test-description]',

View file

@ -25,12 +25,12 @@ module('Integration | Component | auth | form template', function (hooks) {
setupMirage(hooks);
hooks.beforeEach(function () {
window.localStorage.clear();
this.version = this.owner.lookup('service:version');
this.authTabData = null;
this.visibleMountsByType = null;
this.cluster = { id: '1' };
this.directLinkData = null;
this.handleNamespaceUpdate = sinon.spy();
this.hasVisibleAuthMounts = false;
this.namespaceQueryParam = '';
this.oidcProviderQueryParam = '';
this.onSuccess = sinon.spy();
@ -39,11 +39,10 @@ module('Integration | Component | auth | form template', function (hooks) {
this.renderComponent = () => {
return render(hbs`
<Auth::FormTemplate
@authTabData={{this.authTabData}}
@visibleMountsByType={{this.visibleMountsByType}}
@cluster={{this.cluster}}
@directLinkData={{this.directLinkData}}
@handleNamespaceUpdate={{this.handleNamespaceUpdate}}
@hasVisibleAuthMounts={{this.hasVisibleAuthMounts}}
@namespaceQueryParam={{this.namespaceQueryParam}}
@oidcProviderQueryParam={{this.oidcProviderQueryParam}}
@onSuccess={{this.onSuccess}}
@ -67,7 +66,7 @@ module('Integration | Component | auth | form template', function (hooks) {
});
test('it selects type in the dropdown if @directLinkData data just contains type', async function (assert) {
this.directLinkData = { type: 'oidc', hasMountData: false };
this.directLinkData = { type: 'oidc', isVisibleMount: false };
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('oidc');
assert.dom(GENERAL.inputByAttr('role')).exists();
@ -96,8 +95,7 @@ module('Integration | Component | auth | form template', function (hooks) {
module('listing visibility', function (hooks) {
hooks.beforeEach(function () {
this.hasVisibleAuthMounts = true;
this.authTabData = {
this.visibleMountsByType = {
userpass: [
{
path: 'userpass/',
@ -185,7 +183,7 @@ module('Integration | Component | auth | form template', function (hooks) {
test('it renders the mount description', async function (assert) {
await this.renderComponent();
await click(AUTH_FORM.tabBtn('token'));
assert.dom('section p').hasText('token based credentials data-test-description');
assert.dom('section p').hasText('token based credentials');
});
test('it renders a dropdown if multiple mount paths are returned', async function (assert) {
@ -261,9 +259,9 @@ module('Integration | Component | auth | form template', function (hooks) {
// if mount data exists, the mount has listing_visibility="unauth"
test('it renders single mount view instead of tabs if @directLinkData data exists and includes mount data', async function (assert) {
this.directLinkData = { path: 'my-oidc/', type: 'oidc', hasMountData: true };
this.directLinkData = { path: 'my-oidc/', type: 'oidc', isVisibleMount: true };
await this.renderComponent();
assert.dom(AUTH_FORM.preferredMethod('OIDC')).hasText('OIDC', 'it renders mount type');
assert.dom(AUTH_FORM.preferredMethod('oidc')).hasText('OIDC', 'it renders mount type');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
@ -277,7 +275,7 @@ module('Integration | Component | auth | form template', function (hooks) {
test('it does not render tabs if @directLinkData data exists and just includes type', async function (assert) {
// set a type that is NOT in a visible mount because mount data exists otherwise
this.directLinkData = { type: 'ldap', hasMountData: false };
this.directLinkData = { type: 'ldap', isVisibleMount: false };
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap', 'dropdown has type selected');
@ -287,7 +285,7 @@ module('Integration | Component | auth | form template', function (hooks) {
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists();
assert.dom(AUTH_FORM.preferredMethod('LDAP')).doesNotExist('single mount view does not render');
assert.dom(AUTH_FORM.preferredMethod('ldap')).doesNotExist('single mount view does not render');
assert.dom(AUTH_FORM.tabBtn('ldap')).doesNotExist('tab does not render');
assert
.dom(GENERAL.backButton)

View file

@ -124,7 +124,7 @@ module('Integration | Component | auth | page', function (hooks) {
});
test('it selects type in the dropdown if @directLinkData references NON visible type', async function (assert) {
this.directLinkData = { type: 'ldap', hasMountData: false };
this.directLinkData = { type: 'ldap', isVisibleMount: false };
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap', 'dropdown has type selected');
assert.dom(AUTH_FORM.authForm('ldap')).exists();
@ -133,7 +133,7 @@ module('Integration | Component | auth | page', function (hooks) {
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists();
assert.dom(AUTH_FORM.preferredMethod('LDAP')).doesNotExist('single mount view does not render');
assert.dom(AUTH_FORM.preferredMethod('ldap')).doesNotExist('single mount view does not render');
assert.dom(AUTH_FORM.tabBtn('ldap')).doesNotExist('tab does not render');
assert
.dom(GENERAL.backButton)
@ -142,9 +142,9 @@ module('Integration | Component | auth | page', function (hooks) {
});
test('it renders single mount view instead of tabs if @directLinkData data references a visible type', async function (assert) {
this.directLinkData = { path: 'my-oidc/', type: 'oidc', hasMountData: true };
this.directLinkData = { path: 'my-oidc/', type: 'oidc', isVisibleMount: true };
await this.renderComponent();
assert.dom(AUTH_FORM.preferredMethod('OIDC')).hasText('OIDC', 'it renders mount type');
assert.dom(AUTH_FORM.preferredMethod('oidc')).hasText('OIDC', 'it renders mount type');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
export interface AuthTabData {
export interface UnauthMountsByType {
// key is the auth method type
[key: string]: AuthTabMountData[];
}