mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
Merge pull request #4651 from logto-io/gao-org-apis-3
feat(core): organization role APIs
This commit is contained in:
commit
a74e523bbe
12 changed files with 484 additions and 66 deletions
|
@ -73,6 +73,7 @@
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"p-retry": "^6.0.0",
|
"p-retry": "^6.0.0",
|
||||||
"pg-protocol": "^1.6.0",
|
"pg-protocol": "^1.6.0",
|
||||||
|
"pluralize": "^8.0.0",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"redis": "^4.6.5",
|
"redis": "^4.6.5",
|
||||||
"roarr": "^7.11.0",
|
"roarr": "^7.11.0",
|
||||||
|
@ -100,6 +101,7 @@
|
||||||
"@types/koa__cors": "^4.0.0",
|
"@types/koa__cors": "^4.0.0",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
"@types/oidc-provider": "^8.0.0",
|
"@types/oidc-provider": "^8.0.0",
|
||||||
|
"@types/pluralize": "^0.0.31",
|
||||||
"@types/qrcode": "^1.5.2",
|
"@types/qrcode": "^1.5.2",
|
||||||
"@types/semver": "^7.3.12",
|
"@types/semver": "^7.3.12",
|
||||||
"@types/sinon": "^10.0.13",
|
"@types/sinon": "^10.0.13",
|
||||||
|
|
20
packages/core/src/queries/organization-roles.ts
Normal file
20
packages/core/src/queries/organization-roles.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import {
|
||||||
|
type OrganizationRoleKeys,
|
||||||
|
OrganizationRoles,
|
||||||
|
type CreateOrganizationRole,
|
||||||
|
type OrganizationRole,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { type CommonQueryMethods } from 'slonik';
|
||||||
|
|
||||||
|
import SchemaQueries from '#src/utils/SchemaQueries.js';
|
||||||
|
|
||||||
|
/** Class of queries for roles in the organization template. */
|
||||||
|
export default class OrganizationRoleQueries extends SchemaQueries<
|
||||||
|
OrganizationRoleKeys,
|
||||||
|
CreateOrganizationRole,
|
||||||
|
OrganizationRole
|
||||||
|
> {
|
||||||
|
constructor(pool: CommonQueryMethods) {
|
||||||
|
super(pool, OrganizationRoles);
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ import hookRoutes from './hook.js';
|
||||||
import interactionRoutes from './interaction/index.js';
|
import interactionRoutes from './interaction/index.js';
|
||||||
import logRoutes from './log.js';
|
import logRoutes from './log.js';
|
||||||
import logtoConfigRoutes from './logto-config.js';
|
import logtoConfigRoutes from './logto-config.js';
|
||||||
|
import organizationRoleRoutes from './organization-roles.js';
|
||||||
import organizationScopeRoutes from './organization-scopes.js';
|
import organizationScopeRoutes from './organization-scopes.js';
|
||||||
import organizationRoutes from './organizations.js';
|
import organizationRoutes from './organizations.js';
|
||||||
import resourceRoutes from './resource.js';
|
import resourceRoutes from './resource.js';
|
||||||
|
@ -67,6 +68,7 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
domainRoutes(managementRouter, tenant);
|
domainRoutes(managementRouter, tenant);
|
||||||
organizationRoutes(managementRouter, tenant);
|
organizationRoutes(managementRouter, tenant);
|
||||||
organizationScopeRoutes(managementRouter, tenant);
|
organizationScopeRoutes(managementRouter, tenant);
|
||||||
|
organizationRoleRoutes(managementRouter, tenant);
|
||||||
|
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
const anonymousRouter: AnonymousRouter = new Router();
|
||||||
wellKnownRoutes(anonymousRouter, tenant);
|
wellKnownRoutes(anonymousRouter, tenant);
|
||||||
|
|
83
packages/core/src/routes/organization-roles.ts
Normal file
83
packages/core/src/routes/organization-roles.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import {
|
||||||
|
type CreateOrganizationRole,
|
||||||
|
type OrganizationRole,
|
||||||
|
type OrganizationRoleKeys,
|
||||||
|
OrganizationRoles,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { UniqueIntegrityConstraintViolationError } from 'slonik';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
import SchemaRouter, { SchemaActions } from '#src/utils/SchemaRouter.js';
|
||||||
|
|
||||||
|
import { type AuthedRouter, type RouterInitArgs } from './types.js';
|
||||||
|
|
||||||
|
class OrganizationRoleActions extends SchemaActions<
|
||||||
|
OrganizationRoleKeys,
|
||||||
|
CreateOrganizationRole,
|
||||||
|
OrganizationRole
|
||||||
|
> {
|
||||||
|
override async post(
|
||||||
|
data: Omit<CreateOrganizationRole, 'id'>
|
||||||
|
): Promise<Readonly<OrganizationRole>> {
|
||||||
|
try {
|
||||||
|
return await super.post(data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof UniqueIntegrityConstraintViolationError) {
|
||||||
|
throw new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function organizationRoleRoutes<T extends AuthedRouter>(
|
||||||
|
...[
|
||||||
|
originalRouter,
|
||||||
|
{
|
||||||
|
queries: { organizationRoles, organizationRoleScopeRelations },
|
||||||
|
},
|
||||||
|
]: RouterInitArgs<T>
|
||||||
|
) {
|
||||||
|
const actions = new OrganizationRoleActions(organizationRoles);
|
||||||
|
const router = new SchemaRouter(OrganizationRoles, actions, { disabled: { post: true } });
|
||||||
|
|
||||||
|
/** Allows to carry an initial set of scopes for creating a new organization role. */
|
||||||
|
type CreateOrganizationRolePayload = Omit<CreateOrganizationRole, 'id'> & { scopeIds: string[] };
|
||||||
|
|
||||||
|
const createGuard: z.ZodType<CreateOrganizationRolePayload, z.ZodTypeDef, unknown> =
|
||||||
|
OrganizationRoles.createGuard
|
||||||
|
.omit({
|
||||||
|
id: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
scopeIds: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
koaGuard({
|
||||||
|
body: createGuard,
|
||||||
|
response: OrganizationRoles.guard,
|
||||||
|
status: [201, 422],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { scopeIds, ...data } = ctx.guard.body;
|
||||||
|
const role = await actions.post(data);
|
||||||
|
|
||||||
|
if (scopeIds.length > 0) {
|
||||||
|
await organizationRoleScopeRelations.insert(
|
||||||
|
...scopeIds.map<[string, string]>((id) => [role.id, id])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = role;
|
||||||
|
ctx.status = 201;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
originalRouter.use(router.routes());
|
||||||
|
}
|
|
@ -1,3 +1,8 @@
|
||||||
|
import {
|
||||||
|
OrganizationRoleScopeRelations,
|
||||||
|
OrganizationRoles,
|
||||||
|
OrganizationScopes,
|
||||||
|
} from '@logto/schemas';
|
||||||
import type { CommonQueryMethods } from 'slonik';
|
import type { CommonQueryMethods } from 'slonik';
|
||||||
|
|
||||||
import { type WellKnownCache } from '#src/caches/well-known.js';
|
import { type WellKnownCache } from '#src/caches/well-known.js';
|
||||||
|
@ -11,6 +16,7 @@ import { createHooksQueries } from '#src/queries/hooks.js';
|
||||||
import { createLogQueries } from '#src/queries/log.js';
|
import { createLogQueries } from '#src/queries/log.js';
|
||||||
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
||||||
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js';
|
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js';
|
||||||
|
import OrganizationRoleQueries from '#src/queries/organization-roles.js';
|
||||||
import OrganizationScopeQueries from '#src/queries/organization-scopes.js';
|
import OrganizationScopeQueries from '#src/queries/organization-scopes.js';
|
||||||
import OrganizationQueries from '#src/queries/organizations.js';
|
import OrganizationQueries from '#src/queries/organizations.js';
|
||||||
import { createPasscodeQueries } from '#src/queries/passcode.js';
|
import { createPasscodeQueries } from '#src/queries/passcode.js';
|
||||||
|
@ -22,6 +28,7 @@ import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.j
|
||||||
import { createUserQueries } from '#src/queries/user.js';
|
import { createUserQueries } from '#src/queries/user.js';
|
||||||
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
||||||
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
||||||
|
import RelationQueries from '#src/utils/RelationQueries.js';
|
||||||
|
|
||||||
export default class Queries {
|
export default class Queries {
|
||||||
applications = createApplicationQueries(this.pool);
|
applications = createApplicationQueries(this.pool);
|
||||||
|
@ -46,6 +53,14 @@ export default class Queries {
|
||||||
organizations = new OrganizationQueries(this.pool);
|
organizations = new OrganizationQueries(this.pool);
|
||||||
/** Organization template scope queries. */
|
/** Organization template scope queries. */
|
||||||
organizationScopes = new OrganizationScopeQueries(this.pool);
|
organizationScopes = new OrganizationScopeQueries(this.pool);
|
||||||
|
/** Organization template role queries. */
|
||||||
|
organizationRoles = new OrganizationRoleQueries(this.pool);
|
||||||
|
organizationRoleScopeRelations = new RelationQueries(
|
||||||
|
this.pool,
|
||||||
|
OrganizationRoleScopeRelations.table,
|
||||||
|
OrganizationRoles.table,
|
||||||
|
OrganizationScopes.table
|
||||||
|
);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly pool: CommonQueryMethods,
|
public readonly pool: CommonQueryMethods,
|
||||||
|
|
108
packages/core/src/utils/RelationQueries.ts
Normal file
108
packages/core/src/utils/RelationQueries.ts
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import pluralize from 'pluralize';
|
||||||
|
import { sql, type CommonQueryMethods } from 'slonik';
|
||||||
|
|
||||||
|
type AtLeast2<T extends unknown[]> = `${T['length']}` extends '0' | '1' ? never : T;
|
||||||
|
|
||||||
|
type RemoveLiteral<T extends string, L extends string> = T extends L ? Exclude<T, L> : T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query class for relation tables that connect several tables by their entry ids.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* Let's say we have two tables `users` and `groups` and a relation table
|
||||||
|
* `user_group_relations`. Then we can create a `RelationQueries` instance like this:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', 'users', 'groups');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* To insert a new relation, we can use the {@link RelationQueries.insert} method:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* await userGroupRelations.insert(['user-id-1', 'group-id-1']);
|
||||||
|
* // Insert multiple relations at once
|
||||||
|
* await userGroupRelations.insert(
|
||||||
|
* ['user-id-1', 'group-id-1'],
|
||||||
|
* ['user-id-2', 'group-id-1']
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* To get all entries for a specific table, we can use the {@link RelationQueries.getEntries} method:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* await userGroupRelations.getEntries('users', { group_id: 'group-id-1' });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This will return all entries for the `users` table that are connected to the
|
||||||
|
* group with the id `group-id-1`.
|
||||||
|
*/
|
||||||
|
export default class RelationQueries<
|
||||||
|
SnakeCaseRelations extends Array<Lowercase<string>>,
|
||||||
|
Length = AtLeast2<SnakeCaseRelations>['length'],
|
||||||
|
> {
|
||||||
|
protected get table() {
|
||||||
|
return sql.identifier([this.relationTable]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly relations: SnakeCaseRelations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pool The database pool.
|
||||||
|
* @param relationTable The name of the relation table.
|
||||||
|
* @param relations The names of the tables that are connected by the relation table.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
public readonly pool: CommonQueryMethods,
|
||||||
|
public readonly relationTable: string,
|
||||||
|
...relations: Readonly<SnakeCaseRelations>
|
||||||
|
) {
|
||||||
|
this.relations = relations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert new entries into the relation table.
|
||||||
|
*
|
||||||
|
* Each entry must contain the same number of ids as the number of relations, and
|
||||||
|
* the order of the ids must match the order of the relations.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', 'users', 'groups');
|
||||||
|
*
|
||||||
|
* userGroupRelations.insert(['user-id-1', 'group-id-1']);
|
||||||
|
* // Insert multiple relations at once
|
||||||
|
* userGroupRelations.insert(
|
||||||
|
* ['user-id-1', 'group-id-1'],
|
||||||
|
* ['user-id-2', 'group-id-1']
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param data Entries to insert.
|
||||||
|
* @returns A Promise that resolves to the query result.
|
||||||
|
*/
|
||||||
|
async insert(...data: ReadonlyArray<string[] & { length: Length }>) {
|
||||||
|
return this.pool.query(sql`
|
||||||
|
insert into ${this.table} (${sql.join(
|
||||||
|
this.relations.map((relation) => sql.identifier([pluralize(relation, 1) + '_id'])),
|
||||||
|
sql`, `
|
||||||
|
)})
|
||||||
|
values ${sql.join(
|
||||||
|
data.map(
|
||||||
|
(relation) =>
|
||||||
|
sql`(${sql.join(
|
||||||
|
relation.map((id) => sql`${id}`),
|
||||||
|
sql`, `
|
||||||
|
)})`
|
||||||
|
),
|
||||||
|
sql`, `
|
||||||
|
)};
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntries<L extends SnakeCaseRelations[number]>(
|
||||||
|
forRelation: L,
|
||||||
|
where: Record<RemoveLiteral<SnakeCaseRelations[number], L>, unknown>
|
||||||
|
) {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
import { type SchemaLike, type GeneratedSchema, type Guard } from '@logto/schemas';
|
import { type SchemaLike, type GeneratedSchema, type Guard } from '@logto/schemas';
|
||||||
import { generateStandardId, type OmitAutoSetFields } from '@logto/shared';
|
import { generateStandardId, type OmitAutoSetFields } from '@logto/shared';
|
||||||
|
import { type DeepPartial } from '@silverhand/essentials';
|
||||||
|
import deepmerge from 'deepmerge';
|
||||||
import Router, { type IRouterParamContext } from 'koa-router';
|
import Router, { type IRouterParamContext } from 'koa-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
@ -76,6 +78,22 @@ export class SchemaActions<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SchemaRouterConfig = {
|
||||||
|
/** Disable certain routes for the router. */
|
||||||
|
disabled: {
|
||||||
|
/** Disable `GET /` route. */
|
||||||
|
get: boolean;
|
||||||
|
/** Disable `POST /` route. */
|
||||||
|
post: boolean;
|
||||||
|
/** Disable `GET /:id` route. */
|
||||||
|
getById: boolean;
|
||||||
|
/** Disable `PATCH /:id` route. */
|
||||||
|
patchById: boolean;
|
||||||
|
/** Disable `DELETE /:id` route. */
|
||||||
|
deleteById: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A standard RESTful router for a schema.
|
* A standard RESTful router for a schema.
|
||||||
*
|
*
|
||||||
|
@ -98,77 +116,105 @@ export default class SchemaRouter<
|
||||||
StateT = unknown,
|
StateT = unknown,
|
||||||
CustomT extends IRouterParamContext = IRouterParamContext,
|
CustomT extends IRouterParamContext = IRouterParamContext,
|
||||||
> extends Router<StateT, CustomT> {
|
> extends Router<StateT, CustomT> {
|
||||||
|
public readonly config: SchemaRouterConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly schema: GeneratedSchema<Key, CreateSchema, Schema>,
|
public readonly schema: GeneratedSchema<Key, CreateSchema, Schema>,
|
||||||
public readonly actions: SchemaActions<Key, CreateSchema, Schema>
|
public readonly actions: SchemaActions<Key, CreateSchema, Schema>,
|
||||||
|
config: DeepPartial<SchemaRouterConfig> = {}
|
||||||
) {
|
) {
|
||||||
super({ prefix: '/' + schema.table.replaceAll('_', '-') });
|
super({ prefix: '/' + schema.table.replaceAll('_', '-') });
|
||||||
|
|
||||||
this.get(
|
this.config = deepmerge<SchemaRouterConfig, DeepPartial<SchemaRouterConfig>>(
|
||||||
'/',
|
{
|
||||||
koaPagination(),
|
disabled: {
|
||||||
koaGuard({ response: schema.guard.array(), status: [200] }),
|
get: false,
|
||||||
async (ctx, next) => {
|
post: false,
|
||||||
const [count, entities] = await actions.get(ctx.pagination);
|
getById: false,
|
||||||
ctx.pagination.totalCount = count;
|
patchById: false,
|
||||||
ctx.body = entities;
|
deleteById: false,
|
||||||
return next();
|
},
|
||||||
}
|
},
|
||||||
|
config
|
||||||
);
|
);
|
||||||
|
|
||||||
this.post(
|
const { disabled } = this.config;
|
||||||
'/',
|
|
||||||
koaGuard({
|
|
||||||
// eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well for generic types
|
|
||||||
body: schema.createGuard.omit({ id: true }) as Guard<Omit<CreateSchema, 'id'>>,
|
|
||||||
response: schema.guard,
|
|
||||||
status: [201, 422],
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
ctx.body = await actions.post(ctx.guard.body);
|
|
||||||
ctx.status = 201;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.get(
|
if (!disabled.get) {
|
||||||
'/:id',
|
this.get(
|
||||||
koaGuard({
|
'/',
|
||||||
params: z.object({ id: z.string().min(1) }),
|
koaPagination(),
|
||||||
response: schema.guard,
|
koaGuard({ response: schema.guard.array(), status: [200] }),
|
||||||
status: [200, 404],
|
async (ctx, next) => {
|
||||||
}),
|
const [count, entities] = await actions.get(ctx.pagination);
|
||||||
async (ctx, next) => {
|
ctx.pagination.totalCount = count;
|
||||||
ctx.body = await actions.getById(ctx.guard.params.id);
|
ctx.body = entities;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.patch(
|
if (!disabled.post) {
|
||||||
'/:id',
|
this.post(
|
||||||
koaGuard({
|
'/',
|
||||||
params: z.object({ id: z.string().min(1) }),
|
koaGuard({
|
||||||
body: schema.updateGuard,
|
// eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well for generic types
|
||||||
response: schema.guard,
|
body: schema.createGuard.omit({ id: true }) as Guard<Omit<CreateSchema, 'id'>>,
|
||||||
status: [200, 404],
|
response: schema.guard,
|
||||||
}),
|
status: [201],
|
||||||
async (ctx, next) => {
|
}),
|
||||||
ctx.body = await actions.patchById(ctx.guard.params.id, ctx.guard.body);
|
async (ctx, next) => {
|
||||||
return next();
|
ctx.body = await actions.post(ctx.guard.body);
|
||||||
}
|
ctx.status = 201;
|
||||||
);
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.delete(
|
if (!disabled.getById) {
|
||||||
'/:id',
|
this.get(
|
||||||
koaGuard({
|
'/:id',
|
||||||
params: z.object({ id: z.string().min(1) }),
|
koaGuard({
|
||||||
status: [204, 404],
|
params: z.object({ id: z.string().min(1) }),
|
||||||
}),
|
response: schema.guard,
|
||||||
async (ctx, next) => {
|
status: [200, 404],
|
||||||
await actions.deleteById(ctx.guard.params.id);
|
}),
|
||||||
ctx.status = 204;
|
async (ctx, next) => {
|
||||||
return next();
|
ctx.body = await actions.getById(ctx.guard.params.id);
|
||||||
}
|
return next();
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disabled.patchById) {
|
||||||
|
this.patch(
|
||||||
|
'/:id',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string().min(1) }),
|
||||||
|
body: schema.updateGuard,
|
||||||
|
response: schema.guard,
|
||||||
|
status: [200, 404],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
ctx.body = await actions.patchById(ctx.guard.params.id, ctx.guard.body);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disabled.deleteById) {
|
||||||
|
this.delete(
|
||||||
|
'/:id',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string().min(1) }),
|
||||||
|
status: [204, 404],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
await actions.deleteById(ctx.guard.params.id);
|
||||||
|
ctx.status = 204;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
14
packages/integration-tests/src/api/organization-role.ts
Normal file
14
packages/integration-tests/src/api/organization-role.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { type OrganizationRole } from '@logto/schemas';
|
||||||
|
|
||||||
|
import { ApiFactory } from './factory.js';
|
||||||
|
|
||||||
|
class OrganizationRoleApi extends ApiFactory<
|
||||||
|
OrganizationRole,
|
||||||
|
{ name: string; description?: string; scopeIds?: string[] }
|
||||||
|
> {
|
||||||
|
constructor() {
|
||||||
|
super('organization-roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roleApi = new OrganizationRoleApi();
|
|
@ -0,0 +1,115 @@
|
||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
|
import { generateStandardId } from '@logto/shared';
|
||||||
|
import { isKeyInObject } from '@silverhand/essentials';
|
||||||
|
import { HTTPError } from 'got';
|
||||||
|
|
||||||
|
import { roleApi } from '#src/api/organization-role.js';
|
||||||
|
import { scopeApi } from '#src/api/organization-scope.js';
|
||||||
|
|
||||||
|
const randomId = () => generateStandardId(4);
|
||||||
|
|
||||||
|
describe('organization roles', () => {
|
||||||
|
it('should fail if the name of the new organization role already exists', async () => {
|
||||||
|
const name = 'test' + randomId();
|
||||||
|
await roleApi.create({ name });
|
||||||
|
const response = await roleApi.create({ name }).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
assert(response instanceof HTTPError);
|
||||||
|
|
||||||
|
const { statusCode, body: raw } = response.response;
|
||||||
|
const body: unknown = JSON.parse(String(raw));
|
||||||
|
expect(statusCode).toBe(400);
|
||||||
|
expect(isKeyInObject(body, 'code') && body.code).toBe('entity.duplicate_value_of_unique_field');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to create a role with some scopes', async () => {
|
||||||
|
const name = 'test' + randomId();
|
||||||
|
const [scope1, scope2] = await Promise.all([
|
||||||
|
scopeApi.create({ name: 'test' + randomId() }),
|
||||||
|
scopeApi.create({ name: 'test' + randomId() }),
|
||||||
|
]);
|
||||||
|
const scopeIds = [scope1.id, scope2.id];
|
||||||
|
const role = await roleApi.create({ name, scopeIds });
|
||||||
|
|
||||||
|
expect(role).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Check scopes under a role after API is implemented
|
||||||
|
await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get organization roles successfully', async () => {
|
||||||
|
const [name1, name2] = ['test' + randomId(), 'test' + randomId()];
|
||||||
|
await roleApi.create({ name: name1, description: 'A test organization role.' });
|
||||||
|
await roleApi.create({ name: name2 });
|
||||||
|
const roles = await roleApi.getList();
|
||||||
|
|
||||||
|
expect(roles).toContainEqual(
|
||||||
|
expect.objectContaining({ name: name1, description: 'A test organization role.' })
|
||||||
|
);
|
||||||
|
expect(roles).toContainEqual(expect.objectContaining({ name: name2, description: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get organization roles with pagination', async () => {
|
||||||
|
// Add 20 roles to exceed the default page size
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 30 }).map(async () => roleApi.create({ name: 'test' + randomId() }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const roles = await roleApi.getList();
|
||||||
|
expect(roles).toHaveLength(20);
|
||||||
|
|
||||||
|
const roles2 = await roleApi.getList(
|
||||||
|
new URLSearchParams({
|
||||||
|
page: '2',
|
||||||
|
page_size: '10',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(roles2.length).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(roles2[0]?.id).not.toBeFalsy();
|
||||||
|
expect(roles2[0]?.id).toBe(roles[10]?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to create and get organization roles by id', async () => {
|
||||||
|
const createdRole = await roleApi.create({ name: 'test' + randomId() });
|
||||||
|
const role = await roleApi.get(createdRole.id);
|
||||||
|
|
||||||
|
expect(role).toStrictEqual(createdRole);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when try to get an organization role that does not exist', async () => {
|
||||||
|
const response = await roleApi.get('0').catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to update organization role', async () => {
|
||||||
|
const createdRole = await roleApi.create({ name: 'test' + randomId() });
|
||||||
|
const newName = 'test' + randomId();
|
||||||
|
const role = await roleApi.update(createdRole.id, {
|
||||||
|
name: newName,
|
||||||
|
description: 'test description.',
|
||||||
|
});
|
||||||
|
expect(role).toStrictEqual({
|
||||||
|
...createdRole,
|
||||||
|
name: newName,
|
||||||
|
description: 'test description.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to delete organization role', async () => {
|
||||||
|
const createdRole = await roleApi.create({ name: 'test' + randomId() });
|
||||||
|
await roleApi.delete(createdRole.id);
|
||||||
|
const response = await roleApi.get(createdRole.id).catch((error: unknown) => error);
|
||||||
|
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when try to delete an organization role that does not exist', async () => {
|
||||||
|
const response = await roleApi.delete('0').catch((error: unknown) => error);
|
||||||
|
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
|
@ -21,9 +21,11 @@ export type GeneratedSchema<
|
||||||
Key extends string,
|
Key extends string,
|
||||||
CreateSchema extends Partial<SchemaLike<Key>>,
|
CreateSchema extends Partial<SchemaLike<Key>>,
|
||||||
Schema extends SchemaLike<Key>,
|
Schema extends SchemaLike<Key>,
|
||||||
|
Table extends string = string,
|
||||||
|
TableSingular extends string = string,
|
||||||
> = Readonly<{
|
> = Readonly<{
|
||||||
table: string;
|
table: Table;
|
||||||
tableSingular: string;
|
tableSingular: TableSingular;
|
||||||
fields: {
|
fields: {
|
||||||
[key in Key]: string;
|
[key in Key]: string;
|
||||||
};
|
};
|
||||||
|
|
|
@ -126,7 +126,13 @@ export const generateSchema = ({ name, comments, fields }: TableWithType) => {
|
||||||
'',
|
'',
|
||||||
`export const ${camelcase(name, {
|
`export const ${camelcase(name, {
|
||||||
pascalCase: true,
|
pascalCase: true,
|
||||||
})}: GeneratedSchema<${modelName}Keys, ${databaseEntryType}, ${modelName}> = Object.freeze({`,
|
})}: GeneratedSchema<
|
||||||
|
${modelName}Keys,
|
||||||
|
${databaseEntryType},
|
||||||
|
${modelName},
|
||||||
|
'${name}',
|
||||||
|
'${pluralize(name, 1)}'
|
||||||
|
> = Object.freeze({`,
|
||||||
` table: '${name}',`,
|
` table: '${name}',`,
|
||||||
` tableSingular: '${pluralize(name, 1)}',`,
|
` tableSingular: '${pluralize(name, 1)}',`,
|
||||||
' fields: {',
|
' fields: {',
|
||||||
|
|
|
@ -3265,6 +3265,9 @@ importers:
|
||||||
pg-protocol:
|
pg-protocol:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.6.0
|
version: 1.6.0
|
||||||
|
pluralize:
|
||||||
|
specifier: ^8.0.0
|
||||||
|
version: 8.0.0
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.3
|
specifier: ^1.5.3
|
||||||
version: 1.5.3
|
version: 1.5.3
|
||||||
|
@ -3341,6 +3344,9 @@ importers:
|
||||||
'@types/oidc-provider':
|
'@types/oidc-provider':
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0
|
version: 8.0.0
|
||||||
|
'@types/pluralize':
|
||||||
|
specifier: ^0.0.31
|
||||||
|
version: 0.0.31
|
||||||
'@types/qrcode':
|
'@types/qrcode':
|
||||||
specifier: ^1.5.2
|
specifier: ^1.5.2
|
||||||
version: 1.5.2
|
version: 1.5.2
|
||||||
|
@ -17311,7 +17317,6 @@ packages:
|
||||||
/pluralize@8.0.0:
|
/pluralize@8.0.0:
|
||||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/pngjs@5.0.0:
|
/pngjs@5.0.0:
|
||||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
|
Loading…
Reference in a new issue