UI: Fix reset password error substate (#12429) (#12434)

* fix reset password error substate not rendering

* fix user dropdown not closing after route click

* add test coverage for stuck user menu;

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-02-19 18:29:00 -05:00 committed by GitHub
parent d8788f7792
commit 422acb5e1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 132 additions and 53 deletions

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -5,15 +5,13 @@
<Page::Header @title="Reset password" />
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
<A.Header @title="No password reset access" @titleTag="h2" />
<A.Body @text={{this.model.message}} />
<A.Footer as |F|>
<F.LinkStandalone
<Page::Error @error={{hash httpStatus=403 message=this.model.message}} @isFullPage={{true}}>
<:customFooter>
<Hds::Link::Standalone
@iconPosition="trailing"
@icon="docs-link"
@text="Update password API docs"
@href={{doc-link "/vault/api-docs/auth/userpass#update-password-on-user"}}
/>
</A.Footer>
</Hds::ApplicationState>
</:customFooter>
</Page::Error>

View File

@ -30,4 +30,8 @@
{{/each}}
</div>
{{/if}}
{{#if (has-block "customFooter")}}
{{yield to="customFooter"}}
{{/if}}
</div>

View File

@ -17,13 +17,17 @@
{{/if}}
{{#if this.hasEntityId}}
<D.Interactive @route="vault.cluster.mfa-setup" data-test-user-menu-item="mfa">
<D.Interactive @route="vault.cluster.mfa-setup" {{on "click" D.close}} data-test-user-menu-item="mfa">
Multi-factor authentication
</D.Interactive>
{{/if}}
{{#if this.isUserpass}}
<D.Interactive @route="vault.cluster.access.reset-password" data-test-user-menu-item="reset-password">Reset password</D.Interactive>
<D.Interactive
@route="vault.cluster.access.reset-password"
{{on "click" D.close}}
data-test-user-menu-item="reset-password"
>Reset password</D.Interactive>
{{/if}}
<D.Generic id="container">

View File

@ -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

View File

@ -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();
});
});

View File

@ -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[];