Skip to content

Commit b770887

Browse files
authored
UI: Allow userpass user to update their own password (#23797)
1 parent a10685c commit b770887

File tree

13 files changed

+309
-26
lines changed

13 files changed

+309
-26
lines changed

changelog/23797.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:improvement
2+
ui: Allow users in userpass auth mount to update their own password
3+
```

ui/app/adapters/auth-method.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,10 @@ export default ApplicationAdapter.extend({
7878
const url = `${this.buildURL()}/${this.pathForType()}/${encodePath(path)}tune`;
7979
return this.ajax(url, 'POST', { data });
8080
},
81+
82+
resetPassword(backend, username, password) {
83+
// For userpass auth types only
84+
const url = `/v1/auth/${encodePath(backend)}/users/${encodePath(username)}/password`;
85+
return this.ajax(url, 'POST', { data: { password } });
86+
},
8187
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<PageHeader as |p|>
2+
<p.levelLeft>
3+
<h1 data-test-title class="title is-3">
4+
Reset password
5+
</h1>
6+
</p.levelLeft>
7+
</PageHeader>
8+
9+
<form {{on "submit" (perform this.updatePassword) on="submit"}}>
10+
<div class="box is-sideless is-fullwidth is-marginless">
11+
<Hds::Alert @type="inline" class="has-bottom-margin-m" as |A|>
12+
<A.Title>Current user</A.Title>
13+
<A.Description data-test-current-user-banner>You are updating the password for
14+
<strong>{{@username}}</strong>
15+
on the
16+
<strong>{{@backend}}</strong>
17+
auth mount.</A.Description>
18+
</Hds::Alert>
19+
<FormFieldLabel for="reset-password" @label="New password" />
20+
<MaskedInput
21+
id="reset-password"
22+
@name="reset-password"
23+
@value={{this.newPassword}}
24+
@onChange={{fn (mut this.newPassword)}}
25+
/>
26+
</div>
27+
28+
<Hds::ButtonSet class="has-top-margin-m">
29+
<Hds::Button
30+
@text="Save"
31+
@icon={{if this.updatePassword.isRunning "loading"}}
32+
disabled={{this.updatePassword.isRunning}}
33+
type="submit"
34+
data-test-reset-password-save
35+
/>
36+
{{#if this.error}}
37+
<Hds::Alert @type="compact" @color="critical" class="has-left-margin-s" data-test-reset-password-error as |A|>
38+
<A.Title>Error</A.Title>
39+
<A.Description>
40+
{{this.error}}
41+
</A.Description>
42+
</Hds::Alert>
43+
{{/if}}
44+
</Hds::ButtonSet>
45+
</form>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { service } from '@ember/service';
2+
import Component from '@glimmer/component';
3+
import { tracked } from '@glimmer/tracking';
4+
import { task } from 'ember-concurrency';
5+
import errorMessage from 'vault/utils/error-message';
6+
7+
export default class PageUserpassResetPasswordComponent extends Component {
8+
@service store;
9+
@service flashMessages;
10+
11+
@tracked newPassword = '';
12+
@tracked error = '';
13+
14+
onSuccess() {
15+
this.error = '';
16+
this.newPassword = '';
17+
this.flashMessages.success('Successfully reset password');
18+
}
19+
20+
@task
21+
*updatePassword(evt) {
22+
evt.preventDefault();
23+
this.error = '';
24+
const adapter = this.store.adapterFor('auth-method');
25+
const { backend, username } = this.args;
26+
if (!backend || !username) return;
27+
if (!this.newPassword) {
28+
this.error = 'Please provide a new password.';
29+
return;
30+
}
31+
try {
32+
yield adapter.resetPassword(backend, username, this.newPassword);
33+
this.onSuccess();
34+
} catch (e) {
35+
this.error = errorMessage(e, 'Check Vault logs for details');
36+
}
37+
}
38+
}

ui/app/components/sidebar/user-menu.hbs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@
3939
</LinkTo>
4040
</li>
4141
{{/if}}
42+
{{#if this.isUserpass}}
43+
<li class="action">
44+
<LinkTo @route="vault.cluster.access.reset-password" data-test-user-menu-item="reset-password">
45+
Reset password
46+
</LinkTo>
47+
</li>
48+
{{/if}}
4249
<li class="action" id="container">
4350
{{! container is required in navbar collapsed state }}
4451
<Hds::Copy::Button

ui/app/components/sidebar/user-menu.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export default class SidebarUserMenuComponent extends Component {
2121
// in order to use the MFA end user setup they need an entity_id
2222
return !!this.auth.authData?.entity_id;
2323
}
24+
get isUserpass() {
25+
return this.auth.authData?.backend?.type === 'userpass';
26+
}
2427

2528
get isRenewing() {
2629
return this.fakeRenew || this.auth.isRenewing;

ui/app/router.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Router.map(function () {
5454
this.mount('open-api-explorer', { path: '/api-explorer' });
5555
});
5656
this.route('access', function () {
57+
this.route('reset-password');
5758
this.route('methods', { path: '/' });
5859
this.route('method', { path: '/:path' }, function () {
5960
this.route('index', { path: '/' });
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Route from '@ember/routing/route';
2+
import { service } from '@ember/service';
3+
import { encodePath } from 'vault/utils/path-encoding-helpers';
4+
5+
const ERROR_UNAVAILABLE = 'Password reset is not available for your current auth mount.';
6+
const ERROR_NO_ACCESS =
7+
'You do not have permissions to update your password. If you think this is a mistake ask your administrator to update your policy.';
8+
export default class VaultClusterAccessResetPasswordRoute extends Route {
9+
@service auth;
10+
@service store;
11+
12+
async model() {
13+
// Password reset is only available on userpass type auth mounts
14+
if (this.auth.authData?.backend?.type !== 'userpass') {
15+
throw new Error(ERROR_UNAVAILABLE);
16+
}
17+
const { backend, displayName } = this.auth.authData;
18+
if (!backend.mountPath || !displayName) {
19+
throw new Error(ERROR_UNAVAILABLE);
20+
}
21+
try {
22+
const capabilities = await this.store.findRecord(
23+
'capabilities',
24+
`auth/${encodePath(backend.mountPath)}/users/${encodePath(displayName)}/password`
25+
);
26+
// Check that the user has ability to update password
27+
if (!capabilities.canUpdate) {
28+
throw new Error(ERROR_NO_ACCESS);
29+
}
30+
} catch (e) {
31+
// If capabilities can't be queried, default to letting the API decide
32+
}
33+
return {
34+
backend: backend.mountPath,
35+
username: displayName,
36+
};
37+
}
38+
}

ui/app/services/auth.js

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { getOwner } from '@ember/application';
99
import { isArray } from '@ember/array';
1010
import { computed, get } from '@ember/object';
1111
import { alias } from '@ember/object/computed';
12-
import { assign } from '@ember/polyfills';
1312
import Service, { inject as service } from '@ember/service';
1413
import { capitalize } from '@ember/string';
1514
import fetch from 'fetch';
@@ -118,9 +117,12 @@ export default Service.extend({
118117
}
119118
const backend = this.backendFromTokenName(token);
120119
const stored = this.getTokenData(token);
121-
122-
return assign(stored, {
123-
backend: BACKENDS.findBy('type', backend),
120+
return Object.assign(stored, {
121+
backend: {
122+
// add mount path for password reset
123+
mountPath: stored.backend.mountPath,
124+
...BACKENDS.findBy('type', backend),
125+
},
124126
});
125127
}),
126128

@@ -184,7 +186,7 @@ export default Service.extend({
184186
if (namespace) {
185187
defaults.headers['X-Vault-Namespace'] = namespace;
186188
}
187-
const opts = assign(defaults, options);
189+
const opts = Object.assign(defaults, options);
188190

189191
return fetch(url, {
190192
method: opts.method || 'GET',
@@ -223,10 +225,35 @@ export default Service.extend({
223225
};
224226
},
225227

228+
calculateRootNamespace(currentNamespace, namespace_path, backend) {
229+
// here we prefer namespace_path if its defined,
230+
// else we look and see if there's already a namespace saved
231+
// and then finally we'll use the current query param if the others
232+
// haven't set a value yet
233+
// all of the typeof checks are necessary because the root namespace is ''
234+
let userRootNamespace = namespace_path && namespace_path.replace(/\/$/, '');
235+
// if we're logging in with token and there's no namespace_path, we can assume
236+
// that the token belongs to the root namespace
237+
if (backend === 'token' && !userRootNamespace) {
238+
userRootNamespace = '';
239+
}
240+
if (typeof userRootNamespace === 'undefined') {
241+
if (this.authData) {
242+
userRootNamespace = this.authData.userRootNamespace;
243+
}
244+
}
245+
if (typeof userRootNamespace === 'undefined') {
246+
userRootNamespace = currentNamespace;
247+
}
248+
return userRootNamespace;
249+
},
250+
226251
persistAuthData() {
227252
const [firstArg, resp] = arguments;
228253
const tokens = this.tokens;
229254
const currentNamespace = this.namespaceService.path || '';
255+
// Tab vs dropdown format
256+
const mountPath = firstArg?.selectedAuth || firstArg?.data?.path;
230257
let tokenName;
231258
let options;
232259
let backend;
@@ -238,7 +265,10 @@ export default Service.extend({
238265
backend = options.backend;
239266
}
240267

241-
const currentBackend = BACKENDS.findBy('type', backend);
268+
const currentBackend = {
269+
mountPath,
270+
...BACKENDS.findBy('type', backend),
271+
};
242272
let displayName;
243273
if (isArray(currentBackend.displayNamePath)) {
244274
displayName = currentBackend.displayNamePath.map((name) => get(resp, name)).join('/');
@@ -247,25 +277,7 @@ export default Service.extend({
247277
}
248278

249279
const { entity_id, policies, renewable, namespace_path } = resp;
250-
// here we prefer namespace_path if its defined,
251-
// else we look and see if there's already a namespace saved
252-
// and then finally we'll use the current query param if the others
253-
// haven't set a value yet
254-
// all of the typeof checks are necessary because the root namespace is ''
255-
let userRootNamespace = namespace_path && namespace_path.replace(/\/$/, '');
256-
// if we're logging in with token and there's no namespace_path, we can assume
257-
// that the token belongs to the root namespace
258-
if (backend === 'token' && !userRootNamespace) {
259-
userRootNamespace = '';
260-
}
261-
if (typeof userRootNamespace === 'undefined') {
262-
if (this.authData) {
263-
userRootNamespace = this.authData.userRootNamespace;
264-
}
265-
}
266-
if (typeof userRootNamespace === 'undefined') {
267-
userRootNamespace = currentNamespace;
268-
}
280+
const userRootNamespace = this.calculateRootNamespace(currentNamespace, namespace_path, backend);
269281
const data = {
270282
userRootNamespace,
271283
displayName,
@@ -285,7 +297,7 @@ export default Service.extend({
285297
);
286298

287299
if (resp.renewable) {
288-
assign(data, this.calculateExpiration(resp));
300+
Object.assign(data, this.calculateExpiration(resp));
289301
}
290302

291303
if (!data.displayName) {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<PageHeader as |p|>
2+
<p.levelLeft>
3+
<h1 data-test-title class="title is-3">
4+
Reset password
5+
</h1>
6+
</p.levelLeft>
7+
</PageHeader>
8+
9+
<EmptyState @title="No password reset access" @message={{this.model.message}}>
10+
<p>
11+
Learn more
12+
<DocLink @path="vault/api-docs/auth/userpass#update-password-on-user">about updating passwords</DocLink>
13+
here.</p>
14+
</EmptyState>

0 commit comments

Comments
 (0)