UI: Make single method views consistent, add max width (#30660)

* update single method to match single tab view

* add max-widht

* update tests

* convert page component to typescript

* add azure to icons, update custom-login mirage scenario

* update assertion count
This commit is contained in:
claire bontempo 2025-05-19 08:57:45 -05:00 committed by GitHub
parent b56f3c1135
commit 6964c093e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 94 additions and 85 deletions

View File

@ -40,20 +40,11 @@
<:authSelectOptions>
<div class="has-bottom-margin-m">
{{#if this.showCustomAuthOptions}}
{{#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={{@visibleMountsByType}}
@handleTabClick={{this.setAuthType}}
@selectedAuthMethod={{this.selectedAuthMethod}}
/>
{{/if}}
<Auth::Tabs
@authTabData={{this.tabData}}
@handleTabClick={{this.setAuthType}}
@selectedAuthMethod={{this.selectedAuthMethod}}
/>
{{else}}
{{! fallback view is the dropdown with all auth methods }}
<Hds::Form::Select::Field

View File

@ -64,6 +64,16 @@ export default class AuthFormTemplate extends Component<Args> {
@tracked selectedAuthMethod = '';
@tracked errorMessage = '';
get tabData() {
const { directLinkData } = this.args;
// 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
if (directLinkData?.isVisibleMount && directLinkData?.type) {
return { [directLinkData.type]: [this.args.directLinkData] };
}
return this.args.visibleMountsByType;
}
get authTabTypes() {
const visibleMounts = this.args.visibleMountsByType;
return visibleMounts ? Object.keys(visibleMounts) : [];

View File

@ -1,33 +0,0 @@
{{!
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

@ -8,6 +8,11 @@ import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import type { AuthResponse, AuthResponseWithMfa } from 'vault/vault/services/auth';
import type { UnauthMountsByType, UnauthMountsResponse } from 'vault/vault/auth/form';
import type ClusterModel from 'vault/models/cluster';
import type CspEventService from 'vault/services/csp-event';
/**
* @module AuthPage
* The Auth::Page is the route template for the login splash view. It renders the Auth::FormTemplate or MFA component if an
@ -24,23 +29,36 @@ import { action } from '@ember/object';
* @directLinkData={{this.model.directLinkData}}
* />
*
* @param {string} directLinkData - type or mount data gleaned from query param
* @param {object} cluster - 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 {object} loginSettings - * enterprise only * login settings configured for the namespace
* @param {string} namespaceQueryParam - namespace to login with, updated by typing in to the namespace input
* @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 - 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
* @param {object} visibleAuthMounts - response from unauthenticated request to sys/internal/ui/mounts which returns mount paths tuned with `listing_visibility="unauth"`. keys are the mount path, values are mount data such as "type" or "description," if it exists
* */
export const CSP_ERROR =
"This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.";
export default class AuthPage extends Component {
@service('csp-event') csp;
interface Args {
visibleAuthMounts: UnauthMountsResponse;
cluster: ClusterModel;
onAuthSuccess: CallableFunction;
}
interface MfaAuthData {
mfa_requirement: object;
selectedAuth: string;
path: string;
}
export default class AuthPage extends Component<Args> {
@service('csp-event') declare readonly csp: CspEventService;
@tracked canceledMfaAuth = '';
@tracked mfaAuthData;
@tracked mfaAuthData: MfaAuthData | null = null;
@tracked mfaErrors = '';
get visibleMountsByType() {
@ -52,7 +70,7 @@ export default class AuthPage extends Component {
obj[type] ??= []; // if an array doesn't already exist for that type, create it
obj[type].push({ path, ...mountData });
return obj;
}, {});
}, {} as UnauthMountsByType);
}
return null;
}
@ -64,8 +82,8 @@ export default class AuthPage extends Component {
}
@action
onAuthResponse(authResponse, { selectedAuth, path }) {
const { mfa_requirement } = authResponse;
onAuthResponse(authResponse: AuthResponse | AuthResponseWithMfa, { selectedAuth = '', path = '' }) {
const mfa_requirement = 'mfa_requirement' in authResponse ? authResponse.mfa_requirement : undefined;
/*
Checking for an mfa_requirement happens in two places.
If doSubmit in <AuthForm> is called directly (by the <form> component) mfa is just handled here.
@ -85,12 +103,12 @@ export default class AuthPage extends Component {
@action
onCancelMfa() {
// before resetting mfaAuthData, preserve auth type
this.canceledMfaAuth = this.mfaAuthData.selectedAuth;
this.canceledMfaAuth = this.mfaAuthData?.selectedAuth ?? '';
this.mfaAuthData = null;
}
@action
onMfaSuccess(authResponse) {
onMfaSuccess(authResponse: AuthResponse) {
// calls authSuccess in auth.js controller
this.args.onAuthSuccess(authResponse);
}

View File

@ -12,11 +12,29 @@
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)}}
<Auth::MountsDisplay
@mounts={{mounts}}
@shouldRenderPath={{not-eq @selectedAuthMethod "token"}}
@hideType={{true}}
/>
{{#if (gt mounts.length 1)}}
{{! DROPDOWN for 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}}
{{! SINGLE mount path }}
{{#let (get mounts "0") as |mount|}}
{{#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 (not-eq @selectedAuthMethod "token")}}
{{! 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}}
{{/if}}
</div>
</T.Panel>

View File

@ -13,6 +13,7 @@ const DOMAIN_STRINGS = {
'ping.com': 'Ping',
'okta.com': 'Okta',
'auth0.com': 'Auth0',
'login.microsoftonline.com': 'Azure',
};
const PROVIDER_WITH_LOGO = {
@ -21,6 +22,7 @@ const PROVIDER_WITH_LOGO = {
Google: 'google',
Okta: 'okta',
Auth0: 'auth0',
Azure: 'azure',
};
export { DOMAIN_STRINGS, PROVIDER_WITH_LOGO };

View File

@ -199,5 +199,6 @@
.column.is-4-desktop {
flex: none;
width: 33.33333%;
max-width: 600px;
}
}

View File

@ -8,7 +8,7 @@ export default function (server) {
name: 'Root namespace default',
namespace: '',
default_auth_type: 'userpass',
backup_auth_types: ['okta'],
backup_auth_types: ['okta', 'token'],
disable_inheritance: true,
});
server.create('login-rule', {
@ -16,7 +16,7 @@ export default function (server) {
default_auth_type: 'oidc',
backup_auth_types: ['token'],
});
server.create('login-rule', { default_auth_type: 'jwt', backup_auth_types: [] });
server.create('login-rule', { default_auth_type: '', backup_auth_types: ['oidc', 'jwt'] });
server.create('login-rule', { default_auth_type: '', backup_auth_types: ['token'] });
server.create('login-rule', { default_auth_type: 'jwt', backup_auth_types: null }); // namespace-2
server.create('login-rule', { default_auth_type: '', backup_auth_types: ['oidc', 'jwt'] }); // namespace-3
server.create('login-rule', { default_auth_type: '', backup_auth_types: ['token'] }); // namespace-4
}

View File

@ -103,15 +103,15 @@ 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.tabBtn('oidc'));
assert.dom(AUTH_FORM.authForm('oidc')).exists();
assert.dom(AUTH_FORM.tabBtn('oidc')).exists();
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();
});
@ -122,7 +122,9 @@ 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(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
assert.dom(GENERAL.backButton).doesNotExist();
});

View File

@ -7,7 +7,6 @@ export const AUTH_FORM = {
selectMethod: '[data-test-select="auth type"]',
form: '[data-test-auth-form]',
login: '[data-test-auth-submit]',
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

@ -183,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');
assert.dom(AUTH_FORM.description).hasText('token based credentials');
});
test('it renders a dropdown if multiple mount paths are returned', async function (assert) {
@ -261,14 +261,15 @@ module('Integration | Component | auth | form template', function (hooks) {
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', isVisibleMount: true };
await this.renderComponent();
assert.dom(AUTH_FORM.preferredMethod('oidc')).hasText('OIDC', 'it renders mount type');
assert.dom(AUTH_FORM.authForm('oidc')).exists;
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders auth type tab');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();
});
@ -284,8 +285,6 @@ module('Integration | Component | auth | form template', function (hooks) {
assert.dom(GENERAL.inputByAttr('password')).exists();
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.tabBtn('ldap')).doesNotExist('tab does not render');
assert
.dom(GENERAL.backButton)

View File

@ -132,8 +132,6 @@ module('Integration | Component | auth | page', function (hooks) {
assert.dom(GENERAL.inputByAttr('password')).exists();
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.tabBtn('ldap')).doesNotExist('tab does not render');
assert
.dom(GENERAL.backButton)
@ -144,13 +142,11 @@ 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', isVisibleMount: true };
await this.renderComponent();
assert.dom(AUTH_FORM.preferredMethod('oidc')).hasText('OIDC', 'it renders mount type');
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders tab for type');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();

View File

@ -28,7 +28,7 @@ module('Unit | Model | role-jwt', function (hooks) {
});
test('it provides a providerName for listed known providers', function (assert) {
assert.expect(12);
assert.expect(14);
Object.keys(DOMAIN_STRINGS).forEach((domain) => {
const model = this.owner.lookup('service:store').createRecord('role-jwt', {
authUrl: `http://provider-${domain}`,

View File

@ -7,6 +7,10 @@ export interface UnauthMountsByType {
// key is the auth method type
[key: string]: AuthTabMountData[];
}
export interface UnauthMountsResponse {
// key is the mount path
[key: string]: { type: string; description?: string; config?: object | null };
}
export interface AuthTabMountData {
path: string;

View File

@ -16,7 +16,6 @@ export interface AuthData {
renewable: boolean;
entity_id: string;
displayName?: string;
mfa_requirement?: MfaRequirementApiResponse;
}
export interface AuthResponse {
@ -24,6 +23,9 @@ export interface AuthResponse {
token: string; // the name of the token in local storage, not the actual token
isRoot: boolean;
}
export interface AuthResponseWithMfa {
mfa_requirement: MfaRequirementApiResponse;
}
export default class AuthService extends Service {
authData: AuthData;