diff --git a/packages/@n8n/api-types/src/dto/user/users-list-filter.dto.ts b/packages/@n8n/api-types/src/dto/user/users-list-filter.dto.ts index 1626754695..1dfb1ace59 100644 --- a/packages/@n8n/api-types/src/dto/user/users-list-filter.dto.ts +++ b/packages/@n8n/api-types/src/dto/user/users-list-filter.dto.ts @@ -11,6 +11,8 @@ const USERS_LIST_SORT_OPTIONS = [ 'lastName:desc', 'role:asc', // ascending order by role is Owner, Admin, Member 'role:desc', + 'mfaEnabled:asc', + 'mfaEnabled:desc', // 'lastActive:asc', // 'lastActive:desc', ] as const; @@ -32,6 +34,7 @@ const userFilterSchema = z.object({ firstName: z.string().optional(), lastName: z.string().optional(), email: z.string().optional(), + mfaEnabled: z.boolean().optional(), fullText: z.string().optional(), // Full text search across firstName, lastName, and email }); diff --git a/packages/@n8n/api-types/src/schemas/user.schema.ts b/packages/@n8n/api-types/src/schemas/user.schema.ts index 3b84c4144e..1858721a52 100644 --- a/packages/@n8n/api-types/src/schemas/user.schema.ts +++ b/packages/@n8n/api-types/src/schemas/user.schema.ts @@ -35,6 +35,7 @@ export const userListItemSchema = z.object({ personalizationAnswers: z.object({}).passthrough().nullable().optional(), lastActive: z.string().optional(), projectRelations: z.array(userProjectSchema).nullable().optional(), + mfaEnabled: z.boolean().optional(), }); export const usersListSchema = z.object({ diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index 42c639da3b..0e44aefbe6 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -182,6 +182,12 @@ export class UserRepository extends Repository { }); } + if (filter?.mfaEnabled !== undefined) { + queryBuilder.andWhere('user.mfaEnabled = :mfaEnabled', { + mfaEnabled: filter.mfaEnabled, + }); + } + if (filter?.isOwner !== undefined) { if (filter.isOwner) { queryBuilder.andWhere('user.role = :role', { @@ -240,13 +246,13 @@ export class UserRepository extends Repository { if (sortBy) { for (const sort of sortBy) { const [field, order] = sort.split(':'); - if (field === 'firstName' || field === 'lastName') { - queryBuilder.addOrderBy(`user.${field}`, order.toUpperCase() as 'ASC' | 'DESC'); - } else if (field === 'role') { + if (field === 'role') { queryBuilder.addOrderBy( "CASE WHEN user.role='global:owner' THEN 0 WHEN user.role='global:admin' THEN 1 ELSE 2 END", order.toUpperCase() as 'ASC' | 'DESC', ); + } else { + queryBuilder.addOrderBy(`user.${field}`, order.toUpperCase() as 'ASC' | 'DESC'); } } } diff --git a/packages/cli/test/integration/shared/utils/users.ts b/packages/cli/test/integration/shared/utils/users.ts index 45e3dc8f9b..697bc6cc26 100644 --- a/packages/cli/test/integration/shared/utils/users.ts +++ b/packages/cli/test/integration/shared/utils/users.ts @@ -13,6 +13,7 @@ export const validateUser = (user: PublicUser) => { expect(user.personalizationAnswers).toBeNull(); expect(user.password).toBeUndefined(); expect(user.role).toBeDefined(); + expect(typeof (user as any).mfaEnabled).toBe('boolean'); }; export type UserInvitationResult = { diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 46a76ad87e..11d5206092 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -61,6 +61,7 @@ describe('GET /users', () => { email: 'member1@n8n.io', firstName: 'Member1FirstName', lastName: 'Member1LastName', + mfaEnabled: true, }); await createUser({ role: 'global:member', @@ -73,6 +74,7 @@ describe('GET /users', () => { email: 'admin@n8n.io', firstName: 'AdminFirstName', lastName: 'AdminLastName', + mfaEnabled: true, }); ownerAgent = testServer.authAgentFor(owner); @@ -206,6 +208,38 @@ describe('GET /users', () => { expect(_user.isOwner).toBe(false); }); + test('should filter users by mfaEnabled field', async () => { + const response = await ownerAgent + .get('/users') + .query('filter={ "mfaEnabled": true }') + .expect(200); + + expect(response.body.data).toEqual({ + count: 2, + items: expect.arrayContaining([]), + }); + expect(response.body.data.items).toHaveLength(2); + + const [user] = response.body.data.items; + + expect(user.mfaEnabled).toBe(true); + + const _response = await ownerAgent + .get('/users') + .query('filter={ "mfaEnabled": false }') + .expect(200); + + expect(_response.body.data).toEqual({ + count: 2, + items: expect.arrayContaining([]), + }); + expect(_response.body.data.items).toHaveLength(2); + + const [_user] = _response.body.data.items; + + expect(_user.mfaEnabled).toBe(false); + }); + test('should filter users by field: fullText', async () => { const response = await ownerAgent .get('/users') @@ -574,6 +608,32 @@ describe('GET /users', () => { expect(response.body.data.items[3].lastName).toBe('AdminLastName'); }); + test('should sort by mfaEnabled:asc', async () => { + const response = await ownerAgent + .get('/users') + .query('sortBy[]=mfaEnabled:asc') + .expect(200); + + expect(response.body.data.items).toHaveLength(4); + expect(response.body.data.items[0].mfaEnabled).toBe(false); + expect(response.body.data.items[1].mfaEnabled).toBe(false); + expect(response.body.data.items[2].mfaEnabled).toBe(true); + expect(response.body.data.items[3].mfaEnabled).toBe(true); + }); + + test('should sort by mfaEnabled:desc', async () => { + const response = await ownerAgent + .get('/users') + .query('sortBy[]=mfaEnabled:desc') + .expect(200); + + expect(response.body.data.items).toHaveLength(4); + expect(response.body.data.items[0].mfaEnabled).toBe(true); + expect(response.body.data.items[1].mfaEnabled).toBe(true); + expect(response.body.data.items[2].mfaEnabled).toBe(false); + expect(response.body.data.items[3].mfaEnabled).toBe(false); + }); + test('should sort by firstName and lastName combined', async () => { const user1 = await createUser({ role: 'global:member',