diff --git a/src/components/Security/Users/UserItem.vue b/src/components/Security/Users/UserItem.vue index 4f9122edb..ce7b2d920 100644 --- a/src/components/Security/Users/UserItem.vue +++ b/src/components/Security/Users/UserItem.vue @@ -87,7 +87,7 @@ v-model="expanded" class="ml-3 DocumentListItem-content" > -
+ @@ -96,7 +96,7 @@ import { mapState } from 'pinia'; import jsonFormatter from '@/directives/json-formatter.directive'; -import { useAuthStore } from '@/stores'; +import { useAuthStore, useKuzzleStore } from '@/stores'; const MAX_PROFILES = 5; @@ -114,10 +114,19 @@ export default { return { expanded: false, checked: false, + // Credentials of every strategy, loaded on first expand: the list only + // has `local`, the only one shown on the folded row. + fullCredentials: null, }; }, computed: { ...mapState(useAuthStore, ['canEditUser', 'canDeleteUser']), + ...mapState(useKuzzleStore, ['wrapper']), + expandedDocument() { + return this.fullCredentials + ? { ...this.document, credentials: this.fullCredentials } + : this.document; + }, profileList() { if (!this.document.profileIds) { return []; @@ -138,6 +147,18 @@ export default { }, }, watch: { + expanded(value) { + if (value && !this.fullCredentials) { + this.loadCredentials(); + } + }, + // The list reloaded its documents: what was loaded on expand is stale. + document() { + this.fullCredentials = null; + if (this.expanded) { + this.loadCredentials(); + } + }, isChecked: { handler(value) { this.checked = value; @@ -145,6 +166,22 @@ export default { }, }, methods: { + async loadCredentials() { + if (!this.wrapper) { + return; + } + const document = this.document; + try { + const credentials = await this.wrapper.getUserCredentials(document.id); + // The list may have reloaded its documents meanwhile. + if (this.document === document) { + this.fullCredentials = credentials; + } + } catch (error) { + // The JSON then keeps the `local` credentials from the list. + this.$log.error(error); + } + }, toggleCollapse() { this.expanded = !this.expanded; }, diff --git a/src/services/kuzzleWrapper-v1.ts b/src/services/kuzzleWrapper-v1.ts index 22fe91727..45bd3d59d 100644 --- a/src/services/kuzzleWrapper-v1.ts +++ b/src/services/kuzzleWrapper-v1.ts @@ -101,34 +101,48 @@ export class KuzzleWrapperV1 { return deleted; } + /* + * Folded rows only show the `local` username: other strategies are loaded + * when a row is expanded (`getUserCredentials`). Requests run in parallel — + * sequentially, a page of 25 users with two strategies took 52 round trips, + * several seconds on a remote backend. + */ async performSearchUsers(collection, index, filters = {}, pagination = {}, sort = []) { - const strategies = await this.kuzzle.auth.getStrategies(); - - const result = await this.kuzzle.security.searchUsers({ ...filters, sort }, { ...pagination }); - - const users: any[] = []; - for (const user of result.hits) { - const formattedUser: any = { + const [strategies, result] = await Promise.all([ + this.kuzzle.auth.getStrategies(), + this.kuzzle.security.searchUsers({ ...filters, sort }, { ...pagination }), + ]); + const hasLocal = strategies.includes('local'); + + const users = await Promise.all( + result.hits.map(async (user: any) => ({ id: user._id, ...user.content, _kuzzle_info: this.formatMeta(user.content._kuzzle_info), - credentials: {}, - }; - for (const strategy of strategies) { + credentials: hasLocal ? await this.getUserCredentials(user._id, ['local']) : {}, + })), + ); + + return { documents: users, total: result.total }; + } + + /* + * A user's credentials by strategy, restricted to `only` when given. A + * strategy without credentials for this user throws: it is left out. + */ + async getUserCredentials(kuid: string, only?: string[]) { + const strategies: string[] = only ?? (await this.kuzzle.auth.getStrategies()); + const entries = await Promise.all( + strategies.map(async (strategy) => { try { - const res = await this.kuzzle.security.getCredentials(strategy, user._id); - formattedUser.credentials[strategy] = res; + return [strategy, await this.kuzzle.security.getCredentials(strategy, kuid)] as const; } catch (e) { - /* eslint-disable no-empty */ - // Strategies contains local by default but some user - // might not have local credentials + return null; } - } - - users.push(formattedUser); - } - - return { documents: users, total: result.total }; + }), + ); + // In strategy order, not in response order. + return Object.fromEntries(entries.filter((entry) => entry !== null) as any); } getMappingUsers() { diff --git a/test/e2e/cypress/integration/single-backend/users.spec.js b/test/e2e/cypress/integration/single-backend/users.spec.js index 86a1b822f..c160abc27 100644 --- a/test/e2e/cypress/integration/single-backend/users.spec.js +++ b/test/e2e/cypress/integration/single-backend/users.spec.js @@ -695,6 +695,25 @@ describe('Users', function() { .should('contain', username) }) + it('Should load the credentials of a user when expanding it', () => { + const username = 'Lazaaaaro' + + cy.request('POST', `${kuzzleUrl}/users/${username}/_create?refresh=wait_for`, { + content: { profileIds: ['default'] }, + credentials: { local: { username, password: 'test' } } + }) + + // The list only loads `local`; expanding loads every strategy, and the + // JSON renders them without losing `local`. + cy.visit('/#/security/users') + cy.get(`[data-cy="local-strategy-username-${username}"]`).should('be.visible') + cy.get(`[data-cy=UserItem-${username}--toggle]`).click() + cy.get(`[id="collapse-${username}"]`) + .should('contain', 'credentials:') + .and('contain', 'local:') + .and('contain', 'username:') + }) + it('Should be able to create an user without strategy', () => { cy.visit(`/#/security/users/create`)