0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

Merge pull request #5335 from logto-io/gao-remove-magic-links

refactor: remove magic links
This commit is contained in:
Gao Sun 2024-01-30 15:29:21 +08:00 committed by GitHub
commit 70b8ff707e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 134 additions and 250 deletions

View file

@ -1,11 +1,5 @@
import { type GeneratedSchema } from '@logto/schemas';
import {
type SchemaLike,
conditionalSql,
convertToIdentifiers,
type Table,
type FieldIdentifiers,
} from '@logto/shared';
import { type SchemaLike, conditionalSql, convertToIdentifiers, type Table } from '@logto/shared';
import { type SqlSqlToken, sql } from 'slonik';
/**
@ -62,48 +56,3 @@ export const expandFields = <Keys extends string>(schema: Table<Keys>, tablePref
const { fields } = convertToIdentifiers(schema, tablePrefix);
return sql.join(Object.values(fields), sql`, `);
};
/**
* Given a set of identifiers, build a SQL that converts them into a JSON object by mapping
* the keys to the values.
*
* @example
* ```ts
* buildJsonObjectSql({
* id: sql.identifier(['id']),
* firstName: sql.identifier(['first_name']),
* lastName: sql.identifier(['last_name']),
* createdAt: sql.identifier(['created_at']),
* );
* ```
*
* will generate
*
* ```sql
* json_build_object(
* 'id', "id",
* 'firstName', "first_name",
* 'lastName', "last_name",
* 'createdAt', trunc(extract(epoch from "created_at") * 1000)
* )
* ```
*
* @remarks The values will be converted to epoch milliseconds if the key ends with `At` since
* slonik has a default parser that converts timestamps to epoch milliseconds, but it does not
* work for JSON objects.
*/
export const buildJsonObjectSql = <Identifiers extends FieldIdentifiers<string>>(
identifiers: Identifiers
) => sql`
json_build_object(
${sql.join(
Object.entries(identifiers).map(
([key, value]) =>
sql`${sql.literalValue(key)}, ${
key.endsWith('At') ? sql`trunc(extract(epoch from ${value}) * 1000)` : value
}`
),
sql`, `
)}
)
`;

View file

