diff --git a/changelog/23797.txt b/changelog/23797.txt
new file mode 100644
index 0000000000..32369b0fbd
--- /dev/null
+++ b/changelog/23797.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: Allow users in userpass auth mount to update their own password
+```
\ No newline at end of file
diff --git a/ui/app/adapters/auth-method.js b/ui/app/adapters/auth-method.js
index 01d7465174..0dd2896b50 100644
--- a/ui/app/adapters/auth-method.js
+++ b/ui/app/adapters/auth-method.js
@@ -78,4 +78,10 @@ export default ApplicationAdapter.extend({
const url = `${this.buildURL()}/${this.pathForType()}/${encodePath(path)}tune`;
return this.ajax(url, 'POST', { data });
},
+
+ resetPassword(backend, username, password) {
+ // For userpass auth types only
+ const url = `/v1/auth/${encodePath(backend)}/users/${encodePath(username)}/password`;
+ return this.ajax(url, 'POST', { data: { password } });
+ },
});
diff --git a/ui/app/components/page/userpass-reset-password.hbs b/ui/app/components/page/userpass-reset-password.hbs
new file mode 100644
index 0000000000..c4e25ef314
--- /dev/null
+++ b/ui/app/components/page/userpass-reset-password.hbs
@@ -0,0 +1,45 @@
+
+
+
+ Reset password
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/components/page/userpass-reset-password.js b/ui/app/components/page/userpass-reset-password.js
new file mode 100644
index 0000000000..e269a93723
--- /dev/null
+++ b/ui/app/components/page/userpass-reset-password.js
@@ -0,0 +1,38 @@
+import { service } from '@ember/service';
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { task } from 'ember-concurrency';
+import errorMessage from 'vault/utils/error-message';
+
+export default class PageUserpassResetPasswordComponent extends Component {
+ @service store;
+ @service flashMessages;
+
+ @tracked newPassword = '';
+ @tracked error = '';
+
+ onSuccess() {
+ this.error = '';
+ this.newPassword = '';
+ this.flashMessages.success('Successfully reset password');
+ }
+
+ @task
+ *updatePassword(evt) {
+ evt.preventDefault();
+ this.error = '';
+ const adapter = this.store.adapterFor('auth-method');
+ const { backend, username } = this.args;
+ if (!backend || !username) return;
+ if (!this.newPassword) {
+ this.error = 'Please provide a new password.';
+ return;
+ }
+ try {
+ yield adapter.resetPassword(backend, username, this.newPassword);
+ this.onSuccess();
+ } catch (e) {
+ this.error = errorMessage(e, 'Check Vault logs for details');
+ }
+ }
+}
diff --git a/ui/app/components/sidebar/user-menu.hbs b/ui/app/components/sidebar/user-menu.hbs
index 3021c84337..d15d368f25 100644
--- a/ui/app/components/sidebar/user-menu.hbs
+++ b/ui/app/components/sidebar/user-menu.hbs
@@ -39,6 +39,13 @@
{{/if}}
+ {{#if this.isUserpass}}
+
+
+ Reset password
+
+
+ {{/if}}
{{! container is required in navbar collapsed state }}
get(resp, name)).join('/');
- } else {
- displayName = get(resp, currentBackend.displayNamePath);
- }
-
- const { entity_id, policies, renewable, namespace_path } = resp;
+ calculateRootNamespace(currentNamespace, namespace_path, backend) {
// here we prefer namespace_path if its defined,
// else we look and see if there's already a namespace saved
// and then finally we'll use the current query param if the others
@@ -266,6 +245,39 @@ export default Service.extend({
if (typeof userRootNamespace === 'undefined') {
userRootNamespace = currentNamespace;
}
+ return userRootNamespace;
+ },
+
+ persistAuthData() {
+ const [firstArg, resp] = arguments;
+ const tokens = this.tokens;
+ const currentNamespace = this.namespaceService.path || '';
+ // Tab vs dropdown format
+ const mountPath = firstArg?.selectedAuth || firstArg?.data?.path;
+ let tokenName;
+ let options;
+ let backend;
+ if (typeof firstArg === 'string') {
+ tokenName = firstArg;
+ backend = this.backendFromTokenName(tokenName);
+ } else {
+ options = firstArg;
+ backend = options.backend;
+ }
+
+ const currentBackend = {
+ mountPath,
+ ...BACKENDS.findBy('type', backend),
+ };
+ let displayName;
+ if (isArray(currentBackend.displayNamePath)) {
+ displayName = currentBackend.displayNamePath.map((name) => get(resp, name)).join('/');
+ } else {
+ displayName = get(resp, currentBackend.displayNamePath);
+ }
+
+ const { entity_id, policies, renewable, namespace_path } = resp;
+ const userRootNamespace = this.calculateRootNamespace(currentNamespace, namespace_path, backend);
const data = {
userRootNamespace,
displayName,
@@ -285,7 +297,7 @@ export default Service.extend({
);
if (resp.renewable) {
- assign(data, this.calculateExpiration(resp));
+ Object.assign(data, this.calculateExpiration(resp));
}
if (!data.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
new file mode 100644
index 0000000000..3c193957dc
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/reset-password-error.hbs
@@ -0,0 +1,14 @@
+
+
+
+ Reset password
+
+
+
+
+
+
+ Learn more
+ about updating passwords
+ here.
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/reset-password.hbs b/ui/app/templates/vault/cluster/access/reset-password.hbs
new file mode 100644
index 0000000000..aa8a9e239b
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/reset-password.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/tests/acceptance/reset-password-test.js b/ui/tests/acceptance/reset-password-test.js
new file mode 100644
index 0000000000..59325ad04a
--- /dev/null
+++ b/ui/tests/acceptance/reset-password-test.js
@@ -0,0 +1,47 @@
+import { module, test } from 'qunit';
+import sinon from 'sinon';
+import { currentURL, click, fillIn, settled } from '@ember/test-helpers';
+import { setupApplicationTest } from 'vault/tests/helpers';
+import authPage from 'vault/tests/pages/auth';
+import { createPolicyCmd, mountAuthCmd, runCmd } from '../helpers/commands';
+
+const resetPolicy = `
+path "auth/userpass/users/reset-me/password" {
+ capabilities = ["update", "create"]
+}
+`;
+module('Acceptance | reset password', function (hooks) {
+ setupApplicationTest(hooks);
+
+ test('does not allow password reset for non-userpass users', async function (assert) {
+ await authPage.login();
+ await settled();
+
+ await click('[data-test-user-menu-trigger]');
+ assert.dom('[data-test-user-menu-item="reset-password"]').doesNotExist();
+ });
+
+ test('allows password reset for userpass users', async function (assert) {
+ const flashMessages = this.owner.lookup('service:flashMessages');
+ const flashSpy = sinon.spy(flashMessages, 'success');
+ await authPage.login();
+ await runCmd([
+ mountAuthCmd('userpass'),
+ createPolicyCmd('userpass', resetPolicy),
+ 'write auth/userpass/users/reset-me password=password token_policies=userpass',
+ ]);
+ await authPage.loginUsername('reset-me', 'password');
+
+ await click('[data-test-user-menu-trigger]');
+ await click('[data-test-user-menu-item="reset-password"]');
+
+ assert.strictEqual(currentURL(), '/vault/access/reset-password', 'links to password reset');
+
+ assert.dom('[data-test-title]').hasText('Reset password', 'page title');
+ await fillIn('[data-test-textarea]', 'newpassword');
+ await click('[data-test-reset-password-save]');
+
+ assert.true(flashSpy.calledOnceWith('Successfully reset password'), 'Shows success message');
+ assert.dom('[data-test-textarea]').hasValue('', 'Resets input after save');
+ });
+});
diff --git a/ui/tests/integration/components/page/userpass-reset-password-test.js b/ui/tests/integration/components/page/userpass-reset-password-test.js
new file mode 100644
index 0000000000..4d3512d42b
--- /dev/null
+++ b/ui/tests/integration/components/page/userpass-reset-password-test.js
@@ -0,0 +1,68 @@
+import { module, test } from 'qunit';
+import sinon from 'sinon';
+import { setupRenderingTest } from 'vault/tests/helpers';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { Response } from 'miragejs';
+import { click, fillIn, render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+const S = {
+ infoBanner: '[data-test-current-user-banner]',
+ save: '[data-test-reset-password-save]',
+ error: '[data-test-reset-password-error]',
+ input: '[data-test-textarea]',
+};
+module('Integration | Component | page/userpass-reset-password', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.backend = 'userpass3';
+ this.username = 'alice';
+ });
+
+ test('form works -- happy path', async function (assert) {
+ assert.expect(5);
+ const flashMessages = this.owner.lookup('service:flashMessages');
+ const flashSpy = sinon.spy(flashMessages, 'success');
+ this.server.post(`/auth/${this.backend}/users/${this.username}/password`, (schema, req) => {
+ const body = JSON.parse(req.requestBody);
+ assert.ok(true, 'correct endpoint called for update (once)');
+ assert.deepEqual(body, { password: 'new' }, 'request body is correct');
+ return {};
+ });
+ await render(hbs``);
+
+ assert
+ .dom(S.infoBanner)
+ .hasText(
+ `You are updating the password for ${this.username} on the ${this.backend} auth mount.`,
+ 'info text correct'
+ );
+
+ await fillIn(S.input, 'new');
+ await click(S.save);
+
+ assert.true(flashSpy.calledOnceWith('Successfully reset password'), 'Shows success message');
+ assert.dom(S.input).hasValue('', 'Reset shows input again with empty value');
+ });
+
+ test('form works -- handles error', async function (assert) {
+ this.server.post(`/auth/${this.backend}/users/${this.username}/password`, () => {
+ return new Response(403, {}, { errors: ['some error occurred'] });
+ });
+ await render(hbs``);
+
+ assert
+ .dom(S.infoBanner)
+ .hasText(`You are updating the password for ${this.username} on the ${this.backend} auth mount.`);
+
+ await click(S.save);
+ assert.dom(S.error).hasText('Error Please provide a new password.');
+
+ await fillIn(S.input, 'invalid-pw');
+ await click(S.save);
+
+ assert.dom(S.error).hasText('Error some error occurred', 'Shows error from API');
+ });
+});