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 SchemaQueries from '#src/utils/SchemaQueries.js';
|
||||||
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
|
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
|
||||||
|
|
||||||
|
import { EmailDomainQueries } from './email-domains.js';
|
||||||
import { RoleUserRelationQueries } from './role-user-relations.js';
|
import { RoleUserRelationQueries } from './role-user-relations.js';
|
||||||
import { UserRelationQueries } from './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) {
|
constructor(pool: CommonQueryMethods) {
|
||||||
super(pool, Organizations);
|
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 { yes } from '@silverhand/essentials';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||||
import koaQuotaGuard from '#src/middleware/koa-quota-guard.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 { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
import emailDomainRoutes from './index.email-domains.js';
|
||||||
import userRoleRelationRoutes from './index.user-role-relations.js';
|
import userRoleRelationRoutes from './index.user-role-relations.js';
|
||||||
import organizationInvitationRoutes from './invitations.js';
|
import organizationInvitationRoutes from './invitations.js';
|
||||||
import organizationRoleRoutes from './roles.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);
|
userRoleRelationRoutes(router, organizations);
|
||||||
|
|
||||||
|
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||||
|
emailDomainRoutes(router, organizations);
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Mount sub-routes
|
// MARK: Mount sub-routes
|
||||||
organizationRoleRoutes(...args);
|
organizationRoleRoutes(...args);
|
||||||
organizationScopeRoutes(...args);
|
organizationScopeRoutes(...args);
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
type UserWithOrganizationRoles,
|
type UserWithOrganizationRoles,
|
||||||
type OrganizationWithFeatured,
|
type OrganizationWithFeatured,
|
||||||
type OrganizationScope,
|
type OrganizationScope,
|
||||||
|
type OrganizationEmailDomain,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
|
||||||
import { authedAdminApi } from './api.js';
|
import { authedAdminApi } from './api.js';
|
||||||
|
@ -74,4 +75,36 @@ export class OrganizationApi extends ApiFactory<
|
||||||
.get(`${this.path}/${id}/users/${userId}/scopes`)
|
.get(`${this.path}/${id}/users/${userId}/scopes`)
|
||||||
.json<OrganizationScope[]>();
|
.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