From 6964c093e71ce3da6211f0e4ec320a154cfa036b Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Mon, 19 May 2025 08:57:45 -0500 Subject: [PATCH] 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 --- ui/app/components/auth/form-template.hbs | 19 +++------- ui/app/components/auth/form-template.ts | 10 +++++ ui/app/components/auth/mounts-display.hbs | 33 ---------------- ui/app/components/auth/{page.js => page.ts} | 38 ++++++++++++++----- ui/app/components/auth/tabs.hbs | 28 +++++++++++--- ui/app/models/role-jwt.js | 2 + ui/app/styles/core/columns.scss | 1 + ui/mirage/scenarios/custom-login.js | 8 ++-- ui/tests/acceptance/auth/auth-test.js | 12 +++--- ui/tests/helpers/auth/auth-form-selectors.ts | 1 - .../components/auth/form-template-test.js | 11 +++--- .../integration/components/auth/page-test.js | 6 +-- ui/tests/unit/models/role-jwt-test.js | 2 +- ui/types/vault/auth/form.d.ts | 4 ++ ui/types/vault/services/auth.d.ts | 4 +- 15 files changed, 94 insertions(+), 85 deletions(-) delete mode 100644 ui/app/components/auth/mounts-display.hbs rename ui/app/components/auth/{page.js => page.ts} (68%) diff --git a/ui/app/components/auth/form-template.hbs b/ui/app/components/auth/form-template.hbs index 50250f9fdc..b4a24c1c47 100644 --- a/ui/app/components/auth/form-template.hbs +++ b/ui/app/components/auth/form-template.hbs @@ -40,20 +40,11 @@ <:authSelectOptions>
{{#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 }} - - {{else}} - - {{/if}} + {{else}} {{! fallback view is the dropdown with all auth methods }} { @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) : []; diff --git a/ui/app/components/auth/mounts-display.hbs b/ui/app/components/auth/mounts-display.hbs deleted file mode 100644 index 841986f326..0000000000 --- a/ui/app/components/auth/mounts-display.hbs +++ /dev/null @@ -1,33 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -}} - -{{#if (gt @mounts.length 1)}} - {{! render dropdown of mount paths }} - - Mount path - - {{#each @mounts as |mount|}} - - {{/each}} - - -{{else}} - {{! render a single mount path }} - {{#let (get @mounts "0") as |mount|}} - {{#unless @hideType}} - - {{auth-display-name mount.type}} - - {{/unless}} - {{#if mount.description}} - {{mount.description}} - {{/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 }} - - {{/if}} - {{/let}} -{{/if}} \ No newline at end of file diff --git a/ui/app/components/auth/page.js b/ui/app/components/auth/page.ts similarity index 68% rename from ui/app/components/auth/page.js rename to ui/app/components/auth/page.ts index dbb0841525..b0c1c9a3c0 100644 --- a/ui/app/components/auth/page.js +++ b/ui/app/components/auth/page.ts @@ -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 { + @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 is called directly (by the
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); } diff --git a/ui/app/components/auth/tabs.hbs b/ui/app/components/auth/tabs.hbs index 1a8c22c172..1188522b0f 100644 --- a/ui/app/components/auth/tabs.hbs +++ b/ui/app/components/auth/tabs.hbs @@ -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)}} - + {{#if (gt mounts.length 1)}} + {{! DROPDOWN for mount paths }} + + Mount path + + {{#each mounts as |mount|}} + + {{/each}} + + + {{else}} + {{! SINGLE mount path }} + {{#let (get mounts "0") as |mount|}} + {{#if mount.description}} + {{mount.description}} + {{/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 }} + + {{/if}} + {{/let}} + {{/if}} {{/if}}
diff --git a/ui/app/models/role-jwt.js b/ui/app/models/role-jwt.js index 965364848f..bf5505f3a7 100644 --- a/ui/app/models/role-jwt.js +++ b/ui/app/models/role-jwt.js @@ -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 }; diff --git a/ui/app/styles/core/columns.scss b/ui/app/styles/core/columns.scss index a8e029e589..e8437cb2c0 100644 --- a/ui/app/styles/core/columns.scss +++ b/ui/app/styles/core/columns.scss @@ -199,5 +199,6 @@ .column.is-4-desktop { flex: none; width: 33.33333%; + max-width: 600px; } } diff --git a/ui/mirage/scenarios/custom-login.js b/ui/mirage/scenarios/custom-login.js index 44d45f7e50..5fe4bf312e 100644 --- a/ui/mirage/scenarios/custom-login.js +++ b/ui/mirage/scenarios/custom-login.js @@ -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 } diff --git a/ui/tests/acceptance/auth/auth-test.js b/ui/tests/acceptance/auth/auth-test.js index a2d9ec8dd7..4414888577 100644 --- a/ui/tests/acceptance/auth/auth-test.js +++ b/ui/tests/acceptance/auth/auth-test.js @@ -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(); }); diff --git a/ui/tests/helpers/auth/auth-form-selectors.ts b/ui/tests/helpers/auth/auth-form-selectors.ts index 5f4f34cf90..762060a039 100644 --- a/ui/tests/helpers/auth/auth-form-selectors.ts +++ b/ui/tests/helpers/auth/auth-form-selectors.ts @@ -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]', diff --git a/ui/tests/integration/components/auth/form-template-test.js b/ui/tests/integration/components/auth/form-template-test.js index 99eeee1304..ab1db62d7c 100644 --- a/ui/tests/integration/components/auth/form-template-test.js +++ b/ui/tests/integration/components/auth/form-template-test.js @@ -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) diff --git a/ui/tests/integration/components/auth/page-test.js b/ui/tests/integration/components/auth/page-test.js index d1d41c8f3c..d855041ab5 100644 --- a/ui/tests/integration/components/auth/page-test.js +++ b/ui/tests/integration/components/auth/page-test.js @@ -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(); diff --git a/ui/tests/unit/models/role-jwt-test.js b/ui/tests/unit/models/role-jwt-test.js index 71777c4c7e..6f2e1775df 100644 --- a/ui/tests/unit/models/role-jwt-test.js +++ b/ui/tests/unit/models/role-jwt-test.js @@ -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}`, diff --git a/ui/types/vault/auth/form.d.ts b/ui/types/vault/auth/form.d.ts index 409acac270..cf32073d7f 100644 --- a/ui/types/vault/auth/form.d.ts +++ b/ui/types/vault/auth/form.d.ts @@ -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; diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts index 4e91797ca3..dee261b39c 100644 --- a/ui/types/vault/services/auth.d.ts +++ b/ui/types/vault/services/auth.d.ts @@ -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;