diff --git a/ui/app/routes/vault/cluster/access/reset-password.js b/ui/app/routes/vault/cluster/access/reset-password.js deleted file mode 100644 index f66a2ce150..0000000000 --- a/ui/app/routes/vault/cluster/access/reset-password.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Route from '@ember/routing/route'; -import { service } from '@ember/service'; -import { encodePath } from 'vault/utils/path-encoding-helpers'; - -const ERROR_UNAVAILABLE = 'Password reset is not available for your current auth mount.'; -const ERROR_NO_ACCESS = - 'You do not have permissions to update your password. If you think this is a mistake ask your administrator to update your policy.'; -export default class VaultClusterAccessResetPasswordRoute extends Route { - @service auth; - @service store; - - async model() { - // Password reset is only available on userpass type auth mounts - if (this.auth.authData?.authMethodType !== 'userpass') { - throw new Error(ERROR_UNAVAILABLE); - } - const { authMountPath, displayName } = this.auth.authData; - if (!authMountPath || !displayName) { - throw new Error(ERROR_UNAVAILABLE); - } - try { - const capabilities = await this.store.findRecord( - 'capabilities', - `auth/${encodePath(authMountPath)}/users/${encodePath(displayName)}/password` - ); - // Check that the user has ability to update password - if (!capabilities.canUpdate) { - throw new Error(ERROR_NO_ACCESS); - } - } catch (e) { - // If capabilities can't be queried, default to letting the API decide - } - return { - backend: authMountPath, - username: displayName, - }; - } -} diff --git a/ui/app/routes/vault/cluster/access/reset-password.ts b/ui/app/routes/vault/cluster/access/reset-password.ts new file mode 100644 index 0000000000..9f78fe771c --- /dev/null +++ b/ui/app/routes/vault/cluster/access/reset-password.ts @@ -0,0 +1,45 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +import type AuthService from 'vault/vault/services/auth'; +import type CapabilitiesService from 'vault/services/capabilities'; + +const ERROR_UNAVAILABLE = 'Password reset is not available for the current user.'; +const ERROR_NO_ACCESS = + 'You do not have permissions to update your password. If you think this is a mistake ask your administrator to update your policy.'; + +export default class VaultClusterAccessResetPasswordRoute extends Route { + @service declare readonly auth: AuthService; + @service declare readonly capabilities: CapabilitiesService; + + async model() { + const { authMethodType, authMountPath, displayName } = this.auth.authData; + // Password reset is only available on userpass type auth mounts + if (authMethodType !== 'userpass') { + throw new Error(ERROR_UNAVAILABLE); + } + + // Both of these are necessary to build the reset password URL + if (!authMountPath || !displayName) { + throw new Error(ERROR_UNAVAILABLE); + } + + const capabilities = await this.capabilities.fetchPathCapabilities( + `auth/${authMountPath}/users/${displayName}/password` + ); + + // Throw an error if we know for certain the user doesn't have permission + if (!capabilities.canUpdate) { + throw new Error(ERROR_NO_ACCESS); + } + return { + backend: authMountPath, + username: displayName, + }; + } +} diff --git a/ui/app/templates/vault/cluster/access/reset-password-error.hbs b/ui/app/templates/vault/cluster/access/reset-password-error.hbs index 487be1758f..70eaa1a5ab 100644 --- a/ui/app/templates/vault/cluster/access/reset-password-error.hbs +++ b/ui/app/templates/vault/cluster/access/reset-password-error.hbs @@ -5,15 +5,13 @@ - - - - - + <:customFooter> + - - \ No newline at end of file + + \ No newline at end of file diff --git a/ui/lib/core/addon/components/page/error.hbs b/ui/lib/core/addon/components/page/error.hbs index d739fbe909..5d61c52a70 100644 --- a/ui/lib/core/addon/components/page/error.hbs +++ b/ui/lib/core/addon/components/page/error.hbs @@ -30,4 +30,8 @@ {{/each}} {{/if}} + + {{#if (has-block "customFooter")}} + {{yield to="customFooter"}} + {{/if}} \ No newline at end of file diff --git a/ui/lib/core/addon/components/sidebar/user-menu.hbs b/ui/lib/core/addon/components/sidebar/user-menu.hbs index 123ca67f20..69e2f60cd2 100644 --- a/ui/lib/core/addon/components/sidebar/user-menu.hbs +++ b/ui/lib/core/addon/components/sidebar/user-menu.hbs @@ -17,13 +17,17 @@ {{/if}} {{#if this.hasEntityId}} - + Multi-factor authentication {{/if}} {{#if this.isUserpass}} - Reset password + Reset password {{/if}} diff --git a/ui/tests/acceptance/mfa-setup-test.js b/ui/tests/acceptance/mfa-setup-test.js index 62f54300c5..cdc5c99f2a 100644 --- a/ui/tests/acceptance/mfa-setup-test.js +++ b/ui/tests/acceptance/mfa-setup-test.js @@ -58,6 +58,12 @@ module('Acceptance | mfa-setup', function (hooks) { await click('[data-test-user-menu-item="mfa"]'); }); + test('it closes the dropdown after navigating', async function (assert) { + assert + .dom(GENERAL.button('user-menu-trigger')) + .hasAttribute('aria-expanded', 'false', 'dropdown closes after navigating to MFA'); + }); + test('it should login through MFA and post to generate and be able to restart the setup', async function (assert) { assert.expect(5); // the network requests required in this test diff --git a/ui/tests/acceptance/reset-password-test.js b/ui/tests/acceptance/reset-password-test.js index 998d73ae0f..44a94f8b61 100644 --- a/ui/tests/acceptance/reset-password-test.js +++ b/ui/tests/acceptance/reset-password-test.js @@ -4,12 +4,13 @@ */ import { module, test } from 'qunit'; -import { currentURL, click, fillIn, settled, waitFor } from '@ember/test-helpers'; +import { currentURL, click, fillIn, settled, waitFor, currentRouteName, visit } from '@ember/test-helpers'; import { setupApplicationTest } from 'vault/tests/helpers'; import { login, loginMethod } from 'vault/tests/helpers/auth/auth-helpers'; import { createPolicyCmd, deleteAuthCmd, mountAuthCmd, runCmd } from '../helpers/commands'; import { v4 as uuidv4 } from 'uuid'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import sinon from 'sinon'; const SUCCESS_MESSAGE = 'Successfully reset password'; @@ -70,4 +71,66 @@ module('Acceptance | reset password', function (hooks) { assert.dom('[data-test-flash-message]').hasText(`Success ${SUCCESS_MESSAGE}`); assert.dom('[data-test-input="reset-password"]').hasValue('', 'Resets input after save'); }); + + test('renders error template when user lacks update permission', async function (assert) { + await login(); + // Create a user with just the default policy + await runCmd([ + mountAuthCmd('userpass', this.userpass), + `write auth/${this.userpass}/users/no-access password=password`, + ]); + + await loginMethod( + { username: 'no-access', password: 'password', path: this.userpass }, + { authType: 'userpass', toggleOptions: true } + ); + + await click(GENERAL.button('user-menu-trigger')); + await click('[data-test-user-menu-item="reset-password"]'); + assert + .dom(GENERAL.button('user-menu-trigger')) + .hasAttribute('aria-expanded', 'false', 'dropdown closes after navigating'); + assert.dom(GENERAL.pageError.title(403)).hasText('403 Not Authorized'); + assert + .dom(GENERAL.pageError.message) + .hasText( + 'You do not have permissions to update your password. If you think this is a mistake ask your administrator to update your policy.' + ); + assert.strictEqual( + currentRouteName(), + 'vault.cluster.access.reset-password_error', + 'redirects to reset password route' + ); + }); + + test('renders error if auth data is unavailable', async function (assert) { + const authStub = sinon.stub(this.owner.lookup('service:auth'), 'authData'); + await login(); + authStub.value({}); + // Create a user with just the default policy + await runCmd([ + mountAuthCmd('userpass', this.userpass), + `write auth/${this.userpass}/users/no-access password=password`, + ]); + + await loginMethod( + { username: 'no-access', password: 'password', path: this.userpass }, + { authType: 'userpass', toggleOptions: true } + ); + + // Have to visit the route directly since user menu option is hidden when auth data unavailable + await visit('/vault/access/reset-password'); + assert.strictEqual( + currentRouteName(), + 'vault.cluster.access.reset-password_error', + 'redirects to reset password route' + ); + assert.dom(GENERAL.pageError.title(403)).hasText('403 Not Authorized'); + assert.dom(GENERAL.pageError.message).hasText('Password reset is not available for the current user.'); + assert + .dom(`${GENERAL.pageError.error} a`) + .exists() + .hasText('Update password API docs', 'it renders doc link'); + authStub.restore(); + }); }); diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts index f57e344db5..c450d6316f 100644 --- a/ui/types/vault/services/auth.d.ts +++ b/ui/types/vault/services/auth.d.ts @@ -12,6 +12,8 @@ import type { NormalizedAuthData, NormalizeAuthResponseKeys } from 'vault/auth/f import type { AuthResponseAuthKey, AuthResponseDataKey } from 'vault/auth/methods'; interface AuthData { + authMethodType: string; + authMountPath: string; userRootNamespace: string; token: string; policies: string[];