mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): organization email domains apis
This commit is contained in:
parent
d66d8c2286
commit
b85ecb6c93
6 changed files with 311 additions and 1 deletions
104
packages/core/src/queries/organization/email-domains.ts
Normal file
104
packages/core/src/queries/organization/email-domains.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import {
|
||||
type OrganizationEmailDomain,
|
||||
OrganizationEmailDomains,
|
||||
type CreateOrganizationEmailDomain,
|
||||
} from '@logto/schemas';
|
||||
import { type CommonQueryMethods, sql } from '@silverhand/slonik';
|
||||
|
||||
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
import { type GetEntitiesOptions } from '#src/utils/RelationQueries.js';
|
||||
import { type OmitAutoSetFields, conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(OrganizationEmailDomains);
|
||||
|
||||
export class EmailDomainQueries {
|
||||
readonly #insert: (
|
||||
data: OmitAutoSetFields<CreateOrganizationEmailDomain>
|
||||
) => Promise<Readonly<OrganizationEmailDomain>>;
|
||||
|
||||
constructor(protected pool: CommonQueryMethods) {
|
||||
this.#insert = buildInsertIntoWithPool(this.pool)(OrganizationEmailDomains, {
|
||||
returning: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getEntities(
|
||||
organizationId: string,
|
||||
options: GetEntitiesOptions
|
||||
): Promise<[number, readonly OrganizationEmailDomain[]]> {
|
||||
const { limit, offset } = options;
|
||||
const mainSql = sql`
|
||||
from ${table}
|
||||
where ${fields.organizationId} = ${organizationId}
|
||||
`;
|
||||
|
||||
const [{ count }, rows] = await Promise.all([
|
||||
this.pool.one<{ count: string }>(sql`
|
||||
select count(*)
|
||||
${mainSql}
|
||||
`),
|
||||
this.pool.any<OrganizationEmailDomain>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
${mainSql}
|
||||
${conditionalSql(limit, (limit) => sql`limit ${limit}`)}
|
||||
${conditionalSql(offset, (offset) => sql`offset ${offset}`)}
|
||||
`),
|
||||
]);
|
||||
|
||||
return [Number(count), rows];
|
||||
}
|
||||
|
||||
async insert(organizationId: string, emailDomain: string): Promise<OrganizationEmailDomain> {
|
||||
return this.#insert({
|
||||
organizationId,
|
||||
emailDomain,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(organizationId: string, emailDomain: string): Promise<void> {
|
||||
const { rowCount } = await this.pool.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.organizationId} = ${organizationId}
|
||||
and ${fields.emailDomain} = ${emailDomain}
|
||||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError(OrganizationEmailDomains.table);
|
||||
}
|
||||
}
|
||||
|
||||
async replace(organizationId: string, emailDomains: readonly string[]): Promise<void> {
|
||||
return this.pool.transaction(async (transaction) => {
|
||||
// Lock organization
|
||||
await transaction.query(sql`
|
||||
select ${fields.organizationId}
|
||||
from ${table}
|
||||
where ${fields.organizationId} = ${organizationId}
|
||||
for update
|
||||
`);
|
||||
|
||||
// Delete old email domains
|
||||
await transaction.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.organizationId} = ${organizationId}
|
||||
`);
|
||||
|
||||
// Insert new email domains
|
||||
if (emailDomains.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await transaction.query(sql`
|
||||
insert into ${table} (
|
||||
${fields.organizationId},
|
||||
${fields.emailDomain}
|
||||
)
|
||||
values ${sql.join(
|
||||
emailDomains.map((emailDomain) => sql`(${organizationId}, ${emailDomain})`),
|
||||
sql`, `
|
||||
)}
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -28,6 +28,7 @@ import { TwoRelationsQueries } from '#src/utils/RelationQueries.js';
|
|||
import SchemaQueries from '#src/utils/SchemaQueries.js';
|
||||
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
|
||||
|
||||
import { EmailDomainQueries } from './email-domains.js';
|
||||
import { RoleUserRelationQueries } from './role-user-relations.js';
|
||||
import { UserRelationQueries } from './user-relations.js';
|
||||
|
||||
|
@ -288,6 +289,9 @@ export default class OrganizationQueries extends SchemaQueries<
|
|||
),
|
||||
};
|
||||
|
||||
/** Queries for email domains that will be automatically provisioned. */
|
||||
emailDomains = new EmailDomainQueries(this.pool);
|
||||
|
||||
constructor(pool: CommonQueryMethods) {
|
||||
super(pool, Organizations);
|
||||
}
|
||||
|
|
85
packages/core/src/routes/organization/index.email-domains.ts
Normal file
85
packages/core/src/routes/organization/index.email-domains.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { OrganizationEmailDomains } from '@logto/schemas';
|
||||
import { type IRouterParamContext } from 'koa-router';
|
||||
import type Router from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
import type OrganizationQueries from '#src/queries/organization/index.js';
|
||||
|
||||
export default function emailDomainRoutes(
|
||||
router: Router<unknown, IRouterParamContext>,
|
||||
organizations: OrganizationQueries
|
||||
) {
|
||||
const params = Object.freeze({ id: z.string().min(1) });
|
||||
const pathname = '/:id/email-domains';
|
||||
|
||||
router.get(
|
||||
pathname,
|
||||
koaPagination(),
|
||||
koaGuard({
|
||||
params: z.object(params),
|
||||
response: OrganizationEmailDomains.guard.array(),
|
||||
status: [200],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
const { limit, offset } = ctx.pagination;
|
||||
|
||||
const [count, rows] = await organizations.emailDomains.getEntities(id, { limit, offset });
|
||||
ctx.pagination.totalCount = count;
|
||||
ctx.body = rows;
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
pathname,
|
||||
koaGuard({
|
||||
params: z.object(params),
|
||||
body: z.object({ emailDomain: z.string().min(1) }),
|
||||
response: OrganizationEmailDomains.guard,
|
||||
status: [201],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
const { emailDomain } = ctx.guard.body;
|
||||
|
||||
ctx.body = await organizations.emailDomains.insert(id, emailDomain);
|
||||
ctx.status = 201;
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.put(
|
||||
pathname,
|
||||
koaGuard({
|
||||
params: z.object(params),
|
||||
body: z.object({ emailDomains: z.string().min(1).array().nonempty() }),
|
||||
status: [204],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
const { emailDomains } = ctx.guard.body;
|
||||
|
||||
await organizations.emailDomains.replace(id, emailDomains);
|
||||
ctx.status = 204;
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
`${pathname}/:emailDomain`,
|
||||
koaGuard({
|
||||
params: z.object({ ...params, emailDomain: z.string().min(1) }),
|
||||
status: [204],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id, emailDomain } = ctx.guard.params;
|
||||
|
||||
await organizations.emailDomains.delete(id, emailDomain);
|
||||
ctx.status = 204;
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { yes } from '@silverhand/essentials';
|
||||
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 koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
|
||||
|
@ -16,6 +17,7 @@ import { parseSearchOptions } from '#src/utils/search.js';
|
|||
|
||||
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
|
||||
|
||||
import emailDomainRoutes from './index.email-domains.js';
|
||||
import userRoleRelationRoutes from './index.user-role-relations.js';
|
||||
import organizationInvitationRoutes from './invitations.js';
|
||||
import organizationRoleRoutes from './roles.js';
|
||||
|
@ -133,9 +135,12 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
|
|||
}
|
||||
);
|
||||
|
||||
// MARK: Organization - user - organization role relation routes
|
||||
userRoleRelationRoutes(router, organizations);
|
||||
|
||||
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||
emailDomainRoutes(router, organizations);
|
||||
}
|
||||
|
||||
// MARK: Mount sub-routes
|
||||
organizationRoleRoutes(...args);
|
||||
organizationScopeRoutes(...args);
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
type UserWithOrganizationRoles,
|
||||
type OrganizationWithFeatured,
|
||||
type OrganizationScope,
|
||||
type OrganizationEmailDomain,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
@ -74,4 +75,36 @@ export class OrganizationApi extends ApiFactory<
|
|||
.get(`${this.path}/${id}/users/${userId}/scopes`)
|
||||
.json<OrganizationScope[]>();
|
||||
}
|
||||
|
||||
async getEmailDomains(
|
||||
id: string,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<OrganizationEmailDomain[]> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (page) {
|
||||
searchParams.append('page', String(page));
|
||||
}
|
||||
|
||||
if (pageSize) {
|
||||
searchParams.append('page_size', String(pageSize));
|
||||
}
|
||||
|
||||
return authedAdminApi
|
||||
.get(`${this.path}/${id}/email-domains`, { searchParams })
|
||||
.json<OrganizationEmailDomain[]>();
|
||||
}
|
||||
|
||||
async addEmailDomain(id: string, emailDomain: string): Promise<void> {
|
||||
await authedAdminApi.post(`${this.path}/${id}/email-domains`, { json: { emailDomain } });
|
||||
}
|
||||
|
||||
async deleteEmailDomain(id: string, emailDomain: string): Promise<void> {
|
||||
await authedAdminApi.delete(`${this.path}/${id}/email-domains/${emailDomain}`);
|
||||
}
|
||||
|
||||
async replaceEmailDomains(id: string, emailDomains: string[]): Promise<void> {
|
||||
await authedAdminApi.put(`${this.path}/${id}/email-domains`, { json: { emailDomains } });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import { generateStandardId } from '@logto/shared';
|
||||
|
||||
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||
|
||||
const randomId = () => generateStandardId(6);
|
||||
|
||||
describe('organization email domains', () => {
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
|
||||
afterEach(async () => {
|
||||
await organizationApi.cleanUp();
|
||||
});
|
||||
|
||||
it('should add and delete email domains', async () => {
|
||||
const organization = await organizationApi.create({ name: 'foo' });
|
||||
const emailDomain = `${randomId()}.com`;
|
||||
|
||||
await organizationApi.addEmailDomain(organization.id, emailDomain);
|
||||
await expect(organizationApi.getEmailDomains(organization.id)).resolves.toMatchObject([
|
||||
{ emailDomain },
|
||||
]);
|
||||
|
||||
await organizationApi.deleteEmailDomain(organization.id, emailDomain);
|
||||
await expect(organizationApi.getEmailDomains(organization.id)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should have default pagination', async () => {
|
||||
const organization = await organizationApi.create({ name: 'foo' });
|
||||
|
||||
const emailDomains = Array.from({ length: 30 }, () => `${randomId()}.com`);
|
||||
|
||||
await organizationApi.replaceEmailDomains(organization.id, emailDomains);
|
||||
|
||||
const emailDomainsPage1 = await organizationApi.getEmailDomains(organization.id);
|
||||
const emailDomainsPage2 = await organizationApi.getEmailDomains(organization.id, 2);
|
||||
|
||||
expect(emailDomainsPage1).toHaveLength(20);
|
||||
expect(emailDomainsPage2).toHaveLength(10);
|
||||
expect(emailDomainsPage1).toMatchObject(
|
||||
emailDomains.slice(0, 20).map((emailDomain) => expect.objectContaining({ emailDomain }))
|
||||
);
|
||||
expect(emailDomainsPage2).toEqual(
|
||||
emailDomains.slice(20).map((emailDomain) => expect.objectContaining({ emailDomain }))
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 when deleting a non-existent email domain', async () => {
|
||||
const organization = await organizationApi.create({ name: 'foo' });
|
||||
const emailDomain = `${randomId()}.com`;
|
||||
|
||||
await expect(
|
||||
organizationApi.deleteEmailDomain(organization.id, emailDomain)
|
||||
).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 404 Not Found]');
|
||||
});
|
||||
|
||||
it('should return 400 when adding an email domain that already exists', async () => {
|
||||
const organization = await organizationApi.create({ name: 'foo' });
|
||||
const emailDomain = `${randomId()}.com`;
|
||||
|
||||
await organizationApi.addEmailDomain(organization.id, emailDomain);
|
||||
await expect(
|
||||
organizationApi.addEmailDomain(organization.id, emailDomain)
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
'[HTTPError: Request failed with status code 422 Unprocessable Entity]'
|
||||
);
|
||||
});
|
||||
|
||||
it('should be able to replace email domains', async () => {
|
||||
const organization = await organizationApi.create({ name: 'foo' });
|
||||
await organizationApi.addEmailDomain(organization.id, `${randomId()}.com`);
|
||||
|
||||
const emailDomains = [`${randomId()}.com`, `${randomId()}.com`];
|
||||
|
||||
await organizationApi.replaceEmailDomains(organization.id, emailDomains);
|
||||
await expect(organizationApi.getEmailDomains(organization.id)).resolves.toMatchObject(
|
||||
emailDomains.map((emailDomain) => expect.objectContaining({ emailDomain }))
|
||||
);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue