diff --git a/changelog/_9944.txt b/changelog/_9944.txt new file mode 100644 index 0000000000..77e7da97b7 --- /dev/null +++ b/changelog/_9944.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui (enterprise): Fixes login form so input renders correctly when token is a preferred login method for a namespace. +``` diff --git a/ui/app/components/auth/page.ts b/ui/app/components/auth/page.ts index 186a5e1c3e..cb0f7d786d 100644 --- a/ui/app/components/auth/page.ts +++ b/ui/app/components/auth/page.ts @@ -9,7 +9,8 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import type { AuthSuccessResponse } from 'vault/vault/services/auth'; -import type { NormalizedAuthData, UnauthMountsByType, UnauthMountsResponse } from 'vault/vault/auth/form'; +import type AuthMethodResource from 'vault/resources/auth/method'; +import type { NormalizedAuthData, UnauthMountsByType } from 'vault/vault/auth/form'; import type AuthService from 'vault/vault/services/auth'; import type ClusterModel from 'vault/models/cluster'; import type CspEventService from 'vault/services/csp-event'; @@ -76,7 +77,7 @@ import type { Task } from 'ember-concurrency'; * @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider" * @param {function} loginAndTransition - 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 - 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 + * @param {object} visibleAuthMounts - array of AuthMethodResources from unauthenticated request to sys/internal/ui/mounts which returns mount paths tuned with `listing_visibility="unauth"` * */ export const CSP_ERROR = @@ -88,7 +89,7 @@ interface Args { loginAndTransition: Task; loginSettings: { defaultType: string; backupTypes: string[] | null }; // enterprise only roleQueryParam?: string; - visibleAuthMounts: UnauthMountsResponse; + visibleAuthMounts: AuthMethodResource[]; } enum FormView { @@ -112,11 +113,10 @@ export default class AuthPage extends Component { get visibleMountsByType() { const visibleAuthMounts = this.args.visibleAuthMounts; if (visibleAuthMounts) { - const authMounts = visibleAuthMounts; - return Object.entries(authMounts).reduce((obj, [path, mountData]) => { - const { type } = mountData; - obj[type] ??= []; // if an array doesn't already exist for that type, create it - obj[type].push({ path, ...mountData }); + return visibleAuthMounts.reduce((obj, authMount) => { + const { methodType } = authMount; + obj[methodType] ??= []; // if an array doesn't already exist for that methodType, create it + obj[methodType].push({ ...authMount }); return obj; }, {} as UnauthMountsByType); } diff --git a/ui/app/routes/vault/cluster/auth.js b/ui/app/routes/vault/cluster/auth.js index 5113f9275d..1d1be7ddc7 100644 --- a/ui/app/routes/vault/cluster/auth.js +++ b/ui/app/routes/vault/cluster/auth.js @@ -6,9 +6,9 @@ import { service } from '@ember/service'; import ClusterRouteBase from './cluster-route-base'; import config from 'vault/config/environment'; -import { isEmptyValue } from 'core/helpers/is-empty-value'; import { supportedTypes } from 'vault/utils/auth-form-helpers'; import { sanitizePath } from 'core/utils/sanitize-path'; +import AuthMethodResource from 'vault/resources/auth/method'; export default class AuthRoute extends ClusterRouteBase { queryParams = { @@ -112,8 +112,11 @@ export default class AuthRoute extends ClusterRouteBase { const resp = await this.api.sys.internalUiListEnabledVisibleMounts( this.api.buildHeaders({ token: '' }) ); - // return a falsy value if the object is empty - return isEmptyValue(resp.auth) ? null : resp.auth; + const authMounts = this.api.responseObjectToArray(resp.auth, 'path').flatMap((method) => { + const resource = new AuthMethodResource(method, this); + return this.isSupported(resource.methodType) ? [resource] : []; + }); + return authMounts.length ? authMounts : null; } catch { // catch error if there's a problem fetching mount data (i.e. invalid namespace) return null; @@ -133,17 +136,18 @@ export default class AuthRoute extends ClusterRouteBase { const sanitizedParam = sanitizePath(authMount); // strip leading/trailing slashes // mount paths in visibleAuthMounts always end in a slash, so format for consistency const formattedPath = `${sanitizedParam}/`; - const mountData = visibleAuthMounts?.[formattedPath]; + const mountData = visibleAuthMounts?.find((a) => a.path === formattedPath); if (mountData) { return { path: formattedPath, type: mountData.type }; } - const types = supportedTypes(this.version.isEnterprise); - if (types.includes(sanitizedParam)) { + if (this.isSupported(sanitizedParam)) { return { type: sanitizedParam }; } // `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. return null; } + + isSupported = (type = '') => supportedTypes(this.version.isEnterprise).includes(type); } diff --git a/ui/tests/acceptance/auth/auth-login-test.js b/ui/tests/acceptance/auth/auth-login-test.js index 84629a13f8..e77ee25023 100644 --- a/ui/tests/acceptance/auth/auth-login-test.js +++ b/ui/tests/acceptance/auth/auth-login-test.js @@ -106,6 +106,21 @@ module('Acceptance | auth login', function (hooks) { assert.dom(AUTH_FORM.tabs).doesNotExist(); }); + test('it renders default view if sys/internal/ui/mounts has unsupported type', async function (assert) { + this.server.get('/sys/internal/ui/mounts', () => { + return { data: { auth: { 'custom-auth/': { type: 'custom-auth' } } } }; + }); + await logout(); + await visit('/vault/auth'); + await waitFor(AUTH_FORM.form); + assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders'); + assert.dom(GENERAL.selectByAttr('auth type')).hasValue('token', 'dropdown has default "Token" selected'); + // dropdown could still render in "Sign in with other methods" view, so make sure we're not in a weird state + assert.dom(GENERAL.backButton).doesNotExist('it does not render "Back" button'); + assert.dom(AUTH_FORM.authForm('token')).exists('it renders token form'); + assert.dom(AUTH_FORM.tabs).doesNotExist('it does not render auth tabs'); + }); + module('listing visibility', function (hooks) { hooks.beforeEach(async function () { this.server.get('/sys/internal/ui/mounts', () => { diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts index bd1b48df35..8e4825e1e1 100644 --- a/ui/tests/helpers/auth/auth-helpers.ts +++ b/ui/tests/helpers/auth/auth-helpers.ts @@ -7,8 +7,10 @@ import { click, currentRouteName, fillIn, visit, waitUntil } from '@ember/test-h import VAULT_KEYS from 'vault/tests/helpers/vault-keys'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import AuthMethodResource from 'vault/resources/auth/method'; import type { LoginFields } from 'vault/vault/auth/form'; +import type ApiService from 'vault/services/api'; export const { rootToken } = VAULT_KEYS; @@ -118,3 +120,7 @@ export const SYS_INTERNAL_UI_MOUNTS = { type: 'ldap', }, }; + +// helper function to stub logic in the AuthRoute to format visibleAuthMounts for integration tests +export const formatAuthMounts = (api: ApiService, mounts = SYS_INTERNAL_UI_MOUNTS) => + api.responseObjectToArray(mounts, 'path').map((method) => new AuthMethodResource(method, this)); diff --git a/ui/tests/integration/components/auth/page/listing-visibility-test.js b/ui/tests/integration/components/auth/page/listing-visibility-test.js index 417658a10d..9aad8b65f1 100644 --- a/ui/tests/integration/components/auth/page/listing-visibility-test.js +++ b/ui/tests/integration/components/auth/page/listing-visibility-test.js @@ -5,7 +5,7 @@ import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { click, fillIn, waitFor } from '@ember/test-helpers'; -import { fillInLoginFields, SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers'; +import { fillInLoginFields, formatAuthMounts } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { module, test } from 'qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; @@ -20,7 +20,8 @@ module('Integration | Component | auth | page | listing visibility', function (h hooks.beforeEach(function () { setupTestContext(this); - this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS; + this.api = this.owner.lookup('service:api'); + this.visibleAuthMounts = formatAuthMounts(this.api); // extra setup for when the "oidc" is selected and the oidc-jwt component renders this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); }); @@ -77,6 +78,17 @@ module('Integration | Component | auth | page | listing visibility', function (h .exists('"Sign in with other methods" renders again'); }); + test('it renders tabs for types prefixed with "ns_"', async function (assert) { + this.visibleAuthMounts = formatAuthMounts(this.api, { 'token/': { type: 'ns_token' } }); + await this.renderComponent(); + assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render'); + assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'it renders 1 tab'); + assert.dom(AUTH_FORM.tabBtn('token')).exists(`token renders as a tab`); + assert + .dom(AUTH_FORM.tabBtn('token')) + .hasAttribute('aria-selected', 'true', 'it selects the first type by default'); + }); + // integration tests for ?with= query param module('with a direct link', function (hooks) { hooks.beforeEach(function () { diff --git a/ui/tests/integration/components/auth/page/login-settings-test.js b/ui/tests/integration/components/auth/page/login-settings-test.js index 03cb239d03..4c57403845 100644 --- a/ui/tests/integration/components/auth/page/login-settings-test.js +++ b/ui/tests/integration/components/auth/page/login-settings-test.js @@ -8,9 +8,10 @@ import { click } from '@ember/test-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers'; +import { formatAuthMounts, SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers'; import setupTestContext from './setup-test-context'; import sinon from 'sinon'; +import AuthMethodResource from 'vault/resources/auth/method'; /* Login settings are an enterprise only feature but the component is version agnostic (and subsequently so are these tests) @@ -118,7 +119,7 @@ module('Integration | Component | auth | page | ent login settings', function (h defaultType: 'oidc', backupTypes: ['userpass', 'ldap'], }; - this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS; + this.visibleAuthMounts = formatAuthMounts(this.api); }); test('(default+backups): it hides advanced settings for both views', async function (assert) { @@ -168,11 +169,20 @@ module('Integration | Component | auth | page | ent login settings', function (h defaultType: 'oidc', backupTypes: ['userpass', 'ldap'], }; - this.mountData = (path) => ({ [path]: SYS_INTERNAL_UI_MOUNTS[path] }); + this.setVisibleMounts = (...paths) => { + // Builds an object in the shape of SYS_INTERNAL_UI_MOUNTS response with only the provided ...paths + const mounts = Object.fromEntries( + paths + .filter((path) => SYS_INTERNAL_UI_MOUNTS[path]) + .map((path) => [path, SYS_INTERNAL_UI_MOUNTS[path]]) + ); + // Format stubbed response the same way the auth route does + return formatAuthMounts(this.api, mounts); + }; }); test('(default+backups): it hides advanced settings for default with visible mount but it renders for backups', async function (assert) { - this.visibleAuthMounts = { ...this.mountData('my_oidc/') }; + this.visibleAuthMounts = this.setVisibleMounts('my_oidc/'); await this.renderComponent(); this.assertPathInput(assert, { isHidden: true, value: 'my_oidc/' }); await click(GENERAL.button('Sign in with other methods')); @@ -184,11 +194,7 @@ module('Integration | Component | auth | page | ent login settings', function (h test('(default+backups): it only renders advanced settings for method without mounts', async function (assert) { // default and only one backup method have visible mounts - this.visibleAuthMounts = { - ...this.mountData('my_oidc/'), - ...this.mountData('userpass/'), - ...this.mountData('userpass2/'), - }; + this.visibleAuthMounts = this.setVisibleMounts('my_oidc/', 'userpass/', 'userpass2/'); await this.renderComponent(); assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); await click(GENERAL.button('Sign in with other methods')); @@ -200,7 +206,8 @@ module('Integration | Component | auth | page | ent login settings', function (h }); test('(default+backups): it hides advanced settings for single backup method with mounts', async function (assert) { - this.visibleAuthMounts = { ...this.mountData('ldap/') }; + this.visibleAuthMounts = this.setVisibleMounts('ldap/'); + await this.renderComponent(); assert.dom(AUTH_FORM.authForm('oidc')).exists(); assert.dom(AUTH_FORM.advancedSettings).exists(); @@ -213,7 +220,7 @@ module('Integration | Component | auth | page | ent login settings', function (h test('(backups only): it hides advanced settings for single method with mounts', async function (assert) { this.loginSettings.defaultType = ''; - this.visibleAuthMounts = { ...this.mountData('ldap/') }; + this.visibleAuthMounts = this.setVisibleMounts('ldap/'); await this.renderComponent(); assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); assert.dom(AUTH_FORM.advancedSettings).exists(); @@ -224,7 +231,7 @@ module('Integration | Component | auth | page | ent login settings', function (h module('@directLinkData overrides login settings', function (hooks) { hooks.beforeEach(function () { - this.mountData = SYS_INTERNAL_UI_MOUNTS; + this.mountData = formatAuthMounts(this.api); }); module('when there are no visible mounts at all', function (hooks) { @@ -260,14 +267,10 @@ module('Integration | Component | auth | page | ent login settings', function (h module('when param matches a visible mount path and other visible mounts exist', function (hooks) { hooks.beforeEach(function () { - this.visibleAuthMounts = { + this.visibleAuthMounts = [ ...this.mountData, - 'my-okta/': { - description: '', - options: null, - type: 'okta', - }, - }; + new AuthMethodResource({ path: 'my-okta/', type: 'okta' }), + ]; this.directLinkData = { path: 'my-okta/', type: 'okta' }; }); diff --git a/ui/tests/integration/components/auth/page/setup-test-context.js b/ui/tests/integration/components/auth/page/setup-test-context.js index 16552a9bc1..ea124117a7 100644 --- a/ui/tests/integration/components/auth/page/setup-test-context.js +++ b/ui/tests/integration/components/auth/page/setup-test-context.js @@ -9,6 +9,7 @@ import sinon from 'sinon'; export default (context) => { context.version = context.owner.lookup('service:version'); + context.api = context.owner.lookup('service:api'); context.cluster = { id: '1' }; context.directLinkData = null; context.loginSettings = null; @@ -17,7 +18,7 @@ export default (context) => { // mocking as an object with the `perform()` method because loginAndTransition is a concurrency task context.loginAndTransition = { perform: sinon.spy() }; context.onNamespaceUpdate = sinon.spy(); - context.visibleAuthMounts = false; + context.visibleAuthMounts = null; context.renderComponent = () => { return render(hbs`