mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
refactor: remove magic links
This commit is contained in:
parent
0a20b5a6f8
commit
6d0f95739c
9 changed files with 54 additions and 122 deletions
|
@ -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`, `
|
||||
)}
|
||||
)
|
||||
`;
|
||||
|
|
|
@ -5,12 +5,9 @@ import {
|
|||
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';
|
||||
|
@ -62,18 +59,11 @@ export class OrganizationInvitationLibrary {
|
|||
|
||||
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,
|
||||
});
|
||||
|
@ -85,7 +75,7 @@ export class OrganizationInvitationLibrary {
|
|||
}
|
||||
|
||||
if (!skipEmail) {
|
||||
await this.sendEmail(invitee, magicLink.token);
|
||||
await this.sendEmail(invitee);
|
||||
}
|
||||
|
||||
// Additional query to get the full invitation data
|
||||
|
@ -195,14 +185,13 @@ export class OrganizationInvitationLibrary {
|
|||
});
|
||||
}
|
||||
|
||||
protected async sendEmail(to: string, token: string) {
|
||||
protected async sendEmail(to: string) {
|
||||
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,
|
||||
link: 'TODO',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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`}`;
|
||||
})}
|
||||
|
|
|
@ -138,7 +138,6 @@ describe('organization invitation creation', () => {
|
|||
true
|
||||
);
|
||||
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);
|
||||
|
|
|
@ -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;
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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);
|
|
@ -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. */
|
||||
|
|
Loading…
Add table
Reference in a new issue