mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
Merge pull request #6086 from logto-io/gao-org-app-apis
feat(core): add organization - application apis
This commit is contained in:
commit
1620bbd718
10 changed files with 298 additions and 77 deletions
|
@ -1,6 +1,11 @@
|
||||||
import type { Application, CreateApplication } from '@logto/schemas';
|
import type { Application, CreateApplication } from '@logto/schemas';
|
||||||
import { ApplicationType, Applications, SearchJointMode } from '@logto/schemas';
|
import {
|
||||||
import { pick } from '@silverhand/essentials';
|
ApplicationType,
|
||||||
|
Applications,
|
||||||
|
OrganizationApplicationRelations,
|
||||||
|
SearchJointMode,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { condArray, pick } from '@silverhand/essentials';
|
||||||
import type { CommonQueryMethods, SqlSqlToken } from '@silverhand/slonik';
|
import type { CommonQueryMethods, SqlSqlToken } from '@silverhand/slonik';
|
||||||
import { sql } from '@silverhand/slonik';
|
import { sql } from '@silverhand/slonik';
|
||||||
|
|
||||||
|
@ -23,6 +28,7 @@ import {
|
||||||
} from './application-user-consent-scopes.js';
|
} from './application-user-consent-scopes.js';
|
||||||
|
|
||||||
const { table, fields } = convertToIdentifiers(Applications);
|
const { table, fields } = convertToIdentifiers(Applications);
|
||||||
|
const organizationApplicationRelations = convertToIdentifiers(OrganizationApplicationRelations);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The schema field keys that can be used for searching apps. For the actual field names,
|
* The schema field keys that can be used for searching apps. For the actual field names,
|
||||||
|
@ -34,13 +40,13 @@ export const applicationSearchKeys = Object.freeze(['id', 'name', 'description']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The actual database field names that can be used for searching apps. For the schema field
|
* The actual database field names that can be used for searching apps. For the schema field
|
||||||
* keys, see {@link userSearchKeys}.
|
* keys, see {@link applicationSearchKeys}.
|
||||||
*/
|
*/
|
||||||
const applicationSearchFields = Object.freeze(
|
const applicationSearchFields = Object.freeze(
|
||||||
Object.values(pick(Applications.fields, ...applicationSearchKeys))
|
Object.values(pick(Applications.fields, ...applicationSearchKeys))
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildApplicationConditions = (search: Search) => {
|
const buildApplicationSearchConditions = (search: Search) => {
|
||||||
return conditionalSql(
|
return conditionalSql(
|
||||||
search.matches.length > 0,
|
search.matches.length > 0,
|
||||||
() =>
|
() =>
|
||||||
|
@ -53,40 +59,60 @@ const buildApplicationConditions = (search: Search) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildConditionArray = (conditions: SqlSqlToken[]) => {
|
const buildConditionArray = (conditions: SqlSqlToken[]) => {
|
||||||
const filteredConditions = conditions.filter((condition) => condition.sql !== '');
|
const filteredConditions = conditions.filter((condition) => condition.sql.trim() !== '');
|
||||||
return conditionalArraySql(
|
return conditionalArraySql(
|
||||||
filteredConditions,
|
filteredConditions,
|
||||||
(filteredConditions) => sql`where ${sql.join(filteredConditions, sql` and `)}`
|
(filteredConditions) => sql`where ${sql.join(filteredConditions, sql` and `)}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApplicationConditions = {
|
||||||
|
/** The search config object, can apply to fields in {@link applicationSearchFields}. */
|
||||||
|
search: Search;
|
||||||
|
/** Exclude applications with these ids. */
|
||||||
|
excludeApplicationIds?: string[];
|
||||||
|
/** Exclude applications associated with an organization. */
|
||||||
|
excludeOrganizationId?: string;
|
||||||
|
/** Filter applications by types, if not provided, all types will be included. */
|
||||||
|
types?: ApplicationType[];
|
||||||
|
/** Filter applications by whether it is a third party application. */
|
||||||
|
isThirdParty?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildApplicationConditions = ({
|
||||||
|
search,
|
||||||
|
excludeApplicationIds,
|
||||||
|
excludeOrganizationId,
|
||||||
|
types,
|
||||||
|
isThirdParty,
|
||||||
|
}: ApplicationConditions) => {
|
||||||
|
return buildConditionArray(
|
||||||
|
condArray(
|
||||||
|
excludeApplicationIds?.length &&
|
||||||
|
sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})`,
|
||||||
|
excludeOrganizationId &&
|
||||||
|
sql`
|
||||||
|
not exists (
|
||||||
|
select 1 from ${organizationApplicationRelations.table}
|
||||||
|
where ${organizationApplicationRelations.fields.applicationId} = ${fields.id}
|
||||||
|
and ${organizationApplicationRelations.fields.organizationId}=${excludeOrganizationId}
|
||||||
|
)`,
|
||||||
|
types?.length && sql`${fields.type} in (${sql.join(types, sql`, `)})`,
|
||||||
|
typeof isThirdParty === 'boolean' && sql`${fields.isThirdParty} = ${isThirdParty}`,
|
||||||
|
buildApplicationSearchConditions(search)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
||||||
/**
|
/**
|
||||||
* Get the number of applications that match the search conditions, conditions are joined in `and` mode.
|
* Get the number of applications that match the search conditions, conditions are joined in `and` mode.
|
||||||
*
|
|
||||||
* @param search The search config object, can apply to `id`, `name` and `description` field for application.
|
|
||||||
* @param excludeApplicationIds Exclude applications with these ids.
|
|
||||||
* @param isThirdParty Optional boolean, filter applications by whether it is a third party application.
|
|
||||||
* @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included.
|
|
||||||
* @returns A Promise that resolves the number of applications that match the search conditions.
|
|
||||||
*/
|
*/
|
||||||
const countApplications = async (
|
const countApplications = async (conditions: ApplicationConditions) => {
|
||||||
search: Search,
|
|
||||||
excludeApplicationIds: string[],
|
|
||||||
isThirdParty?: boolean,
|
|
||||||
types?: ApplicationType[]
|
|
||||||
) => {
|
|
||||||
const { count } = await pool.one<{ count: string }>(sql`
|
const { count } = await pool.one<{ count: string }>(sql`
|
||||||
select count(*)
|
select count(*)
|
||||||
from ${table}
|
from ${table}
|
||||||
${buildConditionArray([
|
${buildApplicationConditions(conditions)}
|
||||||
excludeApplicationIds.length > 0
|
|
||||||
? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})`
|
|
||||||
: sql``,
|
|
||||||
types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``,
|
|
||||||
typeof isThirdParty === 'boolean' ? sql`${fields.isThirdParty} = ${isThirdParty}` : sql``,
|
|
||||||
buildApplicationConditions(search),
|
|
||||||
])}
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return { count: Number(count) };
|
return { count: Number(count) };
|
||||||
|
@ -94,47 +120,17 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the list of applications that match the search conditions, conditions are joined in `and` mode.
|
* Get the list of applications that match the search conditions, conditions are joined in `and` mode.
|
||||||
*
|
|
||||||
* @param conditions The conditions to filter applications.
|
|
||||||
* @param conditions.search The search config object, can apply to `id`, `name` and `description` field for application
|
|
||||||
* @param conditions.excludeApplicationIds Exclude applications with these ids.
|
|
||||||
* @param conditions.types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included.
|
|
||||||
* @param conditions.isThirdParty Optional boolean, filter applications by whether it is a third party application.
|
|
||||||
* @param conditions.pagination Optional pagination config object.
|
|
||||||
* @param conditions.pagination.limit The number of applications to return.
|
|
||||||
* @param conditions.pagination.offset The offset of applications to return.
|
|
||||||
* @returns A Promise that resolves the list of applications that match the search conditions.
|
|
||||||
*/
|
*/
|
||||||
const findApplications = async ({
|
const findApplications = async (
|
||||||
search,
|
conditions: ApplicationConditions,
|
||||||
excludeApplicationIds,
|
pagination?: { limit: number; offset: number }
|
||||||
types,
|
) =>
|
||||||
isThirdParty,
|
|
||||||
pagination,
|
|
||||||
}: {
|
|
||||||
search: Search;
|
|
||||||
excludeApplicationIds: string[];
|
|
||||||
types?: ApplicationType[];
|
|
||||||
isThirdParty?: boolean;
|
|
||||||
pagination?: {
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
};
|
|
||||||
}) =>
|
|
||||||
pool.any<Application>(sql`
|
pool.any<Application>(sql`
|
||||||
select ${sql.join(Object.values(fields), sql`, `)}
|
select ${sql.join(Object.values(fields), sql`, `)}
|
||||||
from ${table}
|
from ${table}
|
||||||
${buildConditionArray([
|
${buildApplicationConditions(conditions)}
|
||||||
excludeApplicationIds.length > 0
|
|
||||||
? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})`
|
|
||||||
: sql``,
|
|
||||||
types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``,
|
|
||||||
typeof isThirdParty === 'boolean' ? sql`${fields.isThirdParty} = ${isThirdParty}` : sql``,
|
|
||||||
buildApplicationConditions(search),
|
|
||||||
])}
|
|
||||||
order by ${fields.createdAt} desc
|
order by ${fields.createdAt} desc
|
||||||
${conditionalSql(pagination?.limit, (value) => sql`limit ${value}`)}
|
${conditionalSql(pagination, ({ limit, offset }) => sql`limit ${limit} offset ${offset}`)}
|
||||||
${conditionalSql(pagination?.offset, (value) => sql`offset ${value}`)}
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table);
|
const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table);
|
||||||
|
@ -153,14 +149,13 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
||||||
) => updateApplication({ set, where: { id }, jsonbMode: 'merge' });
|
) => updateApplication({ set, where: { id }, jsonbMode: 'merge' });
|
||||||
|
|
||||||
const countAllApplications = async () =>
|
const countAllApplications = async () =>
|
||||||
countApplications(
|
countApplications({
|
||||||
{
|
search: {
|
||||||
matches: [],
|
matches: [],
|
||||||
joint: SearchJointMode.And, // Dummy since there is no match
|
joint: SearchJointMode.And, // Dummy since there is no match
|
||||||
isCaseSensitive: false, // Dummy since there is no match
|
isCaseSensitive: false, // Dummy since there is no match
|
||||||
},
|
},
|
||||||
[]
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const countM2mApplications = async () => {
|
const countM2mApplications = async () => {
|
||||||
const { count } = await pool.one<{ count: string }>(sql`
|
const { count } = await pool.one<{ count: string }>(sql`
|
||||||
|
@ -193,7 +188,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
||||||
${buildConditionArray([
|
${buildConditionArray([
|
||||||
sql`${fields.type} = ${ApplicationType.MachineToMachine}`,
|
sql`${fields.type} = ${ApplicationType.MachineToMachine}`,
|
||||||
sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`,
|
sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`,
|
||||||
buildApplicationConditions(search),
|
buildApplicationSearchConditions(search),
|
||||||
])}
|
])}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
@ -216,7 +211,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
||||||
${buildConditionArray([
|
${buildConditionArray([
|
||||||
sql`${fields.type} = ${ApplicationType.MachineToMachine}`,
|
sql`${fields.type} = ${ApplicationType.MachineToMachine}`,
|
||||||
sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`,
|
sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`,
|
||||||
buildApplicationConditions(search),
|
buildApplicationSearchConditions(search),
|
||||||
])}
|
])}
|
||||||
limit ${limit}
|
limit ${limit}
|
||||||
offset ${offset}
|
offset ${offset}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
OrganizationRoles,
|
OrganizationRoles,
|
||||||
OrganizationRoleApplicationRelations,
|
OrganizationRoleApplicationRelations,
|
||||||
type ApplicationWithOrganizationRoles,
|
type ApplicationWithOrganizationRoles,
|
||||||
|
type OrganizationWithRoles,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { type CommonQueryMethods, sql } from '@silverhand/slonik';
|
import { type CommonQueryMethods, sql } from '@silverhand/slonik';
|
||||||
|
|
||||||
|
@ -25,6 +26,45 @@ export class ApplicationRelationQueries extends TwoRelationsQueries<
|
||||||
super(pool, OrganizationApplicationRelations.table, Organizations, Applications);
|
super(pool, OrganizationApplicationRelations.table, Organizations, Applications);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOrganizationsByApplicationId(
|
||||||
|
applicationId: string,
|
||||||
|
{ limit, offset }: GetEntitiesOptions
|
||||||
|
): Promise<[totalCount: number, organizations: readonly OrganizationWithRoles[]]> {
|
||||||
|
const organizations = convertToIdentifiers(Organizations, true);
|
||||||
|
const roles = convertToIdentifiers(OrganizationRoles, true);
|
||||||
|
const { fields } = convertToIdentifiers(OrganizationApplicationRelations, true);
|
||||||
|
const relations = convertToIdentifiers(OrganizationRoleApplicationRelations, true);
|
||||||
|
|
||||||
|
const [{ count }, entities] = await Promise.all([
|
||||||
|
this.pool.one<{ count: string }>(sql`
|
||||||
|
select count(*)
|
||||||
|
from ${this.table}
|
||||||
|
left join ${organizations.table}
|
||||||
|
on ${fields.organizationId} = ${organizations.fields.id}
|
||||||
|
where ${fields.applicationId} = ${applicationId}
|
||||||
|
`),
|
||||||
|
this.pool.any<OrganizationWithRoles>(sql`
|
||||||
|
select
|
||||||
|
${sql.join(Object.values(organizations.fields), sql`, `)},
|
||||||
|
${aggregateRoles()}
|
||||||
|
from ${this.table}
|
||||||
|
left join ${organizations.table}
|
||||||
|
on ${fields.organizationId} = ${organizations.fields.id}
|
||||||
|
left join ${relations.table}
|
||||||
|
on ${relations.fields.organizationId} = ${organizations.fields.id}
|
||||||
|
and ${relations.fields.applicationId} = ${fields.applicationId}
|
||||||
|
left join ${roles.table}
|
||||||
|
on ${relations.fields.organizationRoleId} = ${roles.fields.id}
|
||||||
|
where ${fields.applicationId} = ${applicationId}
|
||||||
|
group by ${organizations.fields.id}
|
||||||
|
limit ${limit}
|
||||||
|
offset ${offset}
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [Number(count), entities];
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the applications of an organization with their organization roles. */
|
/** Get the applications of an organization with their organization roles. */
|
||||||
async getApplicationsByOrganizationId(
|
async getApplicationsByOrganizationId(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
|
|
@ -65,7 +65,7 @@ export default function adminUserSearchRoutes<T extends ManagementApiRouter>(
|
||||||
if (excludeRoleId && excludeOrganizationId) {
|
if (excludeRoleId && excludeOrganizationId) {
|
||||||
throw new RequestError({
|
throw new RequestError({
|
||||||
code: 'request.invalid_input',
|
code: 'request.invalid_input',
|
||||||
status: 422,
|
status: 400,
|
||||||
details:
|
details:
|
||||||
'Parameter `excludeRoleId` and `excludeOrganizationId` cannot be used at the same time.',
|
'Parameter `excludeRoleId` and `excludeOrganizationId` cannot be used at the same time.',
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"paths": {
|
||||||
|
"/api/applications/{id}/organizations": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Dev feature"],
|
||||||
|
"summary": "Get application organizations",
|
||||||
|
"description": "Get the list of organizations that an application is associated with.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "An array of organizations that the application is associated with."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { organizationWithOrganizationRolesGuard } from '@logto/schemas';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||||
|
|
||||||
|
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
export default function applicationOrganizationRoutes<T extends ManagementApiRouter>(
|
||||||
|
...[router, { queries }]: RouterInitArgs<T>
|
||||||
|
) {
|
||||||
|
// TODO: Remove
|
||||||
|
if (!EnvSet.values.isDevFeaturesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/applications/:id/organizations',
|
||||||
|
koaPagination(),
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string() }),
|
||||||
|
response: organizationWithOrganizationRolesGuard.array(),
|
||||||
|
status: [200, 404],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
|
||||||
|
// Ensure that the user exists.
|
||||||
|
await queries.applications.findApplicationById(id);
|
||||||
|
|
||||||
|
const [count, entities] =
|
||||||
|
await queries.organizations.relations.apps.getOrganizationsByApplicationId(
|
||||||
|
id,
|
||||||
|
ctx.pagination
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.pagination.totalCount = count;
|
||||||
|
ctx.body = entities;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -76,6 +76,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
||||||
.or(applicationTypeGuard.transform((type) => [type]))
|
.or(applicationTypeGuard.transform((type) => [type]))
|
||||||
.optional(),
|
.optional(),
|
||||||
excludeRoleId: string().optional(),
|
excludeRoleId: string().optional(),
|
||||||
|
excludeOrganizationId: string().optional(),
|
||||||
isThirdParty: z.union([z.literal('true'), z.literal('false')]).optional(),
|
isThirdParty: z.union([z.literal('true'), z.literal('false')]).optional(),
|
||||||
}),
|
}),
|
||||||
response: z.array(Applications.guard),
|
response: z.array(Applications.guard),
|
||||||
|
@ -84,7 +85,21 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { limit, offset, disabled: paginationDisabled } = ctx.pagination;
|
const { limit, offset, disabled: paginationDisabled } = ctx.pagination;
|
||||||
const { searchParams } = ctx.URL;
|
const { searchParams } = ctx.URL;
|
||||||
const { types, excludeRoleId, isThirdParty: isThirdPartyParam } = ctx.guard.query;
|
const {
|
||||||
|
types,
|
||||||
|
excludeRoleId,
|
||||||
|
excludeOrganizationId,
|
||||||
|
isThirdParty: isThirdPartyParam,
|
||||||
|
} = ctx.guard.query;
|
||||||
|
|
||||||
|
if (excludeRoleId && excludeOrganizationId) {
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'request.invalid_input',
|
||||||
|
status: 400,
|
||||||
|
details:
|
||||||
|
'Parameter `excludeRoleId` and `excludeOrganizationId` cannot be used at the same time.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isThirdParty = parseIsThirdPartQueryParam(isThirdPartyParam);
|
const isThirdParty = parseIsThirdPartQueryParam(isThirdPartyParam);
|
||||||
|
|
||||||
|
@ -100,20 +115,35 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (paginationDisabled) {
|
if (paginationDisabled) {
|
||||||
ctx.body = await findApplications({ search, excludeApplicationIds, types, isThirdParty });
|
ctx.body = await findApplications({
|
||||||
|
search,
|
||||||
|
excludeApplicationIds,
|
||||||
|
excludeOrganizationId,
|
||||||
|
types,
|
||||||
|
isThirdParty,
|
||||||
|
});
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ count }, applications] = await Promise.all([
|
const [{ count }, applications] = await Promise.all([
|
||||||
countApplications(search, excludeApplicationIds, isThirdParty, types),
|
countApplications({
|
||||||
findApplications({
|
|
||||||
search,
|
search,
|
||||||
excludeApplicationIds,
|
excludeApplicationIds,
|
||||||
types,
|
excludeOrganizationId,
|
||||||
isThirdParty,
|
isThirdParty,
|
||||||
pagination: { limit, offset },
|
types,
|
||||||
}),
|
}),
|
||||||
|
findApplications(
|
||||||
|
{
|
||||||
|
search,
|
||||||
|
excludeApplicationIds,
|
||||||
|
excludeOrganizationId,
|
||||||
|
types,
|
||||||
|
isThirdParty,
|
||||||
|
},
|
||||||
|
{ limit, offset }
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Return totalCount to pagination middleware
|
// Return totalCount to pagination middleware
|
||||||
|
|
|
@ -12,6 +12,7 @@ import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
import koaAuth from '../middleware/koa-auth/index.js';
|
import koaAuth from '../middleware/koa-auth/index.js';
|
||||||
|
|
||||||
import adminUserRoutes from './admin-user/index.js';
|
import adminUserRoutes from './admin-user/index.js';
|
||||||
|
import applicationOrganizationRoutes from './applications/application-organization.js';
|
||||||
import applicationProtectedAppMetadataRoutes from './applications/application-protected-app-metadata.js';
|
import applicationProtectedAppMetadataRoutes from './applications/application-protected-app-metadata.js';
|
||||||
import applicationRoleRoutes from './applications/application-role.js';
|
import applicationRoleRoutes from './applications/application-role.js';
|
||||||
import applicationSignInExperienceRoutes from './applications/application-sign-in-experience.js';
|
import applicationSignInExperienceRoutes from './applications/application-sign-in-experience.js';
|
||||||
|
@ -52,16 +53,17 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
managementRouter.use(koaTenantGuard(tenant.id, tenant.queries));
|
managementRouter.use(koaTenantGuard(tenant.id, tenant.queries));
|
||||||
managementRouter.use(koaManagementApiHooks(tenant.libraries.hooks));
|
managementRouter.use(koaManagementApiHooks(tenant.libraries.hooks));
|
||||||
|
|
||||||
|
// TODO: FIXME @sijie @darcy mount these routes in `applicationRoutes` instead
|
||||||
applicationRoutes(managementRouter, tenant);
|
applicationRoutes(managementRouter, tenant);
|
||||||
applicationRoleRoutes(managementRouter, tenant);
|
applicationRoleRoutes(managementRouter, tenant);
|
||||||
|
applicationProtectedAppMetadataRoutes(managementRouter, tenant);
|
||||||
|
applicationOrganizationRoutes(managementRouter, tenant);
|
||||||
|
|
||||||
// Third-party application related routes
|
// Third-party application related routes
|
||||||
applicationUserConsentScopeRoutes(managementRouter, tenant);
|
applicationUserConsentScopeRoutes(managementRouter, tenant);
|
||||||
applicationSignInExperienceRoutes(managementRouter, tenant);
|
applicationSignInExperienceRoutes(managementRouter, tenant);
|
||||||
applicationUserConsentOrganizationRoutes(managementRouter, tenant);
|
applicationUserConsentOrganizationRoutes(managementRouter, tenant);
|
||||||
|
|
||||||
applicationProtectedAppMetadataRoutes(managementRouter, tenant);
|
|
||||||
|
|
||||||
logtoConfigRoutes(managementRouter, tenant);
|
logtoConfigRoutes(managementRouter, tenant);
|
||||||
connectorRoutes(managementRouter, tenant);
|
connectorRoutes(managementRouter, tenant);
|
||||||
resourceRoutes(managementRouter, tenant);
|
resourceRoutes(managementRouter, tenant);
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
type OidcClientMetadata,
|
type OidcClientMetadata,
|
||||||
type Role,
|
type Role,
|
||||||
type ProtectedAppMetadata,
|
type ProtectedAppMetadata,
|
||||||
|
type OrganizationWithRoles,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { formUrlEncodedHeaders } from '@logto/shared';
|
import { formUrlEncodedHeaders } from '@logto/shared';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
|
@ -108,3 +109,11 @@ export const generateM2mLog = async (applicationId: string) => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Get organizations that an application is associated with. */
|
||||||
|
export const getOrganizations = async (applicationId: string, page: number, pageSize: number) =>
|
||||||
|
authedAdminApi
|
||||||
|
.get(`applications/${applicationId}/organizations`, {
|
||||||
|
searchParams: { page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
.json<OrganizationWithRoles[]>();
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { type Application, ApplicationType } from '@logto/schemas';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createApplication as createApplicationApi,
|
||||||
|
deleteApplication,
|
||||||
|
getApplications,
|
||||||
|
getOrganizations,
|
||||||
|
} from '#src/api/application.js';
|
||||||
|
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||||
|
import { devFeatureTest, generateTestName } from '#src/utils.js';
|
||||||
|
|
||||||
|
devFeatureTest.describe('application organizations', () => {
|
||||||
|
const organizationApi = new OrganizationApiTest();
|
||||||
|
const applications: Application[] = [];
|
||||||
|
const createApplication = async (...args: Parameters<typeof createApplicationApi>) => {
|
||||||
|
const created = await createApplicationApi(...args);
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||||
|
applications.push(created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||||
|
applications.push(
|
||||||
|
await createApplication(generateTestName(), ApplicationType.MachineToMachine)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 30 }).map(async () => {
|
||||||
|
const organization = await organizationApi.create({ name: generateTestName() });
|
||||||
|
await organizationApi.applications.add(organization.id, [applications[0]!.id]);
|
||||||
|
return organization;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
organizationApi.cleanUp(),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
...applications.map(async ({ id }) => deleteApplication(id).catch(() => {})),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get organizations by application id with pagination', async () => {
|
||||||
|
const organizations1 = await getOrganizations(applications[0]!.id, 1, 30);
|
||||||
|
const organizations2 = await getOrganizations(applications[0]!.id, 2, 10);
|
||||||
|
const organizations3 = await getOrganizations(applications[0]!.id, 2, 20);
|
||||||
|
|
||||||
|
expect(organizations1).toEqual(
|
||||||
|
expect.arrayContaining(
|
||||||
|
organizationApi.organizations.map((object) => expect.objectContaining(object))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(organizations1).toHaveLength(30);
|
||||||
|
expect(organizations2).toHaveLength(10);
|
||||||
|
expect(organizations3).toHaveLength(10);
|
||||||
|
expect(organizations2[0]?.id).toBe(organizations1[10]?.id);
|
||||||
|
expect(organizations3[0]?.id).toBe(organizations1[20]?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to fetch applications by excluding an organization', async () => {
|
||||||
|
const excludedOrganization = await organizationApi.create({ name: generateTestName() });
|
||||||
|
const applications = await Promise.all(
|
||||||
|
Array.from({ length: 3 }).map(async () =>
|
||||||
|
createApplication(generateTestName(), ApplicationType.MachineToMachine)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await organizationApi.applications.add(excludedOrganization.id, [applications[0]!.id]);
|
||||||
|
|
||||||
|
const fetchedApplications = await getApplications(undefined, {
|
||||||
|
excludeOrganizationId: excludedOrganization.id,
|
||||||
|
page_size: '100', // Just in case
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchedApplications).not.toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining(applications[0]!)])
|
||||||
|
);
|
||||||
|
expect(fetchedApplications).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining(applications[1]!),
|
||||||
|
expect.objectContaining(applications[2]!),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from '#src/api/index.js';
|
} from '#src/api/index.js';
|
||||||
import { expectRejects } from '#src/helpers/index.js';
|
import { expectRejects } from '#src/helpers/index.js';
|
||||||
|
|
||||||
describe('admin console application', () => {
|
describe('application APIs', () => {
|
||||||
it('should create application successfully', async () => {
|
it('should create application successfully', async () => {
|
||||||
const applicationName = 'test-create-app';
|
const applicationName = 'test-create-app';
|
||||||
const applicationType = ApplicationType.SPA;
|
const applicationType = ApplicationType.SPA;
|
||||||
|
|
Loading…
Reference in a new issue