@ -1,16 +1,13 @@
import { ConnectorType, TemplateType } from '@logto/connector-kit';
import { ConnectorType, type SendMessagePayload, TemplateType } from '@logto/connector-kit';
import {
OrganizationInvitationStatus,
type CreateOrganizationInvitation,
type OrganizationInvitationEntity,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { appendPath, removeUndefinedKeys } from '@silverhand/essentials';
import { removeUndefinedKeys } from '@silverhand/essentials';
import { EnvSet } from '#src/env-set/index.js';
import { getTenantEndpoint } from '#src/env-set/utils.js';
import RequestError from '#src/errors/RequestError/index.js';
import MagicLinkQueries from '#src/queries/magic-link.js';
import OrganizationQueries from '#src/queries/organization/index.js';
import { createUserQueries } from '#src/queries/user.js';
import type Queries from '#src/tenants/Queries.js';
@ -49,31 +46,25 @@ export class OrganizationInvitationLibrary {
* @param data.organizationId The ID of the organization to invite to.
* @param data.expiresAt The epoch time in milliseconds when the invitation expires.
* @param data.organizationRoleIds The IDs of the organization roles to assign to the invitee.
* @param skipEmail Whether to skip sending the invitation email. Defaults to `false`.
* @param messagePayload The payload to send in the email. If it is `false`, the email will be
* skipped.
*/
async insert(
data: Pick<
CreateOrganizationInvitation,
'inviterId' | 'invitee' | 'organizationId' | 'expiresAt'
> & { organizationRoleIds?: string[] },
skipEmail = false
messagePayload: SendMessagePayload | false
) {
const { inviterId, invitee, organizationId, expiresAt, organizationRoleIds } = data;
return this.queries.pool.transaction(async (connection) => {
const organizationQueries = new OrganizationQueries(connection);
const magicLinkQueries = new MagicLinkQueries(connection);
const magicLink = await magicLinkQueries.insert({
id: generateStandardId(),
token: generateStandardId(32),
});
const invitation = await organizationQueries.invitations.insert({
id: generateStandardId(),
inviterId,
invitee,
organizationId,
magicLinkId: magicLink.id,
status: OrganizationInvitationStatus.Pending,
expiresAt,
});
@ -84,8 +75,8 @@ export class OrganizationInvitationLibrary {
);
}
if (!skipEmail) {
await this.sendEmail(invitee, magicLink.token);
if (messagePayload) {
await this.sendEmail(invitee, messagePayload);
}
// Additional query to get the full invitation data
@ -195,15 +186,12 @@ export class OrganizationInvitationLibrary {
});
}
protected async sendEmail(to: string, token: string) {
protected async sendEmail(to: string, payload: SendMessagePayload) {
const emailConnector = await this.connector.getMessageConnector(ConnectorType.Email);
return emailConnector.sendMessage({
to,
type: TemplateType.OrganizationInvitation,
payload: {
link: appendPath(getTenantEndpoint(this.tenantId, EnvSet.values), invitationLinkPath, token)
.href,
},
payload,
});
}
}

View file

@ -1,19 +0,0 @@
import {
type CreateMagicLink,
type MagicLink,
type MagicLinkKeys,
MagicLinks,
} from '@logto/schemas';
import { type CommonQueryMethods } from 'slonik';
import SchemaQueries from '#src/utils/SchemaQueries.js';
export default class MagicLinkQueries extends SchemaQueries<
MagicLinkKeys,
CreateMagicLink,
MagicLink
> {
constructor(pool: CommonQueryMethods) {
super(pool, MagicLinks);
}
}

View file

@ -15,14 +15,13 @@ import {
type CreateOrganizationInvitation,
type OrganizationInvitation,
type OrganizationInvitationEntity,
MagicLinks,
OrganizationInvitationRoleRelations,
OrganizationInvitationStatus,
} from '@logto/schemas';
import { conditionalSql, convertToIdentifiers } from '@logto/shared';
import { sql, type CommonQueryMethods } from 'slonik';
import { type SearchOptions, buildSearchSql, buildJsonObjectSql } from '#src/database/utils.js';
import { type SearchOptions, buildSearchSql } from '#src/database/utils.js';
import { TwoRelationsQueries } from '#src/utils/RelationQueries.js';
import SchemaQueries from '#src/utils/SchemaQueries.js';
@ -116,7 +115,6 @@ class OrganizationInvitationsQueries extends SchemaQueries<
search?: SearchOptions<OrganizationInvitationKeys>
) {
const { table, fields } = convertToIdentifiers(OrganizationInvitations, true);
const magicLinks = convertToIdentifiers(MagicLinks, true);
const roleRelations = convertToIdentifiers(OrganizationInvitationRoleRelations, true);
const roles = convertToIdentifiers(OrganizationRoles, true);
@ -143,20 +141,17 @@ class OrganizationInvitationsQueries extends SchemaQueries<
) order by ${roles.fields.name}
) filter (where ${roles.fields.id} is not null),
'[]'
) as "organizationRoles", -- left join could produce nulls
${buildJsonObjectSql(magicLinks.fields)} as "magicLink"
) as "organizationRoles" -- left join could produce nulls
from ${table}
left join ${roleRelations.table}
on ${roleRelations.fields.organizationInvitationId} = ${fields.id}
left join ${roles.table}
on ${roles.fields.id} = ${roleRelations.fields.organizationRoleId}
left join ${magicLinks.table}
on ${magicLinks.fields.id} = ${fields.magicLinkId}
${conditionalSql(invitationId, (id) => {
return sql`where ${fields.id} = ${id}`;
})}
${buildSearchSql(OrganizationInvitations, search)}
group by ${fields.id}, ${magicLinks.fields.id}
group by ${fields.id}
${conditionalSql(this.orderBy, ({ field, order }) => {
return sql`order by ${fields[field]} ${order === 'desc' ? sql`desc` : sql`asc`}`;
})}

View file

