diff --git a/packages/console/src/pages/Organizations/OrganizationsTable/index.tsx b/packages/console/src/pages/Organizations/OrganizationsTable/index.tsx index 631774a87..1ce48e317 100644 --- a/packages/console/src/pages/Organizations/OrganizationsTable/index.tsx +++ b/packages/console/src/pages/Organizations/OrganizationsTable/index.tsx @@ -1,4 +1,4 @@ -import { type Organization } from '@logto/schemas'; +import { type OrganizationWithFeatured, RoleType } from '@logto/schemas'; import { joinPath } from '@silverhand/essentials'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,6 +11,7 @@ import { defaultPageSize } from '@/consts'; import CopyToClipboard from '@/ds-components/CopyToClipboard'; import Table from '@/ds-components/Table'; import { type RequestError } from '@/hooks/use-api'; +import AssignedEntities from '@/pages/Roles/components/AssignedEntities'; import { buildUrl } from '@/utils/url'; const pageSize = defaultPageSize; @@ -19,8 +20,9 @@ const apiPathname = 'api/organizations'; function OrganizationsTable() { const [page, setPage] = useState(1); - const { data: response, error } = useSWR<[Organization[], number], RequestError>( + const { data: response, error } = useSWR<[OrganizationWithFeatured[], number], RequestError>( buildUrl(apiPathname, { + showFeatured: '1', page: String(page), page_size: String(pageSize), }) @@ -53,7 +55,13 @@ function OrganizationsTable() { { title: t('organizations.members'), dataIndex: 'members', - render: () => 'members', // TODO: render members + render: ({ usersCount, featuredUsers }) => ( + + ), }, ]} rowIndexKey="id" diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index e6c0fd636..6f39f4337 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -163,14 +163,18 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { `); }; - const findApplicationsByIds = async (applicationIds: string[]) => - applicationIds.length > 0 - ? pool.any(sql` - select ${sql.join(Object.values(fields), sql`, `)} - from ${table} - where ${fields.id} in (${sql.join(applicationIds, sql`, `)}) - `) - : []; + const findApplicationsByIds = async ( + applicationIds: string[] + ): Promise => { + if (applicationIds.length === 0) { + return []; + } + return pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.id} in (${sql.join(applicationIds, sql`, `)}) + `); + }; const deleteApplicationById = async (id: string) => { const { rowCount } = await pool.query(sql` diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts index 60e68d1fc..6bdc04f58 100644 --- a/packages/core/src/queries/organizations.ts +++ b/packages/core/src/queries/organizations.ts @@ -15,6 +15,7 @@ import { type OrganizationRoleWithScopes, type OrganizationWithRoles, type UserWithOrganizationRoles, + type FeaturedUser, } from '@logto/schemas'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import { sql, type CommonQueryMethods } from 'slonik'; @@ -34,6 +35,35 @@ class UserRelationQueries extends TwoRelationsQueries { + const users = convertToIdentifiers(Users, true); + const relations = convertToIdentifiers(OrganizationUserRelations, true); + const mainSql = sql` + from ${relations.table} + left join ${users.table} + on ${relations.fields.userId} = ${users.fields.id} + where ${relations.fields.organizationId} = ${organizationId} + `; + const [{ count }, data] = await Promise.all([ + this.pool.one<{ count: string }>(sql` + select count(*) + ${mainSql} + `), + this.pool.any(sql` + select + ${users.fields.id}, + ${users.fields.avatar}, + ${users.fields.name} + ${mainSql} + limit 3 + `), + ]); + + return [Number(count), data]; + } + async getOrganizationsByUserId(userId: string): Promise> { const roles = convertToIdentifiers(OrganizationRoles, true); const organizations = convertToIdentifiers(Organizations, true); @@ -62,7 +92,7 @@ class UserRelationQueries extends TwoRelationsQueries - ): Promise<[totalCount: number, entities: Readonly]> { + ): Promise<[totalNumber: number, entities: Readonly]> { const roles = convertToIdentifiers(OrganizationRoles, true); const users = convertToIdentifiers(Users, true); const { fields } = convertToIdentifiers(OrganizationUserRelations, true); diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index eb711c419..8f823f980 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -1,5 +1,11 @@ -import { OrganizationRoles, Organizations, userWithOrganizationRolesGuard } from '@logto/schemas'; -import { type Optional, cond } from '@silverhand/essentials'; +import { + OrganizationRoles, + type OrganizationWithFeatured, + Organizations, + featuredUserGuard, + userWithOrganizationRolesGuard, +} from '@logto/schemas'; +import { type Optional, cond, yes } from '@silverhand/essentials'; import { z } from 'zod'; import { type SearchOptions } from '#src/database/utils.js'; @@ -8,6 +14,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import { userSearchKeys } from '#src/queries/user.js'; import SchemaRouter from '#src/utils/SchemaRouter.js'; +import { parseSearchOptions } from '#src/utils/search.js'; import { type AuthedRouter, type RouterInitArgs } from '../types.js'; @@ -22,11 +29,52 @@ export default function organizationRoutes(...args: Rout queries: { organizations }, }, ] = args; + const router = new SchemaRouter(Organizations, organizations, { errorHandler, searchFields: ['name'], + disabled: { get: true }, }); + router.get( + '/', + koaPagination(), + koaGuard({ + query: z.object({ q: z.string().optional(), showFeatured: z.string().optional() }), + response: ( + Organizations.guard.merge( + // For `showFeatured` query + z + .object({ + usersCount: z.number(), + featuredUsers: featuredUserGuard.array(), + }) + .partial() + ) satisfies z.ZodType + ).array(), + status: [200], + }), + async (ctx, next) => { + const { query } = ctx.guard; + const search = parseSearchOptions(['name'], query); + const { limit, offset } = ctx.pagination; + const [count, entities] = await organizations.findAll(limit, offset, search); + + ctx.pagination.totalCount = count; + ctx.body = yes(query.showFeatured) + ? await Promise.all( + entities.map(async (entity) => { + const [usersCount, featuredUsers] = await organizations.relations.users.getFeatured( + entity.id + ); + return { ...entity, usersCount, featuredUsers }; + }) + ) + : entities; + return next(); + } + ); + // MARK: Organization - user relation routes router.addRelationRoutes(organizations.relations.users, undefined, { disabled: { get: true } }); diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts index 7e8e0641a..a19395dbc 100644 --- a/packages/core/src/routes/role.test.ts +++ b/packages/core/src/routes/role.test.ts @@ -101,8 +101,6 @@ describe('role routes', () => { id: mockUser.id, avatar: mockUser.avatar, name: mockUser.name, - username: mockUser.username, - primaryEmail: mockUser.primaryEmail, }, ], applicationsCount: 0, diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index d98482aa7..44f6e8cac 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -1,7 +1,7 @@ import type { RoleResponse } from '@logto/schemas'; -import { Applications, Roles, Users } from '@logto/schemas'; +import { Roles, featuredApplicationGuard, featuredUserGuard } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; -import { tryThat } from '@silverhand/essentials'; +import { pickState, tryThat } from '@silverhand/essentials'; import { object, string, z, number } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; @@ -53,20 +53,9 @@ export default function roleRoutes(...[router, tenant]: .merge( object({ usersCount: number(), - featuredUsers: Users.guard - .pick({ - avatar: true, - id: true, - name: true, - }) - .array(), + featuredUsers: featuredUserGuard.array(), applicationsCount: number(), - featuredApplications: Applications.guard - .pick({ - id: true, - name: true, - }) - .array(), + featuredApplications: featuredApplicationGuard.array(), }) ) .array(), @@ -113,15 +102,9 @@ export default function roleRoutes(...[router, tenant]: return { ...role, usersCount, - featuredUsers: users.map(({ id, avatar, name, username, primaryEmail }) => ({ - id, - avatar, - name, - username, - primaryEmail, - })), + featuredUsers: users.map(pickState('id', 'avatar', 'name')), applicationsCount, - featuredApplications: applications.map(({ id, name }) => ({ id, name })), + featuredApplications: applications.map(pickState('id', 'name', 'type')), }; }) ); diff --git a/packages/core/src/utils/RelationQueries.ts b/packages/core/src/utils/RelationQueries.ts index 2810d0c7b..868b3099b 100644 --- a/packages/core/src/utils/RelationQueries.ts +++ b/packages/core/src/utils/RelationQueries.ts @@ -167,7 +167,7 @@ export default class RelationQueries< * @param options Options for the query. * @param options.limit The maximum number of entities to return. * @param options.offset The number of entities to skip. - * @returns A Promise that resolves an array of entities of the given schema. + * @returns A Promise that resolves to the total number of entities and the entities. * * @example * ```ts @@ -182,7 +182,7 @@ export default class RelationQueries< forSchema: S, where: CamelCaseIdObject>, options?: GetEntitiesOptions - ): Promise<[totalCount: number, entities: ReadonlyArray>]> { + ): Promise<[totalNumber: number, entities: ReadonlyArray>]> { const { limit, offset } = options ?? {}; const snakeCaseWhere = snakecaseKeys(where); const forTable = sql.identifier([forSchema.table]); diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index 84bd32c24..4ed5bee46 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -1,6 +1,6 @@ import { type SchemaLike, type GeneratedSchema } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; -import { cond, type Optional, type DeepPartial } from '@silverhand/essentials'; +import { type DeepPartial } from '@silverhand/essentials'; import camelcase from 'camelcase'; import deepmerge from 'deepmerge'; import Router, { type IRouterParamContext } from 'koa-router'; @@ -12,6 +12,7 @@ import koaPagination from '#src/middleware/koa-pagination.js'; import { type TwoRelationsQueries } from './RelationQueries.js'; import type SchemaQueries from './SchemaQueries.js'; +import { parseSearchOptions } from './search.js'; /** * Generate the pathname for from a table name. @@ -129,14 +130,7 @@ export default class SchemaRouter< status: [200], }), async (ctx, next) => { - const { q } = ctx.guard.query; - const search: Optional> = cond( - q && - searchFields.length > 0 && { - fields: searchFields, - keyword: q, - } - ); + const search = parseSearchOptions(searchFields, ctx.guard.query); const { limit, offset } = ctx.pagination; const [count, entities] = await queries.findAll(limit, offset, search); diff --git a/packages/core/src/utils/search.ts b/packages/core/src/utils/search.ts index 524558329..9fe693f16 100644 --- a/packages/core/src/utils/search.ts +++ b/packages/core/src/utils/search.ts @@ -1,9 +1,11 @@ import { SearchJointMode, SearchMatchMode } from '@logto/schemas'; import type { Nullable, Optional } from '@silverhand/essentials'; -import { yes, conditionalString, conditional } from '@silverhand/essentials'; +import { yes, conditionalString, conditional, cond } from '@silverhand/essentials'; import { sql } from 'slonik'; import { snakeCase } from 'snake-case'; +import { type SearchOptions } from '#src/database/utils.js'; + import assertThat from './assert-that.js'; import { isEnum } from './type.js'; @@ -281,3 +283,27 @@ export const buildConditionsFromSearch = ( return sql.join(conditions, getJointModeSql(joint)); }; + +/** + * Parse the search query from the request query string and build the search options + * for certain search fields. + * + * @param searchFields Search fields to be included in the search options. + * @param guardedQuery The guarded query key-value object. + * @returns The search options object, or `undefined` if no search query is found. + */ +export const parseSearchOptions = ( + searchFields: readonly Key[], + guardedQuery: { + q?: string; + } +): Optional> => { + const { q } = guardedQuery; + return cond( + q && + searchFields.length > 0 && { + fields: searchFields, + keyword: q, + } + ); +}; diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts index dd7adfd19..5adc40d59 100644 --- a/packages/integration-tests/src/api/organization.ts +++ b/packages/integration-tests/src/api/organization.ts @@ -3,6 +3,7 @@ import { type Organization, type OrganizationWithRoles, type UserWithOrganizationRoles, + type OrganizationWithFeatured, } from '@logto/schemas'; import { authedAdminApi } from './api.js'; @@ -22,6 +23,11 @@ export class OrganizationApi extends ApiFactory< super('organizations'); } + override async getList(query?: URLSearchParams): Promise { + // eslint-disable-next-line no-restricted-syntax -- This API has different response type + return super.getList(query) as Promise; + } + async addUsers(id: string, userIds: string[]): Promise { await authedAdminApi.post(`${this.path}/${id}/users`, { json: { userIds } }); } diff --git a/packages/integration-tests/src/tests/api/organization.test.ts b/packages/integration-tests/src/tests/api/organization.test.ts index 8fc468bb8..107b8c110 100644 --- a/packages/integration-tests/src/tests/api/organization.test.ts +++ b/packages/integration-tests/src/tests/api/organization.test.ts @@ -1,90 +1,127 @@ -import { generateStandardId } from '@logto/shared'; import { HTTPError } from 'got'; import { OrganizationApiTest } from '#src/helpers/organization.js'; - -const randomId = () => generateStandardId(4); +import { UserApiTest } from '#src/helpers/user.js'; // Add additional layer of describe to run tests in band describe('organization APIs', () => { - describe('organizations', () => { - const organizationApi = new OrganizationApiTest(); + const organizationApi = new OrganizationApiTest(); + const userApi = new UserApiTest(); - afterEach(async () => { - await organizationApi.cleanUp(); + afterEach(async () => { + await Promise.all([organizationApi.cleanUp(), userApi.cleanUp()]); + }); + + it('should get organizations successfully', async () => { + await organizationApi.create({ name: 'test', description: 'A test organization.' }); + await organizationApi.create({ name: 'test2' }); + const organizations = await organizationApi.getList(); + + expect(organizations).toContainEqual( + expect.objectContaining({ name: 'test', description: 'A test organization.' }) + ); + expect(organizations).toContainEqual( + expect.objectContaining({ name: 'test2', description: null }) + ); + for (const organization of organizations) { + expect(organization).not.toHaveProperty('usersCount'); + expect(organization).not.toHaveProperty('featuredUsers'); + } + }); + + it('should get organizations with featured users', async () => { + const [organization1, organization2] = await Promise.all([ + organizationApi.create({ name: 'test' }), + organizationApi.create({ name: 'test' }), + ]); + const createdUsers = await Promise.all( + Array.from({ length: 5 }).map(async () => userApi.create({ name: 'featured' })) + ); + await organizationApi.addUsers( + organization1.id, + createdUsers.map((user) => user.id) + ); + + const organizations = await organizationApi.getList( + new URLSearchParams({ + showFeatured: '1', + }) + ); + + expect(organizations).toContainEqual(expect.objectContaining({ id: organization1.id })); + expect(organizations).toContainEqual(expect.objectContaining({ id: organization2.id })); + for (const organization of organizations) { + expect(organization).toHaveProperty('usersCount'); + expect(organization).toHaveProperty('featuredUsers'); + if (organization.id === organization1.id) { + expect(organization.usersCount).toBe(5); + expect(organization.featuredUsers).toHaveLength(3); + } + + if (organization.id === organization2.id) { + expect(organization.usersCount).toBe(0); + expect(organization.featuredUsers).toHaveLength(0); + } + } + }); + + it('should get organizations with pagination', async () => { + // Add organizations to exceed the default page size + await Promise.all( + Array.from({ length: 30 }).map(async () => organizationApi.create({ name: 'test' })) + ); + + const organizations = await organizationApi.getList(); + expect(organizations).toHaveLength(20); + + const organizations2 = await organizationApi.getList( + new URLSearchParams({ + page: '2', + page_size: '10', + }) + ); + expect(organizations2.length).toBeGreaterThanOrEqual(10); + expect(organizations2[0]?.id).not.toBeFalsy(); + expect(organizations2[0]?.id).toBe(organizations[10]?.id); + }); + + it('should be able to create and get organizations by id', async () => { + const createdOrganization = await organizationApi.create({ name: 'test' }); + const organization = await organizationApi.get(createdOrganization.id); + + expect(organization).toStrictEqual(createdOrganization); + }); + + it('should fail when try to get an organization that does not exist', async () => { + const response = await organizationApi.get('0').catch((error: unknown) => error); + + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); + + it('should be able to update organization', async () => { + const createdOrganization = await organizationApi.create({ name: 'test' }); + const organization = await organizationApi.update(createdOrganization.id, { + name: 'test2', + description: 'test description.', }); - - it('should get organizations successfully', async () => { - await organizationApi.create({ name: 'test', description: 'A test organization.' }); - await organizationApi.create({ name: 'test2' }); - const organizations = await organizationApi.getList(); - - expect(organizations).toContainEqual( - expect.objectContaining({ name: 'test', description: 'A test organization.' }) - ); - expect(organizations).toContainEqual( - expect.objectContaining({ name: 'test2', description: null }) - ); - }); - - it('should get organizations with pagination', async () => { - // Add organizations to exceed the default page size - await Promise.all( - Array.from({ length: 30 }).map(async () => organizationApi.create({ name: 'test' })) - ); - - const organizations = await organizationApi.getList(); - expect(organizations).toHaveLength(20); - - const organizations2 = await organizationApi.getList( - new URLSearchParams({ - page: '2', - page_size: '10', - }) - ); - expect(organizations2.length).toBeGreaterThanOrEqual(10); - expect(organizations2[0]?.id).not.toBeFalsy(); - expect(organizations2[0]?.id).toBe(organizations[10]?.id); - }); - - it('should be able to create and get organizations by id', async () => { - const createdOrganization = await organizationApi.create({ name: 'test' }); - const organization = await organizationApi.get(createdOrganization.id); - - expect(organization).toStrictEqual(createdOrganization); - }); - - it('should fail when try to get an organization that does not exist', async () => { - const response = await organizationApi.get('0').catch((error: unknown) => error); - - expect(response instanceof HTTPError && response.response.statusCode).toBe(404); - }); - - it('should be able to update organization', async () => { - const createdOrganization = await organizationApi.create({ name: 'test' }); - const organization = await organizationApi.update(createdOrganization.id, { - name: 'test2', - description: 'test description.', - }); - expect(organization).toStrictEqual({ - ...createdOrganization, - name: 'test2', - description: 'test description.', - }); - }); - - it('should be able to delete organization', async () => { - const createdOrganization = await organizationApi.create({ name: 'test' }); - await organizationApi.delete(createdOrganization.id); - const response = await organizationApi - .get(createdOrganization.id) - .catch((error: unknown) => error); - expect(response instanceof HTTPError && response.response.statusCode).toBe(404); - }); - - it('should fail when try to delete an organization that does not exist', async () => { - const response = await organizationApi.delete('0').catch((error: unknown) => error); - expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + expect(organization).toStrictEqual({ + ...createdOrganization, + name: 'test2', + description: 'test description.', }); }); + + it('should be able to delete organization', async () => { + const createdOrganization = await organizationApi.create({ name: 'test' }); + await organizationApi.delete(createdOrganization.id); + const response = await organizationApi + .get(createdOrganization.id) + .catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); + + it('should fail when try to delete an organization that does not exist', async () => { + const response = await organizationApi.delete('0').catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); }); diff --git a/packages/schemas/src/types/application.ts b/packages/schemas/src/types/application.ts index d81926093..8095bd226 100644 --- a/packages/schemas/src/types/application.ts +++ b/packages/schemas/src/types/application.ts @@ -1,3 +1,18 @@ -import type { Application } from '../db-entries/index.js'; +import { type z } from 'zod'; + +import { Applications, type Application } from '../db-entries/index.js'; export type ApplicationResponse = Application & { isAdmin: boolean }; + +/** + * An application that is featured for display. Usually used in a list of resources that are + * related to a group of applications. + */ +export type FeaturedApplication = Pick; + +/** The guard for {@link FeaturedApplication}. */ +export const featuredApplicationGuard = Applications.guard.pick({ + id: true, + name: true, + type: true, +}) satisfies z.ZodType; diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index 0f015063b..e59d1cf3a 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -9,6 +9,8 @@ import { Users, } from '../db-entries/index.js'; +import { type FeaturedUser } from './user.js'; + export type OrganizationRoleWithScopes = OrganizationRole & { scopes: Array<{ id: string; @@ -67,3 +69,12 @@ export const userWithOrganizationRolesGuard: z.ZodType>; + featuredUsers: FeaturedUser[]; applicationsCount: number; - featuredApplications: Array>; + featuredApplications: FeaturedApplication[]; }; diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts index 57ae22221..24a28b65b 100644 --- a/packages/schemas/src/types/user.ts +++ b/packages/schemas/src/types/user.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { Users } from '../db-entries/index.js'; +import { type User, Users } from '../db-entries/index.js'; import { MfaFactor } from '../foundations/index.js'; export const userInfoSelectFields = Object.freeze([ @@ -63,3 +63,16 @@ export enum AdminTenantRole { export enum PredefinedScope { All = 'all', } + +/** + * A user that is featured for display. Usually used in a list of resources that are related to + * a group of users. + */ +export type FeaturedUser = Pick; + +/** The guard for {@link FeaturedUser}. */ +export const featuredUserGuard = Users.guard.pick({ + id: true, + avatar: true, + name: true, +}) satisfies z.ZodType;