From b99d97950c6f7da29bf9af8ce33a8cf1a8e342d3 Mon Sep 17 00:00:00 2001 From: Ricky Date: Sat, 3 Oct 2026 06:39:23 +0200 Subject: [PATCH] perf(users): load other strategies' credentials on expand The users list took several seconds to open on a remote backend: it called getCredentials for every user and every strategy, sequentially. It now only loads `local`, the only one shown on a folded row, in parallel; UserItem loads every strategy when first expanded. Backport of kuzzleio/kuzzle-admin-console#1250 (G-132). Co-Authored-By: Claude Opus 5.5 --- src/components/Security/Users/UserItem.vue | 41 +++++++++++++- src/services/kuzzleWrapper-v1.ts | 56 ++++++++++++------- .../integration/single-backend/users.spec.js | 19 +++++++ 3 files changed, 93 insertions(+), 23 deletions(-) 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`)