mirror of
https://github.com/logto-io/logto.git
synced 2025-04-14 23:11:31 -05:00
Merge pull request #4640 from logto-io/gao-org-apis-2
feat(core): add organization scope APIs
This commit is contained in:
commit
f0e6cf3199
22 changed files with 262 additions and 0 deletions
packages
core/src
queries
routes
tenants
integration-tests/src
phrases/src/locales
20
packages/core/src/queries/organization-scopes.ts
Normal file
20
packages/core/src/queries/organization-scopes.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
type OrganizationScopeKeys,
|
||||
OrganizationScopes,
|
||||
type CreateOrganizationScope,
|
||||
type OrganizationScope,
|
||||
} from '@logto/schemas';
|
||||
import { type CommonQueryMethods } from 'slonik';
|
||||
|
||||
import SchemaQueries from '#src/utils/SchemaQueries.js';
|
||||
|
||||
/** Class of queries for scopes in the organization template. */
|
||||
export default class OrganizationScopeQueries extends SchemaQueries<
|
||||
OrganizationScopeKeys,
|
||||
CreateOrganizationScope,
|
||||
OrganizationScope
|
||||
> {
|
||||
constructor(pool: CommonQueryMethods) {
|
||||
super(pool, OrganizationScopes);
|
||||
}
|
||||
}
|
|
@ -25,6 +25,7 @@ import hookRoutes from './hook.js';
|
|||
import interactionRoutes from './interaction/index.js';
|
||||
import logRoutes from './log.js';
|
||||
import logtoConfigRoutes from './logto-config.js';
|
||||
import organizationScopeRoutes from './organization-scopes.js';
|
||||
import organizationRoutes from './organizations.js';
|
||||
import resourceRoutes from './resource.js';
|
||||
import roleRoutes from './role.js';
|
||||
|
@ -65,6 +66,7 @@ const createRouters = (tenant: TenantContext) => {
|
|||
userAssetsRoutes(managementRouter, tenant);
|
||||
domainRoutes(managementRouter, tenant);
|
||||
organizationRoutes(managementRouter, tenant);
|
||||
organizationScopeRoutes(managementRouter, tenant);
|
||||
|
||||
const anonymousRouter: AnonymousRouter = new Router();
|
||||
wellKnownRoutes(anonymousRouter, tenant);
|
||||
|
|
23
packages/core/src/routes/organization-scopes.test.ts
Normal file
23
packages/core/src/routes/organization-scopes.test.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { UniqueIntegrityConstraintViolationError } from 'slonik';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
||||
import { OrganizationScopeActions } from './organization-scopes.js';
|
||||
|
||||
describe('OrganizationScopeActions', () => {
|
||||
it('should throw RequestError if UniqueIntegrityConstraintViolationError is thrown inside', async () => {
|
||||
const tenantContext = new MockTenant(undefined, {
|
||||
organizationScopes: {
|
||||
insert: async () => {
|
||||
throw new UniqueIntegrityConstraintViolationError(new Error('test'), 'unique');
|
||||
},
|
||||
},
|
||||
});
|
||||
const actions = new OrganizationScopeActions(tenantContext.queries.organizationScopes);
|
||||
|
||||
await expect(actions.post({ name: 'test' })).rejects.toThrowError(
|
||||
new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' })
|
||||
);
|
||||
});
|
||||
});
|
48
packages/core/src/routes/organization-scopes.ts
Normal file
48
packages/core/src/routes/organization-scopes.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
type CreateOrganizationScope,
|
||||
type OrganizationScope,
|
||||
type OrganizationScopeKeys,
|
||||
OrganizationScopes,
|
||||
} from '@logto/schemas';
|
||||
import { UniqueIntegrityConstraintViolationError } from 'slonik';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import SchemaRouter, { SchemaActions } from '#src/utils/SchemaRouter.js';
|
||||
|
||||
import { type AuthedRouter, type RouterInitArgs } from './types.js';
|
||||
|
||||
export class OrganizationScopeActions extends SchemaActions<
|
||||
OrganizationScopeKeys,
|
||||
CreateOrganizationScope,
|
||||
OrganizationScope
|
||||
> {
|
||||
override async post(
|
||||
data: Omit<CreateOrganizationScope, 'id'>
|
||||
): Promise<Readonly<OrganizationScope>> {
|
||||
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 organizationScopeRoutes<T extends AuthedRouter>(
|
||||
...[
|
||||
originalRouter,
|
||||
{
|
||||
queries: { organizationScopes },
|
||||
},
|
||||
]: RouterInitArgs<T>
|
||||
) {
|
||||
const router = new SchemaRouter(
|
||||
OrganizationScopes,
|
||||
new OrganizationScopeActions(organizationScopes)
|
||||
);
|
||||
|
||||
originalRouter.use(router.routes());
|
||||
}
|
|
@ -11,6 +11,7 @@ import { createHooksQueries } from '#src/queries/hooks.js';
|
|||
import { createLogQueries } from '#src/queries/log.js';
|
||||
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
||||
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js';
|
||||
import OrganizationScopeQueries from '#src/queries/organization-scopes.js';
|
||||
import OrganizationQueries from '#src/queries/organizations.js';
|
||||
import { createPasscodeQueries } from '#src/queries/passcode.js';
|
||||
import { createResourceQueries } from '#src/queries/resource.js';
|
||||
|
@ -43,6 +44,8 @@ export default class Queries {
|
|||
domains = createDomainsQueries(this.pool);
|
||||
dailyActiveUsers = createDailyActiveUsersQueries(this.pool);
|
||||
organizations = new OrganizationQueries(this.pool);
|
||||
/** Organization template scope queries. */
|
||||
organizationScopes = new OrganizationScopeQueries(this.pool);
|
||||
|
||||
constructor(
|
||||
public readonly pool: CommonQueryMethods,
|
||||
|
|
39
packages/integration-tests/src/api/organization-scope.ts
Normal file
39
packages/integration-tests/src/api/organization-scope.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { type OrganizationScope } from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
||||
export const createOrganizationScope = async (name: string, description?: string) => {
|
||||
return authedAdminApi
|
||||
.post('organization-scopes', {
|
||||
json: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
})
|
||||
.json<OrganizationScope>();
|
||||
};
|
||||
|
||||
export const getOrganizationScopes = async (params?: URLSearchParams) => {
|
||||
return authedAdminApi
|
||||
.get('organization-scopes?' + (params?.toString() ?? ''))
|
||||
.json<OrganizationScope[]>();
|
||||
};
|
||||
|
||||
export const getOrganizationScope = async (id: string) => {
|
||||
return authedAdminApi.get('organization-scopes/' + id).json<OrganizationScope>();
|
||||
};
|
||||
|
||||
export const updateOrganizationScope = async (id: string, name: string, description?: string) => {
|
||||
return authedAdminApi
|
||||
.patch('organization-scopes/' + id, {
|
||||
json: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
})
|
||||
.json<OrganizationScope>();
|
||||
};
|
||||
|
||||
export const deleteOrganizationScope = async (id: string) => {
|
||||
return authedAdminApi.delete('organization-scopes/' + id);
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
import assert from 'node:assert';
|
||||
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { isKeyInObject } from '@silverhand/essentials';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
import {
|
||||
createOrganizationScope,
|
||||
getOrganizationScopes,
|
||||
getOrganizationScope,
|
||||
updateOrganizationScope,
|
||||
deleteOrganizationScope,
|
||||
} from '#src/api/organization-scope.js';
|
||||
|
||||
const randomId = () => generateStandardId(4);
|
||||
|
||||
describe('organization scopes', () => {
|
||||
it('should fail if the name of the new organization scope already exists', async () => {
|
||||
const name = 'test' + randomId();
|
||||
await createOrganizationScope(name);
|
||||
const response = await createOrganizationScope(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 get organization scopes successfully', async () => {
|
||||
const [name1, name2] = ['test' + randomId(), 'test' + randomId()];
|
||||
await createOrganizationScope(name1, 'A test organization scope.');
|
||||
await createOrganizationScope(name2);
|
||||
const scopes = await getOrganizationScopes();
|
||||
|
||||
expect(scopes).toContainEqual(
|
||||
expect.objectContaining({ name: name1, description: 'A test organization scope.' })
|
||||
);
|
||||
expect(scopes).toContainEqual(expect.objectContaining({ name: name2, description: null }));
|
||||
});
|
||||
|
||||
it('should get organization scopes with pagination', async () => {
|
||||
// Add 20 scopes to exceed the default page size
|
||||
await Promise.all(
|
||||
Array.from({ length: 30 }).map(async () => createOrganizationScope('test' + randomId()))
|
||||
);
|
||||
|
||||
const scopes = await getOrganizationScopes();
|
||||
expect(scopes).toHaveLength(20);
|
||||
|
||||
const scopes2 = await getOrganizationScopes(
|
||||
new URLSearchParams({
|
||||
page: '2',
|
||||
page_size: '10',
|
||||
})
|
||||
);
|
||||
expect(scopes2.length).toBeGreaterThanOrEqual(10);
|
||||
expect(scopes2[0]?.id).not.toBeFalsy();
|
||||
expect(scopes2[0]?.id).toBe(scopes[10]?.id);
|
||||
});
|
||||
|
||||
it('should be able to create and get organization scopes by id', async () => {
|
||||
const createdScope = await createOrganizationScope('test' + randomId());
|
||||
const scope = await getOrganizationScope(createdScope.id);
|
||||
|
||||
expect(scope).toStrictEqual(createdScope);
|
||||
});
|
||||
|
||||
it('should fail when try to get an organization scope that does not exist', async () => {
|
||||
const response = await getOrganizationScope('0').catch((error: unknown) => error);
|
||||
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should be able to update organization scope', async () => {
|
||||
const createdScope = await createOrganizationScope('test' + randomId());
|
||||
const newName = 'test' + randomId();
|
||||
const scope = await updateOrganizationScope(createdScope.id, newName, 'test description.');
|
||||
expect(scope).toStrictEqual({
|
||||
...createdScope,
|
||||
name: newName,
|
||||
description: 'test description.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to delete organization scope', async () => {
|
||||
const createdScope = await createOrganizationScope('test' + randomId());
|
||||
await deleteOrganizationScope(createdScope.id);
|
||||
const response = await getOrganizationScope(createdScope.id).catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should fail when try to delete an organization scope that does not exist', async () => {
|
||||
const response = await deleteOrganizationScope('0').catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}} existiert nicht.',
|
||||
not_exists_with_id: '{{name}} mit ID `{{id}}` existiert nicht.',
|
||||
not_found: 'Die Ressource wurde nicht gefunden.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,7 @@ const entity = {
|
|||
not_exists: 'The {{name}} does not exist.',
|
||||
not_exists_with_id: 'The {{name}} with ID `{{id}}` does not exist.',
|
||||
not_found: 'The resource does not exist.',
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: 'El {{name}} no existe.',
|
||||
not_exists_with_id: 'El {{name}} con ID `{{id}}` no existe.',
|
||||
not_found: 'El recurso no existe.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: "Le {{name}} n'existe pas.",
|
||||
not_exists_with_id: "Le {{name}} avec l'ID `{{id}}` n'existe pas.",
|
||||
not_found: "La ressource n'existe pas.",
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}} non esiste.',
|
||||
not_exists_with_id: '{{name}} con ID `{{id}}` non esiste.',
|
||||
not_found: 'La risorsa non esiste.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}}は存在しません。',
|
||||
not_exists_with_id: 'IDが`{{id}}`の{{name}}は存在しません。',
|
||||
not_found: 'リソースが存在しません。',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}}는 존재하지 않아요.',
|
||||
not_exists_with_id: '{{id}} ID를 가진 {{name}}는 존재하지 않아요.',
|
||||
not_found: '리소스가 존재하지 않아요.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}} nie istnieje.',
|
||||
not_exists_with_id: '{{name}} o identyfikatorze `{{id}}` nie istnieje.',
|
||||
not_found: 'Zasób nie istnieje.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: 'O {{name}} não existe.',
|
||||
not_exists_with_id: 'O {{name}} com ID `{{id}}` não existe.',
|
||||
not_found: 'O recurso não existe.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}} não existe.',
|
||||
not_exists_with_id: '{{name}} com o ID `{{id}}` não existe.',
|
||||
not_found: 'O recurso não existe.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}} не существует.',
|
||||
not_exists_with_id: '{{name}} с ID `{{id}}` не существует.',
|
||||
not_found: 'Ресурс не существует.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}} mevcut değil.',
|
||||
not_exists_with_id: ' `{{id}}` id kimliğine sahip {{name}} mevcut değil.',
|
||||
not_found: 'Kaynak mevcut değil.',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '该 {{name}} 不存在。',
|
||||
not_exists_with_id: 'ID 为 `{{id}}` 的 {{name}} 不存在。',
|
||||
not_found: '该资源不存在。',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '該 {{name}} 不存在。',
|
||||
not_exists_with_id: 'ID 為 `{{id}}` 的 {{name}} 不存在。',
|
||||
not_found: '該資源不存在。',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
|
@ -5,6 +5,8 @@ const entity = {
|
|||
not_exists: '{{name}} 不存在。',
|
||||
not_exists_with_id: 'ID 為 `{{id}}` 的 {{name}} 不存在。',
|
||||
not_found: '資源不存在。',
|
||||
/** UNTRANSLATED */
|
||||
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
|
||||
};
|
||||
|
||||
export default Object.freeze(entity);
|
||||
|
|
Loading…
Add table
Reference in a new issue