@ -13,24 +13,13 @@
"parameters": [],
"responses": {
"200": {
"description": "A list of organization invitations, each item also contains the organization roles to be assigned to the user when they accept the invitation, and the corresponding magic link data."
"description": "A list of organization invitations, each item also contains the organization roles to be assigned to the user when they accept the invitation."
}
}
},
"post": {
"summary": "Create organization invitation",
"description": "Create an organization invitation and optionally send it via email. The tenant should have an email connector configured if you want to send the invitation via email at this point.",
"parameters": [
{
"in": "query",
"name": "skipEmail",
"description": "If true, the invitation will not be sent via email; otherwise, the invitation will be sent via email when it is created. If the email is failed to send, the invitation will not be created.",
"required": false,
"schema": {
"default": false
}
}
],
"requestBody": {
"description": "The organization invitation to create.",
"required": true,
@ -52,6 +41,9 @@
},
"organizationRoleIds": {
"description": "The IDs of the organization roles to assign to the user when they accept the invitation."
},
"messagePayload": {
"description": "The message payload for the \"OrganizationInvitation\" template to use when sending the invitation via email. If it is `false`, the invitation will not be sent via email."
}
}
}
@ -60,7 +52,7 @@
},
"responses": {
"201": {
"description": "The organization invitation was created successfully, and the corresponding magic link data."
"description": "The organization invitation was created successfully."
},
"501": {
"description": "No email connector is configured for the tenant."
@ -74,7 +66,7 @@
"description": "Get an organization invitation by ID.",
"responses": {
"200": {
"description": "The organization invitation, also contains the organization roles to be assigned to the user when they accept the invitation, and the corresponding magic link data."
"description": "The organization invitation, also contains the organization roles to be assigned to the user when they accept the invitation."
}
}
},

View file

@ -1,9 +1,9 @@
import { sendMessagePayloadGuard } from '@logto/connector-kit';
import {
OrganizationInvitationStatus,
OrganizationInvitations,
organizationInvitationEntityGuard,
} from '@logto/schemas';
import { yes } from '@silverhand/essentials';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -38,9 +38,6 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
router.post(
'/',
koaGuard({
query: z.object({
skipEmail: z.string().optional(),
}),
body: OrganizationInvitations.createGuard
.pick({
inviterId: true,
@ -51,12 +48,15 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
.extend({
invitee: z.string().email(),
organizationRoleIds: z.string().array().optional(),
messagePayload: sendMessagePayloadGuard.or(z.literal(false)).default(false),
}),
response: organizationInvitationEntityGuard,
status: [201],
}),
async (ctx, next) => {
const { query, body } = ctx.guard;
const {
body: { messagePayload, ...body },
} = ctx.guard;
assertThat(
body.expiresAt > Date.now(),
@ -66,7 +66,7 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
})
);
ctx.body = await organizationInvitations.insert(body, yes(query.skipEmail));
ctx.body = await organizationInvitations.insert(body, messagePayload);
ctx.status = 201;
return next();
}

View file

@ -1,3 +1,4 @@
import { type SendMessagePayload } from '@logto/connector-kit';
import {
type OrganizationInvitationStatus,
type OrganizationInvitationEntity,
@ -12,6 +13,7 @@ export type PostOrganizationInvitationData = {
organizationId: string;
expiresAt: number;
organizationRoleIds?: string[];
emailPayload?: SendMessagePayload | false;
};
export class OrganizationInvitationApi extends ApiFactory<
@ -22,15 +24,8 @@ export class OrganizationInvitationApi extends ApiFactory<
super('organization-invitations');
}
override async create(data: PostOrganizationInvitationData, skipEmail = false) {
return authedAdminApi
.post(this.path, {
searchParams: {
skipEmail: skipEmail.toString(),
},
json: data,
})
.json<OrganizationInvitationEntity>();
override async create(json: PostOrganizationInvitationData) {
return authedAdminApi.post(this.path, { json }).json<OrganizationInvitationEntity>();
}
async updateStatus(id: string, status: OrganizationInvitationStatus, acceptedUserId?: string) {

View file

@ -27,10 +27,9 @@ export class OrganizationInvitationApiTest extends OrganizationInvitationApi {
}
override async create(
data: PostOrganizationInvitationData,
skipEmail = false
data: PostOrganizationInvitationData
): Promise<OrganizationInvitationEntity> {
const created = await super.create(data, skipEmail);
const created = await super.create(data);
this.invitations.push(created);
return created;
}

View file

@ -29,27 +29,21 @@ describe('organization invitation creation', () => {
it('should be able to create an invitation without sending email', async () => {
const organization = await organizationApi.create({ name: 'test' });
await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
});
it('should not be able to create invitations with the same email', async () => {
const organization = await organizationApi.create({ name: 'test' });
const email = `${randomId()}@example.com`;
await invitationApi.create(
{
organizationId: organization.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
},
true
);
await invitationApi.create({
organizationId: organization.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
});
const error = await invitationApi
.create({
organizationId: organization.id,
@ -68,22 +62,16 @@ describe('organization invitation creation', () => {
]);
const email = `${randomId()}@example.com`;
await Promise.all([
invitationApi.create(
{
organizationId: organization1.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
},
true
),
invitationApi.create(
{
organizationId: organization2.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
},
true
),
invitationApi.create({
organizationId: organization1.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
}),
invitationApi.create({
organizationId: organization2.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
}),
]);
});
@ -128,17 +116,13 @@ describe('organization invitation creation', () => {
it('should be able to create invitations with organization role ids', async () => {
const organization = await organizationApi.create({ name: 'test' });
const role = await organizationApi.roleApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
organizationRoleIds: [role.id],
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
organizationRoleIds: [role.id],
});
expect(invitation.organizationRoles.map((role) => role.id)).toEqual([role.id]);
expect(invitation.magicLink).toBeDefined();
// Test if get invitation by id works
const invitationById = await invitationApi.get(invitation.id);

View file

@ -33,14 +33,11 @@ describe('organization invitation status update', () => {
it('should expire invitations and disable update after the expiration date', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 100,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 100,
});
expect(invitation.status).toBe('Pending');
await new Promise((resolve) => {
@ -58,14 +55,11 @@ describe('organization invitation status update', () => {
it('should be able to accept an invitation', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');
const user = await userApi.create({
@ -91,15 +85,12 @@ describe('organization invitation status update', () => {
it('should be able to accept an invitation with roles', async () => {
const organization = await organizationApi.create({ name: 'test' });
const role = await organizationApi.roleApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
organizationRoleIds: [role.id],
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
organizationRoleIds: [role.id],
});
expect(invitation.status).toBe('Pending');
const user = await userApi.create({
@ -125,14 +116,11 @@ describe('organization invitation status update', () => {
it('should not be able to accept an invitation with a different email', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');
const user = await userApi.create({
@ -147,14 +135,11 @@ describe('organization invitation status update', () => {
it('should not be able to accept an invitation with an invalid user id', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');
const error = await invitationApi
@ -166,14 +151,11 @@ describe('organization invitation status update', () => {
it('should not be able to update the status of an ended invitation', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');
await invitationApi.updateStatus(invitation.id, OrganizationInvitationStatus.Revoked);

View file

@ -0,0 +1,46 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
import { applyTableRls, dropTableRls } from './utils/1704934999-tables.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter table organization_invitations
drop column magic_link_id;
`);
await dropTableRls(pool, 'magic_links');
await pool.query(sql`
drop table magic_links;
`);
},
down: async (pool) => {
await pool.query(sql`
create table magic_links (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
/** The unique identifier of the link. */
id varchar(21) not null,
/** The token that can be used to verify the link. */
token varchar(32) not null,
/** The time when the link was created. */
created_at timestamptz not null default (now()),
/** The time when the link was consumed. */
consumed_at timestamptz,
primary key (id)
);
create index magic_links__token
on magic_links (tenant_id, token);
`);
await applyTableRls(pool, 'magic_links');
await pool.query(sql`
alter table organization_invitations
add column magic_link_id varchar(21)
references magic_links (id) on update cascade on delete cascade;
`);
},
};
export default alteration;

View file

@ -6,9 +6,7 @@ import {
type Organization,
Organizations,
type OrganizationInvitation,
type MagicLink,
OrganizationInvitations,
MagicLinks,
} from '../db-entries/index.js';
import { type UserInfo, type FeaturedUser, userInfoGuard } from './user.js';
@ -89,16 +87,13 @@ export type OrganizationWithFeatured = Organization & {
/**
* The organization invitation with additional fields:
*
* - `magicLink`: The magic link that can be used to accept the invitation.
* - `organizationRoles`: The roles to be assigned to the user when accepting the invitation.
*/
export type OrganizationInvitationEntity = OrganizationInvitation & {
magicLink?: MagicLink;
organizationRoles: OrganizationRoleEntity[];
};
export const organizationInvitationEntityGuard: z.ZodType<OrganizationInvitationEntity> =
OrganizationInvitations.guard.extend({
magicLink: MagicLinks.guard.optional(),
organizationRoles: organizationRoleEntityGuard.array(),
});

View file

@ -1,19 +0,0 @@
/* init_order = 1 */
/** Link that can be used to perform certain actions by verifying the token. The expiration time of the link should be determined by the action it performs, thus there is no `expires_at` column in this table. */
create table magic_links (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
/** The unique identifier of the link. */
id varchar(21) not null,
/** The token that can be used to verify the link. */
token varchar(32) not null,
/** The time when the link was created. */
created_at timestamptz not null default (now()),
/** The time when the link was consumed. */
consumed_at timestamptz,
primary key (id)
);
create index magic_links__token
on magic_links (tenant_id, token);

View file

@ -21,9 +21,6 @@ create table organization_invitations (
references organizations (id) on update cascade on delete cascade,
/** The status of the invitation. */
status organization_invitation_status not null,
/** The ID of the magic link that can be used to accept the invitation. */
magic_link_id varchar(21)
references magic_links (id) on update cascade on delete cascade,
/** The time when the invitation was created. */
created_at timestamptz not null default (now()),
/** The time when the invitation status was last updated. */