Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions src/components/Security/Users/UserItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
v-model="expanded"
class="ml-3 DocumentListItem-content"
>
<pre v-json-formatter="{ content: document, open: true }" />
<pre v-json-formatter="{ content: expandedDocument, open: true }" />
</b-collapse>
</div>
</template>
Expand All @@ -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;

Expand All @@ -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 [];
Expand All @@ -138,13 +147,41 @@ 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;
},
},
},
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;
},
Expand Down
56 changes: 35 additions & 21 deletions src/services/kuzzleWrapper-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
19 changes: 19 additions & 0 deletions test/e2e/cypress/integration/single-backend/users.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
Loading