mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
fix(core): add hasPassword
field to user API response (#6543)
This commit is contained in:
parent
31035816c4
commit
a748fc85bb
11 changed files with 99 additions and 44 deletions
5
.changeset/polite-bats-learn.md
Normal file
5
.changeset/polite-bats-learn.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@logto/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: add `hasPassword` field to user API response
|
|
@ -1,6 +1,7 @@
|
||||||
import type { User } from '@logto/schemas';
|
import type { User } from '@logto/schemas';
|
||||||
import { MfaFactor, userInfoSelectFields, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
import { MfaFactor, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||||
import { pick } from '@silverhand/essentials';
|
|
||||||
|
import { transpileUserProfileResponse } from '../utils/user.js';
|
||||||
|
|
||||||
export const mockUser: User = {
|
export const mockUser: User = {
|
||||||
tenantId: 'fake_tenant',
|
tenantId: 'fake_tenant',
|
||||||
|
@ -56,7 +57,7 @@ export const mockUserWithMfaVerifications: User = {
|
||||||
mfaVerifications: [mockUserTotpMfaVerification],
|
mfaVerifications: [mockUserTotpMfaVerification],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockUserResponse = pick(mockUser, ...userInfoSelectFields);
|
export const mockUserResponse = transpileUserProfileResponse(mockUser);
|
||||||
|
|
||||||
export const mockPasswordEncrypted = 'a1b2c3';
|
export const mockPasswordEncrypted = 'a1b2c3';
|
||||||
export const mockUserWithPassword: User = {
|
export const mockUserWithPassword: User = {
|
||||||
|
@ -191,4 +192,4 @@ export const mockUserList: User[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const mockUserListResponse = mockUserList.map((user) => pick(user, ...userInfoSelectFields));
|
export const mockUserListResponse = mockUserList.map((user) => transpileUserProfileResponse(user));
|
||||||
|
|
|
@ -3,11 +3,10 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||||
import {
|
import {
|
||||||
UsersPasswordEncryptionMethod,
|
UsersPasswordEncryptionMethod,
|
||||||
jsonObjectGuard,
|
jsonObjectGuard,
|
||||||
userInfoSelectFields,
|
|
||||||
userProfileGuard,
|
userProfileGuard,
|
||||||
userProfileResponseGuard,
|
userProfileResponseGuard,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { conditional, pick, yes } from '@silverhand/essentials';
|
import { conditional, yes } from '@silverhand/essentials';
|
||||||
import { boolean, literal, nativeEnum, object, string } from 'zod';
|
import { boolean, literal, nativeEnum, object, string } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
@ -16,6 +15,7 @@ import { encryptUserPassword } from '#src/libraries/user.utils.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
import { transpileUserProfileResponse } from '../../utils/user.js';
|
||||||
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
||||||
|
@ -54,20 +54,16 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const {
|
const {
|
||||||
params: { userId },
|
params: { userId },
|
||||||
query: { includeSsoIdentities },
|
query: { includeSsoIdentities = 'false' },
|
||||||
} = ctx.guard;
|
} = ctx.guard;
|
||||||
|
|
||||||
const user = await findUserById(userId);
|
const user = await findUserById(userId);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = transpileUserProfileResponse(user, {
|
||||||
...pick(user, ...userInfoSelectFields),
|
ssoIdentities: conditional(
|
||||||
...conditional(
|
yes(includeSsoIdentities) && [...(await findUserSsoIdentities(userId))]
|
||||||
includeSsoIdentities &&
|
|
||||||
yes(includeSsoIdentities) && {
|
|
||||||
ssoIdentities: await findUserSsoIdentities(userId),
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
};
|
});
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
@ -221,7 +217,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = pick(user, ...userInfoSelectFields);
|
ctx.body = transpileUserProfileResponse(user);
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -252,7 +248,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
||||||
await checkIdentifierCollision(body, userId);
|
await checkIdentifierCollision(body, userId);
|
||||||
|
|
||||||
const updatedUser = await updateUserById(userId, body, 'replace');
|
const updatedUser = await updateUserById(userId, body, 'replace');
|
||||||
ctx.body = pick(updatedUser, ...userInfoSelectFields);
|
ctx.body = transpileUserProfileResponse(updatedUser);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
@ -281,7 +277,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
||||||
passwordEncryptionMethod,
|
passwordEncryptionMethod,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = pick(user, ...userInfoSelectFields);
|
ctx.body = transpileUserProfileResponse(user);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
@ -352,7 +348,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
||||||
await signOutUser(user.id);
|
await signOutUser(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = pick(user, ...userInfoSelectFields);
|
ctx.body = transpileUserProfileResponse(user);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { CreateUser, Role, User } from '@logto/schemas';
|
import type { CreateUser, Role, User } from '@logto/schemas';
|
||||||
import { userInfoSelectFields, RoleType } from '@logto/schemas';
|
import { RoleType } from '@logto/schemas';
|
||||||
import { pickDefault } from '@logto/shared/esm';
|
import { pickDefault } from '@logto/shared/esm';
|
||||||
import { pick, removeUndefinedKeys } from '@silverhand/essentials';
|
import { removeUndefinedKeys } from '@silverhand/essentials';
|
||||||
|
|
||||||
import { mockUser, mockUserList, mockUserListResponse } from '#src/__mocks__/index.js';
|
import { mockUser, mockUserList, mockUserListResponse } from '#src/__mocks__/index.js';
|
||||||
import { type InsertUserResult } from '#src/libraries/user.js';
|
import { type InsertUserResult } from '#src/libraries/user.js';
|
||||||
|
@ -10,6 +10,8 @@ import type Queries from '#src/tenants/Queries.js';
|
||||||
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
|
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
import { createRequester } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import { transpileUserProfileResponse } from '../../utils/user.js';
|
||||||
|
|
||||||
const { jest } = import.meta;
|
const { jest } = import.meta;
|
||||||
|
|
||||||
const filterUsersWithSearch = (users: User[], search: string) =>
|
const filterUsersWithSearch = (users: User[], search: string) =>
|
||||||
|
@ -86,7 +88,7 @@ describe('adminUserRoutes', () => {
|
||||||
const response = await userRequest.get('/users').send({ search });
|
const response = await userRequest.get('/users').send({ search });
|
||||||
expect(response.status).toEqual(200);
|
expect(response.status).toEqual(200);
|
||||||
expect(response.body).toEqual(
|
expect(response.body).toEqual(
|
||||||
filterUsersWithSearch(mockUserList, search).map((user) => pick(user, ...userInfoSelectFields))
|
filterUsersWithSearch(mockUserList, search).map((user) => transpileUserProfileResponse(user))
|
||||||
);
|
);
|
||||||
expect(response.header).toHaveProperty(
|
expect(response.header).toHaveProperty(
|
||||||
'total-number',
|
'total-number',
|
||||||
|
|
|
@ -1,10 +1,5 @@
|
||||||
import {
|
import { OrganizationUserRelations, UsersRoles, userProfileResponseGuard } from '@logto/schemas';
|
||||||
OrganizationUserRelations,
|
import { type Nullable, tryThat } from '@silverhand/essentials';
|
||||||
UsersRoles,
|
|
||||||
userInfoSelectFields,
|
|
||||||
userProfileResponseGuard,
|
|
||||||
} from '@logto/schemas';
|
|
||||||
import { type Nullable, pick, tryThat } from '@silverhand/essentials';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
@ -12,6 +7,7 @@ import koaPagination from '#src/middleware/koa-pagination.js';
|
||||||
import { type UserConditions } from '#src/queries/user.js';
|
import { type UserConditions } from '#src/queries/user.js';
|
||||||
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
||||||
|
|
||||||
|
import { transpileUserProfileResponse } from '../../utils/user.js';
|
||||||
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
const getQueryRelation = (
|
const getQueryRelation = (
|
||||||
|
@ -82,7 +78,7 @@ export default function adminUserSearchRoutes<T extends ManagementApiRouter>(
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ctx.pagination.totalCount = count;
|
ctx.pagination.totalCount = count;
|
||||||
ctx.body = users.map((user) => pick(user, ...userInfoSelectFields));
|
ctx.body = users.map((user) => transpileUserProfileResponse(user));
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,16 +3,16 @@ import {
|
||||||
ConnectorType,
|
ConnectorType,
|
||||||
identityGuard,
|
identityGuard,
|
||||||
identitiesGuard,
|
identitiesGuard,
|
||||||
userInfoSelectFields,
|
|
||||||
userProfileResponseGuard,
|
userProfileResponseGuard,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { has, pick } from '@silverhand/essentials';
|
import { has } from '@silverhand/essentials';
|
||||||
import { object, record, string, unknown } from 'zod';
|
import { object, record, string, unknown } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
import { transpileUserProfileResponse } from '../../utils/user.js';
|
||||||
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
|
export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
|
||||||
|
@ -149,7 +149,7 @@ export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await deleteUserIdentity(userId, target);
|
const updatedUser = await deleteUserIdentity(userId, target);
|
||||||
ctx.body = pick(updatedUser, ...userInfoSelectFields);
|
ctx.body = transpileUserProfileResponse(updatedUser);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { UsersRoles, userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas';
|
import { UsersRoles, userProfileResponseGuard } from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { pick, tryThat } from '@silverhand/essentials';
|
import { tryThat } from '@silverhand/essentials';
|
||||||
import { object, string } from 'zod';
|
import { object, string } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
@ -9,6 +9,8 @@ import koaPagination from '#src/middleware/koa-pagination.js';
|
||||||
import { type UserConditions } from '#src/queries/user.js';
|
import { type UserConditions } from '#src/queries/user.js';
|
||||||
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
||||||
|
|
||||||
|
import { transpileUserProfileResponse } from '../utils/user.js';
|
||||||
|
|
||||||
import type { ManagementApiRouter, RouterInitArgs } from './types.js';
|
import type { ManagementApiRouter, RouterInitArgs } from './types.js';
|
||||||
|
|
||||||
export default function roleUserRoutes<T extends ManagementApiRouter>(
|
export default function roleUserRoutes<T extends ManagementApiRouter>(
|
||||||
|
@ -59,7 +61,7 @@ export default function roleUserRoutes<T extends ManagementApiRouter>(
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ctx.pagination.totalCount = count;
|
ctx.pagination.totalCount = count;
|
||||||
ctx.body = users.map((user) => pick(user, ...userInfoSelectFields));
|
ctx.body = users.map((user) => transpileUserProfileResponse(user));
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
import { MfaFactor, type User, type UserMfaVerificationResponse } from '@logto/schemas';
|
import {
|
||||||
|
MfaFactor,
|
||||||
|
userInfoSelectFields,
|
||||||
|
type UserProfileResponse,
|
||||||
|
type UserSsoIdentity,
|
||||||
|
type User,
|
||||||
|
type UserMfaVerificationResponse,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { pick } from '@silverhand/essentials';
|
||||||
|
|
||||||
export const transpileUserMfaVerifications = (
|
export const transpileUserMfaVerifications = (
|
||||||
mfaVerifications: User['mfaVerifications']
|
mfaVerifications: User['mfaVerifications']
|
||||||
|
@ -21,3 +29,36 @@ export const transpileUserMfaVerifications = (
|
||||||
return { id, createdAt, type };
|
return { id, createdAt, type };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExtraUserInfo = {
|
||||||
|
ssoIdentities?: UserSsoIdentity[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms user data into a user profile response format
|
||||||
|
*
|
||||||
|
* This function is used when API endpoints return user profile information,
|
||||||
|
* converting the internal user data model to an external user profile response format.
|
||||||
|
*
|
||||||
|
* Main purposes:
|
||||||
|
*
|
||||||
|
* 1. Selectively return user information fields
|
||||||
|
* 2. Add additional user-related information (e.g., SSO identities)
|
||||||
|
* 3. Handle password-related information
|
||||||
|
*
|
||||||
|
* @param user - Internal user data model
|
||||||
|
* @param extraInfo - Additional user-related information, such as SSO identities
|
||||||
|
* @returns Formatted user profile response object
|
||||||
|
*/
|
||||||
|
export const transpileUserProfileResponse = (
|
||||||
|
user: User,
|
||||||
|
extraInfo: ExtraUserInfo = {}
|
||||||
|
): UserProfileResponse => {
|
||||||
|
const { ssoIdentities } = extraInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pick(user, ...userInfoSelectFields),
|
||||||
|
hasPassword: Boolean(user.passwordEncrypted),
|
||||||
|
...(ssoIdentities && { ssoIdentities }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { type User } from '@logto/schemas';
|
import { type UserProfileResponse } from '@logto/schemas';
|
||||||
import { trySafe } from '@silverhand/essentials';
|
import { trySafe } from '@silverhand/essentials';
|
||||||
|
|
||||||
import { type CreateUserPayload, createUser, deleteUser } from '#src/api/index.js';
|
import { type CreateUserPayload, createUser, deleteUser } from '#src/api/index.js';
|
||||||
|
@ -51,13 +51,13 @@ export const generateNewUser = async <T extends NewUserProfileOptions>(options:
|
||||||
};
|
};
|
||||||
|
|
||||||
export class UserApiTest {
|
export class UserApiTest {
|
||||||
#users: User[] = [];
|
#users: UserProfileResponse[] = [];
|
||||||
|
|
||||||
get users(): User[] {
|
get users(): UserProfileResponse[] {
|
||||||
return this.#users;
|
return this.#users;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: CreateUserPayload): Promise<User> {
|
async create(data: CreateUserPayload): Promise<UserProfileResponse> {
|
||||||
const user = await createUser(data);
|
const user = await createUser(data);
|
||||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||||
this.users.push(user);
|
this.users.push(user);
|
||||||
|
|
|
@ -201,7 +201,11 @@ describe('admin console user management', () => {
|
||||||
it('should update user password successfully', async () => {
|
it('should update user password successfully', async () => {
|
||||||
const { updatedAt, ...rest } = await createUserByAdmin();
|
const { updatedAt, ...rest } = await createUserByAdmin();
|
||||||
const userEntity = await updateUserPassword(rest.id, 'new_password');
|
const userEntity = await updateUserPassword(rest.id, 'new_password');
|
||||||
expect(userEntity).toMatchObject(rest);
|
expect(userEntity).toMatchObject({
|
||||||
|
...rest,
|
||||||
|
// Since the password is updated, the hasPassword field will be true.
|
||||||
|
hasPassword: true,
|
||||||
|
});
|
||||||
expect(userEntity.updatedAt).toBeGreaterThan(updatedAt);
|
expect(userEntity.updatedAt).toBeGreaterThan(updatedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -47,7 +47,11 @@ describe('organization user APIs', () => {
|
||||||
it('should be able to get organization users with search', async () => {
|
it('should be able to get organization users with search', async () => {
|
||||||
const organizationId = organizationApi.organizations[0]!.id;
|
const organizationId = organizationApi.organizations[0]!.id;
|
||||||
const username = generateTestName();
|
const username = generateTestName();
|
||||||
const createdUser = await userApi.create({ username });
|
/**
|
||||||
|
* Exclude `hasPassword` field since the user type returned by the organization user API is not `userProfileResponse`.
|
||||||
|
* So the `hasPassword` field will not be included in the user object.
|
||||||
|
*/
|
||||||
|
const { hasPassword, ...createdUser } = await userApi.create({ username });
|
||||||
|
|
||||||
await organizationApi.addUsers(organizationId, [createdUser.id]);
|
await organizationApi.addUsers(organizationId, [createdUser.id]);
|
||||||
const [users] = await organizationApi.getUsers(organizationId, {
|
const [users] = await organizationApi.getUsers(organizationId, {
|
||||||
|
@ -59,7 +63,11 @@ describe('organization user APIs', () => {
|
||||||
|
|
||||||
it('should be able to get organization users with their roles', async () => {
|
it('should be able to get organization users with their roles', async () => {
|
||||||
const organizationId = organizationApi.organizations[0]!.id;
|
const organizationId = organizationApi.organizations[0]!.id;
|
||||||
const user = userApi.users[0]!;
|
/**
|
||||||
|
* Exclude `hasPassword` field since the user type returned by the organization user API is not `userProfileResponse`.
|
||||||
|
* So the `hasPassword` field will not be included in the user object.
|
||||||
|
*/
|
||||||
|
const { hasPassword, ...user } = userApi.users[0]!;
|
||||||
|
|
||||||
const roles = await Promise.all([
|
const roles = await Promise.all([
|
||||||
organizationApi.roleApi.create({ name: generateTestName() }),
|
organizationApi.roleApi.create({ name: generateTestName() }),
|
||||||
|
|
Loading…
Add table
Reference in a new issue