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 +

+
+
+ +
+
+ + Current user + You are updating the password for + {{@username}} + on the + {{@backend}} + auth mount. + + + +
+ + + + {{#if this.error}} + + Error + + {{this.error}} + + + {{/if}} + +
\ 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'); + }); +});