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

feat(core): update connector db schema (#732)

* feat(core): update connector db schema

* feat(core): eliminate code redundancy for UTs

* feat(core): delete insertConnector on conflict logic and fix UTs

* feat(core): fix UI according to new connector type implementation

* feat(core): removed unused getConnectorByTargetAndPlatform methods

* feat(core): deprecate the function that updateConnector by giving target and platform

* feat(core): keep SSOT on combination of connector and corresponding metadata

* feat(core): rename index name in db
This commit is contained in:
Darcy Ye 2022-05-12 12:17:17 +08:00 committed by GitHub
parent bafd09474c
commit 8e1533a702
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1306 additions and 1782 deletions

View file

@ -1,6 +1,6 @@
import path from 'path';
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata, ConnectorPlatform } from '@logto/connector-types';
import { getFileContents } from '@logto/shared';
export const authorizationEndpoint = 'https://openauth.alipay.com/oauth2/publicAppAuthorize.htm';
@ -23,8 +23,9 @@ const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'alipay',
target: 'alipay',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
name: {
en: 'Sign In with Alipay',
'zh-CN': '支付宝登录',

View file

@ -56,7 +56,7 @@ export class AlipayConnector implements SocialConnector {
};
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
const { appId: app_id } = await this.getConfig(this.metadata.id);
const { appId: app_id } = await this.getConfig(this.metadata.target, this.metadata.platform);
const redirect_uri = encodeURI(redirectUri);
@ -71,7 +71,7 @@ export class AlipayConnector implements SocialConnector {
};
public getAccessToken: GetAccessToken = async (code): Promise<AccessTokenObject> => {
const config = await this.getConfig(this.metadata.id);
const config = await this.getConfig(this.metadata.target, this.metadata.platform);
const initSearchParameters = {
method: methodForAccessToken,
format: 'JSON',
@ -104,7 +104,7 @@ export class AlipayConnector implements SocialConnector {
};
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
const config = await this.getConfig(this.metadata.id);
const config = await this.getConfig(this.metadata.target, this.metadata.platform);
const { accessToken } = accessTokenObject;
assert(
accessToken && config,

View file

@ -36,8 +36,9 @@ const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'aliyun-dm',
target: 'aliyun-dm',
type: ConnectorType.Email,
platform: null,
name: {
en: 'Aliyun Direct Mail',
'zh-CN': '阿里云邮件推送',

View file

@ -57,7 +57,7 @@ export class AliyunDmConnector implements EmailConnector {
type,
data
) => {
const config = await this.getConfig(this.metadata.id);
const config = await this.getConfig(this.metadata.target, this.metadata.platform);
await this.validateConfig(config);
const { accessKeyId, accessKeySecret, accountName, fromAlias, templates } = config;
const template = templates.find((template) => template.usageType === type);

View file

@ -33,8 +33,9 @@ const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'aliyun-sms',
target: 'aliyun-sms',
type: ConnectorType.SMS,
platform: null,
name: {
en: 'Aliyun Short Message Service',
'zh-CN': '阿里云短信服务',

View file

@ -77,7 +77,7 @@ export class AliyunSmsConnector implements SmsConnector {
type,
{ code }
) => {
const config = await this.getConfig(this.metadata.id);
const config = await this.getConfig(this.metadata.target, this.metadata.platform);
await this.validateConfig(config);
const { accessKeyId, accessKeySecret, signName, templates } = config;
const template = templates.find(({ usageType }) => usageType === type);

View file

@ -1,6 +1,6 @@
import path from 'path';
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { getFileContents } from '@logto/shared';
import { z } from 'zod';
@ -33,8 +33,9 @@ const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'facebook',
target: 'facebook',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
name: {
en: 'Sign In with Facebook',
'zh-CN': 'Facebook 登录',

View file

@ -46,7 +46,7 @@ export class FacebookConnector implements SocialConnector {
};
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
const config = await this.getConfig(this.metadata.id);
const config = await this.getConfig(this.metadata.target, this.metadata.platform);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
@ -67,7 +67,8 @@ export class FacebookConnector implements SocialConnector {
};
const { clientId: client_id, clientSecret: client_secret } = await this.getConfig(
this.metadata.id
this.metadata.target,
this.metadata.platform
);
const { access_token: accessToken } = await got

View file

@ -1,6 +1,6 @@
import path from 'path';
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { getFileContents } from '@logto/shared';
import { z } from 'zod';
@ -24,8 +24,9 @@ const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'github',
target: 'github',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
name: {
en: 'Sign In with GitHub',
'zh-CN': 'GitHub登录',

View file

@ -41,7 +41,7 @@ export class GithubConnector implements SocialConnector {
};
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
const config = await this.getConfig(this.metadata.id);
const config = await this.getConfig(this.metadata.target, this.metadata.platform);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
@ -61,7 +61,8 @@ export class GithubConnector implements SocialConnector {
};
const { clientId: client_id, clientSecret: client_secret } = await this.getConfig(
this.metadata.id
this.metadata.target,
this.metadata.platform
);
const { access_token: accessToken } = await got

View file

@ -1,6 +1,6 @@
import path from 'path';
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { getFileContents } from '@logto/shared';
import { z } from 'zod';
@ -24,8 +24,9 @@ const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'google',
target: 'google',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
name: {
en: 'Sign In with Google',
'zh-CN': 'Google登录',

View file

@ -45,7 +45,7 @@ export class GoogleConnector implements SocialConnector {
};
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
const config = await this.getConfig(this.metadata.id);
const config = await this.getConfig(this.metadata.target, this.metadata.platform);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
@ -65,7 +65,10 @@ export class GoogleConnector implements SocialConnector {
token_type: string;
};
const { clientId, clientSecret } = await this.getConfig(this.metadata.id);
const { clientId, clientSecret } = await this.getConfig(
this.metadata.target,
this.metadata.platform
);
// NoteNeed to decodeURIComponent on code
// https://stackoverflow.com/questions/51058256/google-api-node-js-invalid-grant-malformed-auth-code

View file

@ -1,4 +1,5 @@
import type { Languages } from '@logto/phrases';
import { Language } from '@logto/phrases';
import { Nullable } from '@silverhand/essentials';
export enum ConnectorType {
Email = 'Email',
@ -6,12 +7,19 @@ export enum ConnectorType {
Social = 'Social',
}
export enum ConnectorPlatform {
Native = 'Native',
Universal = 'Universal',
Web = 'Web',
}
export interface ConnectorMetadata {
id: string;
target: string;
type: ConnectorType;
name: Record<Languages, string>;
platform: Nullable<ConnectorPlatform>;
name: Record<Language, string>;
logo: string;
description: Record<Languages, string>;
description: Record<Language, string>;
readme: string;
configTemplate: string;
}
@ -94,4 +102,7 @@ export type GetUserInfo = (
accessTokenObject: AccessTokenObject
) => Promise<{ id: string } & Record<string, string | undefined>>;
export type GetConnectorConfig<T = Record<string, unknown>> = (id: string) => Promise<T>;
export type GetConnectorConfig<T = Record<string, unknown>> = (
target: string,
platform: Nullable<ConnectorPlatform>
) => Promise<T>;

View file

@ -1,6 +1,6 @@
import path from 'path';
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { getFileContents } from '@logto/shared';
import { z } from 'zod';
@ -21,8 +21,9 @@ const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'wechat-native',
target: 'wechat-native',
type: ConnectorType.Social,
platform: ConnectorPlatform.Native,
name: {
en: 'Sign In with WeChat',
'zh-CN': '微信登录',

View file

@ -49,7 +49,7 @@ export class WeChatNativeConnector implements SocialConnector {
};
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
const { appId } = await this.getConfig(this.metadata.id);
const { appId } = await this.getConfig(this.metadata.target, this.metadata.platform);
const queryParameters = new URLSearchParams({
appid: appId,
@ -71,7 +71,10 @@ export class WeChatNativeConnector implements SocialConnector {
errcode?: number;
};
const { appId: appid, appSecret: secret } = await this.getConfig(this.metadata.id);
const { appId: appid, appSecret: secret } = await this.getConfig(
this.metadata.target,
this.metadata.platform
);
const {
access_token: accessToken,

View file

@ -1,6 +1,6 @@
import path from 'path';
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { getFileContents } from '@logto/shared';
import { z } from 'zod';
@ -21,8 +21,9 @@ const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'wechat',
target: 'wechat',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
name: {
en: 'Sign In with WeChat',
'zh-CN': '微信登录',

View file

@ -49,7 +49,7 @@ export class WeChatConnector implements SocialConnector {
};
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
const { appId } = await this.getConfig(this.metadata.id);
const { appId } = await this.getConfig(this.metadata.target, this.metadata.platform);
const queryParameters = new URLSearchParams({
appid: appId,
@ -72,7 +72,10 @@ export class WeChatConnector implements SocialConnector {
errcode?: number;
};
const { appId: appid, appSecret: secret } = await this.getConfig(this.metadata.id);
const { appId: appid, appSecret: secret } = await this.getConfig(
this.metadata.target,
this.metadata.platform
);
const {
access_token: accessToken,

View file

@ -1,9 +1,8 @@
import { Languages } from '@logto/phrases';
import React from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
resource: Record<Languages, string>;
resource: Record<string, string>;
className?: string;
};

View file

@ -37,12 +37,13 @@ const GuideModal = ({ connector, isOpen, onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
id: connectorId,
type: connectorType,
metadata: { name, configTemplate, readme },
metadata: { type: connectorType, name, configTemplate, readme },
} = connector;
const locale = i18next.language;
const connectorName = name[locale] ?? name.en;
// TODO: LOG-2393 should fix name[locale] syntax error
const foundName = Object.entries(name).find(([lang]) => lang === locale);
const connectorName = foundName ? foundName[1] : name.en;
const isSocialConnector =
connectorType !== ConnectorType.SMS && connectorType !== ConnectorType.Email;
const [activeStepIndex, setActiveStepIndex] = useState<number>(0);

View file

@ -1,4 +1,4 @@
import { Languages } from '@logto/phrases';
import { Language } from '@logto/phrases';
import { ConnectorDTO, Identities } from '@logto/schemas';
import { Optional } from '@silverhand/essentials';
import React, { useMemo, useState } from 'react';
@ -23,7 +23,7 @@ type DisplayConnector = {
id: string;
userId?: string;
logo: string;
name: Record<Languages, string>;
name: Record<Language, string>;
};
const UserConnectors = ({ userId, connectors, onDelete }: Props) => {

View file

@ -31,7 +31,7 @@
"@logto/connector-wechat-native": "^0.1.0",
"@logto/phrases": "^0.1.0",
"@logto/schemas": "^0.1.0",
"@silverhand/essentials": "^1.1.0",
"@silverhand/essentials": "^1.1.6",
"argon2": "^0.28.5",
"chalk": "^4",
"dayjs": "^1.10.5",

View file

@ -1,55 +1,152 @@
import { ConnectorPlatform } from '@logto/connector-types';
import { Connector, ConnectorMetadata, ConnectorType } from '@logto/schemas';
export const mockMetadata: ConnectorMetadata = {
target: 'connector',
type: ConnectorType.Email,
platform: null,
name: {
en: 'Connector',
'zh-CN': '连接器',
},
logo: './logo.png',
description: {
en: 'Connector',
'zh-CN': '连接器',
},
readme: 'README.md',
configTemplate: 'config-template.md',
};
export const mockConnector: Connector = {
id: 'connector',
target: 'connector',
platform: ConnectorPlatform.Universal,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
};
const mockMetadata0: ConnectorMetadata = {
...mockMetadata,
target: 'connector_0',
type: ConnectorType.Email,
platform: ConnectorPlatform.Universal,
};
const mockMetadata1: ConnectorMetadata = {
...mockMetadata,
target: 'connector_1',
type: ConnectorType.SMS,
platform: ConnectorPlatform.Universal,
};
const mockMetadata2: ConnectorMetadata = {
...mockMetadata,
target: 'connector_2',
type: ConnectorType.Social,
platform: ConnectorPlatform.Universal,
};
const mockMetadata3: ConnectorMetadata = {
...mockMetadata,
target: 'connector_3',
type: ConnectorType.Social,
platform: ConnectorPlatform.Universal,
};
const mockMetadata4: ConnectorMetadata = {
...mockMetadata,
target: 'connector_4',
type: ConnectorType.Social,
platform: ConnectorPlatform.Universal,
};
const mockMetadata5: ConnectorMetadata = {
...mockMetadata,
target: 'connector_5',
type: ConnectorType.Social,
platform: ConnectorPlatform.Universal,
};
const mockMetadata6: ConnectorMetadata = {
...mockMetadata,
target: 'connector_6',
type: ConnectorType.Social,
platform: ConnectorPlatform.Universal,
};
const mockConnector0: Connector = {
id: 'connector_0',
target: 'connector_0',
platform: ConnectorPlatform.Universal,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
};
const mockConnector1: Connector = {
id: 'connector_1',
target: 'connector_1',
platform: ConnectorPlatform.Universal,
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
};
const mockConnector2: Connector = {
id: 'connector_2',
target: 'connector_2',
platform: ConnectorPlatform.Universal,
enabled: true,
config: {},
createdAt: 1_234_567_890_345,
};
const mockConnector3: Connector = {
id: 'connector_3',
target: 'connector_3',
platform: ConnectorPlatform.Universal,
enabled: true,
config: {},
createdAt: 1_234_567_890_456,
};
const mockConnector4: Connector = {
id: 'connector_4',
target: 'connector_4',
platform: ConnectorPlatform.Universal,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};
const mockConnector5: Connector = {
id: 'connector_5',
target: 'connector_5',
platform: ConnectorPlatform.Universal,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};
const mockConnector6: Connector = {
id: 'connector_6',
target: 'connector_6',
platform: ConnectorPlatform.Universal,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};
export const mockConnectorList: Connector[] = [
{
id: 'connector_0',
type: ConnectorType.Email,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
{
id: 'connector_1',
type: ConnectorType.SMS,
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
},
{
id: 'connector_2',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_345,
},
{
id: 'connector_3',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_456,
},
{
id: 'connector_4',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
},
{
id: 'connector_5',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
},
{
id: 'connector_6',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
},
mockConnector0,
mockConnector1,
mockConnector2,
mockConnector3,
mockConnector4,
mockConnector5,
mockConnector6,
];
export const mockConnectorInstanceList: Array<{
@ -57,189 +154,116 @@ export const mockConnectorInstanceList: Array<{
metadata: ConnectorMetadata;
}> = [
{
connector: {
id: 'connector_0',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockConnector0,
metadata: { ...mockMetadata0, type: ConnectorType.Social },
},
{
connector: mockConnector1,
metadata: mockMetadata1,
},
{
connector: mockConnector2,
metadata: mockMetadata2,
},
{
connector: mockConnector3,
metadata: mockMetadata3,
},
{
connector: {
id: 'connector_1',
type: ConnectorType.SMS,
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
},
metadata: {
id: 'connector_1',
type: ConnectorType.SMS,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
...mockConnector4,
platform: null,
},
metadata: { ...mockMetadata4, type: ConnectorType.Email, platform: null },
},
{
connector: {
id: 'connector_2',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_345,
},
metadata: {
id: 'connector_2',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
...mockConnector5,
platform: null,
},
metadata: { ...mockMetadata5, type: ConnectorType.SMS, platform: null },
},
{
connector: {
id: 'connector_3',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_456,
},
metadata: {
id: 'connector_3',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
},
{
connector: {
id: 'connector_4',
type: ConnectorType.Email,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
},
metadata: {
id: 'connector_4',
type: ConnectorType.Email,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
},
{
connector: {
id: 'connector_5',
type: ConnectorType.SMS,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
},
metadata: {
id: 'connector_5',
type: ConnectorType.SMS,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
},
{
connector: {
id: 'connector_6',
type: ConnectorType.Email,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
},
metadata: {
id: 'connector_6',
type: ConnectorType.Email,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
...mockConnector6,
platform: null,
},
metadata: { ...mockMetadata6, type: ConnectorType.Email, platform: null },
},
];
export const mockAliyunDmConnectorInstance = {
connector: {
...mockConnector,
id: 'aliyun-dm',
enabled: true,
config: {},
createdAt: 1_646_382_233_333,
target: 'aliyun-dm',
platform: null,
},
metadata: {
...mockMetadata,
target: 'aliyun-dm',
type: ConnectorType.Email,
platform: null,
},
};
export const mockAliyunSmsConnectorInstance = {
connector: {
...mockConnector,
id: 'aliyun-sms',
enabled: true,
config: {},
createdAt: 1_646_382_233_333,
target: 'aliyun-sms',
platform: null,
},
metadata: {
...mockMetadata,
target: 'aliyun-sms',
type: ConnectorType.SMS,
platform: null,
},
};
export const mockFacebookConnectorInstance = {
connector: {
...mockConnector,
id: 'facebook',
enabled: true,
config: {},
createdAt: 1_646_382_233_333,
target: 'facebook',
platform: ConnectorPlatform.Web,
},
metadata: {
...mockMetadata,
target: 'facebook',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
},
};
export const mockGithubConnectorInstance = {
connector: {
...mockConnector,
id: 'github',
enabled: true,
config: {},
createdAt: 1_646_382_233_000,
target: 'github',
platform: ConnectorPlatform.Web,
},
metadata: {
...mockMetadata,
target: 'github',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
},
};
export const mockGoogleConnectorInstance = {
connector: {
...mockConnector,
id: 'google',
target: 'google',
platform: ConnectorPlatform.Web,
enabled: false,
config: {},
createdAt: 1_646_382_233_000,
},
metadata: {
...mockMetadata,
target: 'google',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
},
};

View file

@ -1,5 +1,5 @@
import { ConnectorPlatform } from '@logto/connector-types';
import { Connector, ConnectorType } from '@logto/schemas';
import { NotFoundError } from 'slonik';
import {
getConnectorInstanceById,
@ -13,56 +13,64 @@ import RequestError from '@/errors/RequestError';
const alipayConnector = {
id: 'alipay',
type: ConnectorType.Social,
target: 'alipay',
platform: ConnectorPlatform.Web,
enabled: true,
config: {},
createdAt: 1_646_382_233_911,
};
const aliyunDmConnector = {
id: 'aliyun-dm',
type: ConnectorType.Email,
target: 'aliyun-dm',
platform: null,
enabled: true,
config: {},
createdAt: 1_646_382_233_911,
};
const aliyunSmsConnector = {
id: 'aliyun-sms',
type: ConnectorType.SMS,
target: 'aliyun-sms',
platform: null,
enabled: false,
config: {},
createdAt: 1_646_382_233_666,
};
const facebookConnector = {
id: 'facebook',
type: ConnectorType.Social,
target: 'facebook',
platform: ConnectorPlatform.Web,
enabled: true,
config: {},
createdAt: 1_646_382_233_333,
};
const githubConnector = {
id: 'github',
type: ConnectorType.Social,
target: 'github',
platform: ConnectorPlatform.Web,
enabled: true,
config: {},
createdAt: 1_646_382_233_555,
};
const googleConnector = {
id: 'google',
type: ConnectorType.Social,
target: 'google',
platform: ConnectorPlatform.Web,
enabled: false,
config: {},
createdAt: 1_646_382_233_000,
};
const wechatConnector = {
id: 'wechat',
type: ConnectorType.Social,
target: 'wechat',
platform: ConnectorPlatform.Web,
enabled: false,
config: {},
createdAt: 1_646_382_233_000,
};
const wechatNativeConnector = {
id: 'wechat-native',
type: ConnectorType.Social,
target: 'wechat-native',
platform: ConnectorPlatform.Native,
enabled: false,
config: {},
createdAt: 1_646_382_233_000,
@ -78,24 +86,13 @@ const connectors = [
wechatConnector,
wechatNativeConnector,
];
const connectorMap = new Map(connectors.map((connector) => [connector.id, connector]));
const findAllConnectors = jest.fn(async () => connectors);
const findConnectorById = jest.fn(async (id: string) => {
const connector = connectorMap.get(id);
if (!connector) {
throw new NotFoundError();
}
return connector;
});
const insertConnector = jest.fn(async (connector: Connector) => connector);
jest.mock('@/queries/connector', () => ({
...jest.requireActual('@/queries/connector'),
findAllConnectors: async () => findAllConnectors(),
findConnectorById: async (id: string) => findConnectorById(id),
insertConnector: async (connector: Connector) => insertConnector(connector),
}));
@ -122,7 +119,7 @@ describe('getConnectorInstances', () => {
test('should access DB only once and should not throw', async () => {
await expect(getConnectorInstances()).resolves.not.toThrow();
expect(findAllConnectors).toHaveBeenCalledTimes(1);
expect(findAllConnectors).toHaveBeenCalled();
});
afterEach(() => {
@ -131,15 +128,29 @@ describe('getConnectorInstances', () => {
});
describe('getConnectorInstanceById', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('should return the connector existing in DB', async () => {
const connectorInstance = await getConnectorInstanceById('aliyun-dm');
expect(connectorInstance).toHaveProperty('connector', aliyunDmConnector);
});
test('should throw on invalid id', async () => {
test('should throw on invalid id (on DB query)', async () => {
const id = 'invalid_id';
await expect(getConnectorInstanceById(id)).rejects.toThrow();
});
test('should throw on invalid id (on finding metadata)', async () => {
const id = 'invalid_id';
await expect(getConnectorInstanceById(id)).rejects.toMatchError(
new RequestError({ code: 'entity.not_found', id, status: 404 })
new RequestError({
code: 'entity.not_found',
target: 'invalid_target',
platfrom: ConnectorPlatform.Web,
status: 404,
})
);
});
});
@ -150,10 +161,14 @@ describe('getSocialConnectorInstanceById', () => {
expect(socialConnectorInstance).toHaveProperty('connector', googleConnector);
});
test('should throw on invalid id', async () => {
const id = 'invalid_id';
test('should throw on non-social connector', async () => {
const id = 'aliyun-dm';
await expect(getSocialConnectorInstanceById(id)).rejects.toMatchError(
new RequestError({ code: 'entity.not_found', id, status: 404 })
new RequestError({
code: 'entity.not_found',
id,
status: 404,
})
);
});
});
@ -186,8 +201,14 @@ describe('initConnectors', () => {
expect(insertConnector).toHaveBeenCalledTimes(connectors.length);
for (const [i, connector] of connectors.entries()) {
const { id, type } = connector;
expect(insertConnector).toHaveBeenNthCalledWith(i + 1, { id, type });
const { target, platform } = connector;
expect(insertConnector).toHaveBeenNthCalledWith(
i + 1,
expect.objectContaining({
target,
platform,
})
);
}
});
@ -197,6 +218,7 @@ describe('initConnectors', () => {
});
afterEach(() => {
findAllConnectors.mockClear();
insertConnector.mockClear();
});
});

View file

@ -6,12 +6,13 @@ import { GithubConnector } from '@logto/connector-github';
import { GoogleConnector } from '@logto/connector-google';
import { WeChatConnector } from '@logto/connector-wechat';
import { WeChatNativeConnector } from '@logto/connector-wechat-native';
import { nanoid } from 'nanoid';
import RequestError from '@/errors/RequestError';
import { findAllConnectors, findConnectorById, insertConnector } from '@/queries/connector';
import { findAllConnectors, insertConnector } from '@/queries/connector';
import { ConnectorInstance, ConnectorType, IConnector, SocialConnectorInstance } from './types';
import { getConnectorConfig } from './utilities';
import { buildIndexWithTargetAndPlatform, getConnectorConfig } from './utilities';
const allConnectors: IConnector[] = [
new AlipayConnector(getConnectorConfig),
@ -26,14 +27,19 @@ const allConnectors: IConnector[] = [
export const getConnectorInstances = async (): Promise<ConnectorInstance[]> => {
const connectors = await findAllConnectors();
const connectorMap = new Map(connectors.map((connector) => [connector.id, connector]));
const connectorMap = new Map(
connectors.map((connector) => [
buildIndexWithTargetAndPlatform(connector.target, connector.platform),
connector,
])
);
return allConnectors.map((element) => {
const { id } = element.metadata;
const connector = connectorMap.get(id);
const { target, platform } = element.metadata;
const connector = connectorMap.get(buildIndexWithTargetAndPlatform(target, platform));
if (!connector) {
throw new RequestError({ code: 'entity.not_found', id, status: 404 });
throw new RequestError({ code: 'entity.not_found', target, platform, status: 404 });
}
return { connector, ...element };
@ -41,9 +47,10 @@ export const getConnectorInstances = async (): Promise<ConnectorInstance[]> => {
};
export const getConnectorInstanceById = async (id: string): Promise<ConnectorInstance> => {
const found = allConnectors.find((element) => element.metadata.id === id);
const connectorInstances = await getConnectorInstances();
const pickedConnectorInstance = connectorInstances.find(({ connector }) => connector.id === id);
if (!found) {
if (!pickedConnectorInstance) {
throw new RequestError({
code: 'entity.not_found',
id,
@ -51,9 +58,7 @@ export const getConnectorInstanceById = async (id: string): Promise<ConnectorIns
});
}
const connector = await findConnectorById(id);
return { connector, ...found };
return pickedConnectorInstance;
};
const isSocialConnectorInstance = (
@ -88,7 +93,7 @@ export const getEnabledSocialConnectorIds = async <T extends ConnectorInstance>(
(instance): instance is T =>
instance.connector.enabled && instance.metadata.type === ConnectorType.Social
)
.map((instance) => instance.metadata.id);
.map((instance) => instance.connector.id);
};
export const getConnectorInstanceByType = async <T extends ConnectorInstance>(
@ -108,11 +113,26 @@ export const getConnectorInstanceByType = async <T extends ConnectorInstance>(
export const initConnectors = async () => {
const connectors = await findAllConnectors();
const existingConnectors = new Map(connectors.map((connector) => [connector.id, connector]));
const newConnectors = allConnectors.filter(
({ metadata: { id, type } }) => existingConnectors.get(id)?.type !== type
const existingConnectors = new Map(
connectors.map((connector) => [
buildIndexWithTargetAndPlatform(connector.target, connector.platform),
connector,
])
);
const newConnectors = allConnectors.filter(
({ metadata: { target, platform } }) =>
existingConnectors.get(buildIndexWithTargetAndPlatform(target, platform))?.platform !==
platform
);
await Promise.all(
newConnectors.map(async ({ metadata: { id, type } }) => insertConnector({ id, type }))
newConnectors.map(async ({ metadata: { target, platform } }) => {
const id = nanoid();
await insertConnector({
id,
target,
platform,
});
})
);
};

View file

@ -1,24 +1,35 @@
import { ConnectorType } from '@logto/schemas';
import { ConnectorPlatform } from '@logto/connector-types';
import { Connector } from '@logto/schemas';
import { findConnectorById, updateConnector } from '@/queries/connector';
import { buildIndexWithTargetAndPlatform, getConnectorConfig } from '.';
import { getConnectorConfig, updateConnectorConfig } from '.';
jest.mock('@/queries/connector');
it('getConnectorConfig()', async () => {
(findConnectorById as jest.MockedFunction<typeof findConnectorById>).mockResolvedValueOnce({
const connectors: Connector[] = [
{
id: 'id',
type: ConnectorType.Social,
target: 'target',
platform: null,
enabled: true,
config: { foo: 'bar' },
createdAt: 0,
});
const config = await getConnectorConfig('connectorId');
expect(config).toMatchObject({ foo: 'bar' });
},
];
const findAllConnectors = jest.fn(async () => connectors);
jest.mock('@/queries/connector', () => ({
...jest.requireActual('@/queries/connector'),
findAllConnectors: async () => findAllConnectors(),
}));
it('buildIndexWithTargetAndPlatform() with not-null `platform`', async () => {
expect(buildIndexWithTargetAndPlatform('target', ConnectorPlatform.Web)).toEqual('target_Web');
});
it('updateConnectorConfig() should call updateConnector()', async () => {
await updateConnectorConfig('connectorId', { foo: 'bar' });
expect(updateConnector).toHaveBeenCalled();
it('buildIndexWithTargetAndPlatform() with null `platform`', async () => {
expect(buildIndexWithTargetAndPlatform('target', null)).toEqual('target_null');
});
it('getConnectorConfig()', async () => {
const config = await getConnectorConfig('target', null);
expect(config).toMatchObject({ foo: 'bar' });
});

View file

@ -1,19 +1,26 @@
import { ArbitraryObject } from '@logto/schemas';
import { ArbitraryObject, ConnectorPlatform } from '@logto/schemas';
import { Nullable } from '@silverhand/essentials';
import { findConnectorById, updateConnector } from '@/queries/connector';
import RequestError from '@/errors/RequestError';
import { findAllConnectors } from '@/queries/connector';
export const getConnectorConfig = async <T extends ArbitraryObject>(id: string): Promise<T> => {
const connector = await findConnectorById(id);
export const buildIndexWithTargetAndPlatform = (
target: string,
platform: Nullable<string>
): string => [target, platform ?? 'null'].join('_');
export const getConnectorConfig = async <T extends ArbitraryObject>(
target: string,
platform: Nullable<ConnectorPlatform>
): Promise<T> => {
const connectors = await findAllConnectors();
const connector = connectors.find(
(connector) => connector.target === target && connector.platform === platform
);
if (!connector) {
throw new RequestError({ code: 'entity.not_found', target, platform, status: 404 });
}
return connector.config as T;
};
export const updateConnectorConfig = async <T extends ArbitraryObject>(
id: string,
config: T
): Promise<void> => {
await updateConnector({
where: { id },
set: { config },
});
};

View file

@ -1,6 +1,7 @@
import { ConnectorType } from '@logto/connector-types';
import { Passcode, PasscodeType } from '@logto/schemas';
import { mockConnector, mockMetadata } from '@/__mocks__';
import { getConnectorInstanceByType } from '@/connectors';
import RequestError from '@/errors/RequestError';
import {
@ -121,23 +122,20 @@ describe('sendPasscode', () => {
it('should call sendPasscode with params matching', async () => {
const sendMessage = jest.fn();
const mockedMetadata = {
...mockMetadata,
id: 'id',
type: ConnectorType.SMS,
platform: null,
};
mockedGetConnectorInstanceByType.mockResolvedValue({
connector: {
...mockConnector,
id: 'id',
type: ConnectorType.SMS,
enabled: true,
config: {},
createdAt: Date.now(),
},
metadata: {
id: 'id',
type: ConnectorType.SMS,
name: {},
logo: '',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
target: 'connector',
platform: null,
},
metadata: mockedMetadata,
sendMessage,
validateConfig: jest.fn(),
getConfig: jest.fn(),

View file

@ -1,16 +1,12 @@
import { Connectors, ConnectorType, CreateConnector } from '@logto/schemas';
import { createMockPool, createMockQueryResult, sql, QueryResultRow } from 'slonik';
import { Connectors } from '@logto/schemas';
import { createMockPool, createMockQueryResult, sql } from 'slonik';
import { mockConnector } from '@/__mocks__';
import { convertToIdentifiers } from '@/database/utils';
import envSet from '@/env-set';
import { expectSqlAssert, QueryType } from '@/utils/test-utils';
import {
findAllConnectors,
findConnectorById,
insertConnector,
updateConnector,
} from './connector';
import { findAllConnectors, insertConnector, updateConnector } from './connector';
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
@ -43,51 +39,36 @@ describe('connector queries', () => {
await expect(findAllConnectors()).resolves.toEqual([rowData]);
});
it('findConnectorById', async () => {
const id = 'foo';
const rowData = { id };
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.id}=$1
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([id]);
return createMockQueryResult([rowData]);
});
await expect(findConnectorById(id)).resolves.toEqual(rowData);
});
it('insertConnector', async () => {
const connector: CreateConnector & QueryResultRow = {
id: 'foo',
type: ConnectorType.Social,
enabled: true,
const connector = {
...mockConnector,
config: JSON.stringify(mockConnector.config),
};
const expectSql = `
insert into "connectors" ("id", "type", "enabled")
values ($1, $2, $3)
insert into "connectors" ("id", "target", "platform", "enabled", "config")
values ($1, $2, $3, $4, $5)
returning *
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql);
expect(values).toEqual([connector.id, connector.type, connector.enabled]);
expect(values).toEqual([
connector.id,
connector.target,
connector.platform,
connector.enabled,
connector.config,
]);
return createMockQueryResult([connector]);
});
await expect(insertConnector(connector)).resolves.toEqual(connector);
await expect(insertConnector(mockConnector)).resolves.toEqual(connector);
});
it('updateConnector', async () => {
it('updateConnector (with id)', async () => {
const id = 'foo';
const enabled = false;

View file

@ -17,13 +17,6 @@ export const findAllConnectors = async () =>
`)
);
export const findConnectorById = async (id: string) =>
envSet.pool.one<Connector>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.id}=${id}
`);
export const insertConnector = buildInsertInto<CreateConnector, Connector>(Connectors, {
returning: true,
});

View file

@ -20,15 +20,34 @@ import { createRequester } from '@/utils/test-utils';
import connectorRoutes from './connector';
const mockMetadata: ConnectorMetadata = {
target: 'connector_0',
type: ConnectorType.Social,
platform: null,
name: {
en: 'Connector',
'zh-CN': '连接器',
},
logo: './logo.png',
description: { en: 'Connector', 'zh-CN': '连接器' },
readme: 'README.md',
configTemplate: 'config-template.md',
};
const mockConnector: Connector = {
id: 'connector_0',
target: 'connector_0',
platform: null,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
};
type ConnectorInstance = {
connector: Connector;
metadata: ConnectorMetadata;
validateConfig?: ValidateConfig;
};
const findConnectorByIdPlaceHolder = jest.fn() as jest.MockedFunction<
(connectorId: string) => Promise<Connector>
>;
const getConnectorInstanceByIdPlaceHolder = jest.fn() as jest.MockedFunction<
(connectorId: string) => Promise<ConnectorInstance>
>;
@ -40,7 +59,6 @@ const getConnectorInstancesPlaceHolder = jest.fn() as jest.MockedFunction<
>;
jest.mock('@/queries/connector', () => ({
findConnectorById: async (connectorId: string) => findConnectorByIdPlaceHolder(connectorId),
findAllConnectors: jest.fn(),
updateConnector: jest.fn(),
}));
@ -95,7 +113,7 @@ describe('connector route', () => {
it('throws when connector can not be found by given connectorId (locally)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
const found = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.metadata.id === 'connector'
(connectorInstance) => connectorInstance.connector.id === 'connector'
);
assertThat(found, new RequestError({ code: 'entity.not_found', status: 404 }));
@ -108,7 +126,7 @@ describe('connector route', () => {
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (id: string) => {
const foundConnectorInstance = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.metadata.id === id
(connectorInstance) => connectorInstance.connector.id === id
);
assertThat(
foundConnectorInstance,
@ -127,7 +145,7 @@ describe('connector route', () => {
it('shows found connector information', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (id: string) => {
const foundConnectorInstance = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.metadata.id === id
(connectorInstance) => connectorInstance.connector.id === id
);
assertThat(
foundConnectorInstance,
@ -152,7 +170,7 @@ describe('connector route', () => {
it('throws if connector can not be found (locally)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
const found = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.metadata.id === 'connector'
(connectorInstance) => connectorInstance.connector.id === 'connector'
);
assertThat(found, new RequestError({ code: 'entity.not_found', status: 404 }));
@ -167,7 +185,7 @@ describe('connector route', () => {
it('throws if connector can not be found (remotely)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (id: string) => {
const foundConnectorInstance = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.metadata.id === id
(connectorInstance) => connectorInstance.connector.id === id
);
assertThat(
foundConnectorInstance,
@ -188,22 +206,8 @@ describe('connector route', () => {
it('enables one of the social connectors (with valid config)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: {
id: 'connector_0',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockConnector,
metadata: mockMetadata,
validateConfig: jest.fn(),
};
});
@ -217,15 +221,7 @@ describe('connector route', () => {
})
);
expect(response.body).toMatchObject({
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
@ -233,22 +229,8 @@ describe('connector route', () => {
it('enables one of the social connectors (with invalid config)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: {
id: 'connector_0',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockConnector,
metadata: mockMetadata,
validateConfig: async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
@ -263,21 +245,10 @@ describe('connector route', () => {
it('disables one of the social connectors', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: {
id: 'connector_0',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
connector: mockConnector,
metadata: mockMetadata,
validateConfig: async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
};
});
@ -291,38 +262,30 @@ describe('connector route', () => {
})
);
expect(response.body).toMatchObject({
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
},
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the email/sms connectors (with valid config)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList);
const mockedMetadata = {
...mockMetadata,
id: 'connector_1',
type: ConnectorType.SMS,
};
const mockedConnector = {
...mockConnector,
id: 'connector_1',
name: 'connector_1',
platform: null,
type: ConnectorType.SMS,
metadata: mockedMetadata,
};
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: {
id: 'connector_1',
type: ConnectorType.SMS,
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
},
metadata: {
id: 'connector_1',
type: ConnectorType.SMS,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
};
});
@ -351,38 +314,30 @@ describe('connector route', () => {
})
);
expect(response.body).toMatchObject({
metadata: {
id: 'connector_1',
type: ConnectorType.SMS,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
},
metadata: mockedMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the email/sms connectors (with invalid config)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList);
const mockedMetadata = {
...mockMetadata,
id: 'connector_1',
type: ConnectorType.SMS,
};
const mockedConnector = {
...mockConnector,
id: 'connector_1',
name: 'connector_1',
platform: null,
type: ConnectorType.SMS,
metadata: mockedMetadata,
};
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: {
id: 'connector_1',
type: ConnectorType.SMS,
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
},
metadata: {
id: 'connector_1',
type: ConnectorType.SMS,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
@ -395,24 +350,24 @@ describe('connector route', () => {
});
it('disables one of the email/sms connectors', async () => {
const mockedMetadata = {
...mockMetadata,
id: 'connector_4',
type: ConnectorType.Email,
platform: null,
};
const mockedConnector = {
...mockConnector,
id: 'connector_4',
name: 'connector_4',
platform: null,
type: ConnectorType.Email,
metadata: mockedMetadata,
};
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: {
id: 'connector_4',
type: ConnectorType.Email,
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
},
metadata: {
id: 'connector_4',
type: ConnectorType.Email,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockedConnector,
metadata: mockedMetadata,
};
});
const response = await connectorRequest
@ -425,14 +380,7 @@ describe('connector route', () => {
})
);
expect(response.body).toMatchObject({
metadata: {
id: 'connector_4',
type: ConnectorType.Email,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
},
metadata: mockedMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
@ -446,7 +394,7 @@ describe('connector route', () => {
it('throws when connector can not be found by given connectorId (locally)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
const found = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.metadata.id === 'connector'
(connectorInstance) => connectorInstance.connector.id === 'connector'
);
assertThat(found, new RequestError({ code: 'entity.not_found', status: 404 }));
@ -459,7 +407,7 @@ describe('connector route', () => {
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (id: string) => {
const foundConnectorInstance = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.metadata.id === id
(connectorInstance) => connectorInstance.connector.id === id
);
assertThat(
foundConnectorInstance,
@ -478,22 +426,8 @@ describe('connector route', () => {
it('config validation fails', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: {
id: 'connector_0',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockConnector,
metadata: mockMetadata,
validateConfig: async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
@ -508,22 +442,8 @@ describe('connector route', () => {
it('successfully updates connector configs', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: {
id: 'connector_0',
type: ConnectorType.Social,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockConnector,
metadata: mockMetadata,
validateConfig: jest.fn(),
};
});
@ -537,14 +457,7 @@ describe('connector route', () => {
})
);
expect(response.body).toMatchObject({
metadata: {
id: 'connector_0',
type: ConnectorType.Social,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
},
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
@ -556,23 +469,18 @@ describe('connector route', () => {
});
it('should get email connector and send message', async () => {
const mockedMetadata = {
...mockMetadata,
type: ConnectorType.Email,
};
const mockedConnector = {
...mockConnector,
type: ConnectorType.Email,
metadata: mockedMetadata,
};
const mockedEmailConnector: EmailConnectorInstance = {
connector: {
id: 'connector_0',
type: ConnectorType.Email,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.Email,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
getConfig: jest.fn(),
sendMessage: async (
@ -603,23 +511,18 @@ describe('connector route', () => {
});
it('should get SMS connector and send message', async () => {
const mockedSmsConnector: SmsConnectorInstance = {
connector: {
id: 'connector_0',
type: ConnectorType.SMS,
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
id: 'connector_0',
type: ConnectorType.SMS,
name: {},
logo: './logo.png',
description: {},
readme: 'README.md',
configTemplate: 'config-template.md',
},
const mockedMetadata = {
...mockMetadata,
type: ConnectorType.SMS,
};
const mockedConnector = {
...mockConnector,
type: ConnectorType.SMS,
metadata: mockedMetadata,
};
const mockedSmsConnectorInstance: SmsConnectorInstance = {
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
getConfig: jest.fn(),
sendMessage: async (
@ -631,10 +534,10 @@ describe('connector route', () => {
};
getConnectorInstanceByTypePlaceHolder.mockImplementationOnce(async (_: ConnectorType) => {
return mockedSmsConnector;
return mockedSmsConnectorInstance;
});
const sendMessageSpy = jest.spyOn(mockedSmsConnector, 'sendMessage');
const sendMessageSpy = jest.spyOn(mockedSmsConnectorInstance, 'sendMessage');
const response = await connectorRequest
.post('/connectors/test/sms')
.send({ phone: '12345678901' });

View file

@ -2,19 +2,14 @@ import { NormalizeKeyPaths } from '@silverhand/essentials';
import en from './locales/en';
import zhCN from './locales/zh-cn';
import { Resource } from './types';
import { Resource, Language } from './types';
export { Language } from './types';
export type LogtoErrorCode = NormalizeKeyPaths<typeof en.errors>;
export type LogtoErrorI18nKey = `errors:${LogtoErrorCode}`;
export type Languages = keyof Resource;
export type I18nKey = NormalizeKeyPaths<typeof en.translation>;
export type AdminConsoleKey = NormalizeKeyPaths<typeof en.translation.admin_console>;
export enum Language {
English = 'en',
Chinese = 'zh-CN',
}
const resource: Resource = {
[Language.English]: en,
[Language.Chinese]: zhCN,

View file

@ -1,9 +1,7 @@
/* eslint-disable @typescript-eslint/consistent-indexed-object-style */
/* Copied from i18next/index.d.ts */
export interface Resource {
[language: string]: ResourceLanguage;
}
export type Resource = Record<Language, ResourceLanguage>;
export interface ResourceLanguage {
[namespace: string]: ResourceKey;
@ -11,4 +9,9 @@ export interface ResourceLanguage {
export type ResourceKey = string | { [key: string]: any };
export enum Language {
English = 'en',
Chinese = 'zh-CN',
}
/* eslint-enable @typescript-eslint/consistent-indexed-object-style */

View file

@ -3,7 +3,7 @@ import { ConnectorMetadata } from '@logto/connector-types';
import { Connector } from '../db-entries';
export type { ConnectorMetadata } from '@logto/connector-types';
export { ConnectorType } from '@logto/connector-types';
export { ConnectorType, ConnectorPlatform } from '@logto/connector-types';
export interface ConnectorDTO extends Connector {
metadata: ConnectorMetadata;

View file

@ -1,8 +1,11 @@
create table connectors (
id varchar(128) not null,
type varchar(64) not null,
target varchar(64) not null,
platform varchar(64),
enabled boolean not null default FALSE,
config jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
created_at timestamptz not null default(now()),
primary key (id)
);
create index connectors__target_platform on connectors (target, platform);

View file

@ -7,38 +7,43 @@ export const appLogo = 'https://avatars.githubusercontent.com/u/88327661?s=200&v
export const appHeadline = 'Build user identity in a modern way';
export const socialConnectors = [
{
id: 'github',
target: 'github',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: {
en: 'Sign in with GitHub',
'zh-CN': '使用 GitHub 登录',
},
},
{
id: 'alipay',
target: 'alipay',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: {
en: 'Sign in with Alipay',
'zh-CN': '使用 Alipay 登录',
},
},
{
id: 'wechat',
target: 'wechat',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: {
en: 'Sign in with WeChat',
'zh-CN': '使用 WeChat 登录',
},
},
{
id: 'google',
target: 'google',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: {
en: 'Sign in with Google',
'zh-CN': '使用 Google 登录',
},
},
{
id: 'facebook',
target: 'facebook',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: {
en: 'Sign in with Meta',
'zh-CN': '使用 Meta 登录',
},
},
];

View file

@ -6,16 +6,16 @@ import * as styles from './SocialIconButton.module.scss';
type Props = {
className?: string;
connector: Pick<ConnectorMetadata, 'id' | 'logo'>;
connector: Pick<ConnectorMetadata, 'target' | 'logo'>;
onClick?: () => void;
};
const SocialIconButton = ({ className, connector, onClick }: Props) => {
const { id, logo } = connector;
const { target, logo } = connector;
return (
<button className={classNames(styles.socialButton, className)} onClick={onClick}>
{logo && <img src={logo} alt={id} className={styles.icon} />}
{logo && <img src={logo} alt={target} className={styles.icon} />}
</button>
);
};

View file

@ -9,17 +9,19 @@ import * as styles from './index.module.scss';
export type Props = {
isDisabled?: boolean;
className?: string;
connector: Pick<ConnectorMetadata, 'id' | 'name' | 'logo'>;
connector: Pick<ConnectorMetadata, 'target' | 'name' | 'logo'>;
onClick?: (id: string) => void;
};
const SocialLinkButton = ({ isDisabled, className, connector, onClick }: Props) => {
const { id, name, logo } = connector;
const { target, name, logo } = connector;
const {
i18n: { language },
} = useTranslation();
const localName = name[language] ?? name.en;
// TODO: LOG-2393 should fix name[locale] syntax error
const foundName = Object.entries(name).find(([lang]) => lang === language);
const localName = foundName ? foundName[1] : name.en;
return (
<button
@ -32,7 +34,7 @@ const SocialLinkButton = ({ isDisabled, className, connector, onClick }: Props)
)}
type="button"
onClick={() => {
onClick?.(id);
onClick?.(target);
}}
>
{logo && <img src={logo} alt={localName} className={socialLinkButtonStyles.icon} />}

View file

@ -25,9 +25,10 @@ describe('Button Component', () => {
it('render SocialLinkButton', () => {
const connector = {
id: 'foo',
target: 'foo',
name: {
en: 'Sign in with Logto',
'zh-CN': '使用 Logto 登录',
},
logo: 'http://logto.dev/logto.png',
};

View file

@ -33,11 +33,11 @@ const PrimarySocialSignIn = ({ className, isPopup = false, onSocialSignInCallbac
<div className={classNames(styles.socialLinkList, className)}>
{displayConnectors.map((connector) => (
<SocialLinkButton
key={connector.id}
key={connector.target}
className={styles.socialLinkButton}
connector={connector}
onClick={() => {
void invokeSocialSignIn(connector.id, onSocialSignInCallback);
void invokeSocialSignIn(connector.target, onSocialSignInCallback);
}}
/>
))}

View file

@ -28,7 +28,7 @@ describe('SecondarySocialSignIn', () => {
platform: 'web',
getPostMessage: jest.fn(() => jest.fn()),
callbackLink: '/logto:',
supportedSocialConnectorIds: socialConnectors.map(({ id }) => id),
supportedSocialConnectorIds: socialConnectors.map(({ target }) => target),
};
/* eslint-enable @silverhand/fp/no-mutation */
});

View file

@ -32,11 +32,11 @@ const SecondarySocialSignIn = ({ className }: Props) => {
<div className={classNames(styles.socialIconList, className)}>
{displayConnectors.map((connector) => (
<SocialIconButton
key={connector.id}
key={connector.target}
className={styles.socialButton}
connector={connector}
onClick={() => {
void invokeSocialSignIn(connector.id);
void invokeSocialSignIn(connector.target);
}}
/>
))}

View file

@ -90,8 +90,10 @@ const useSocial = () => {
// Filter native supported social connectors
const socialConnectors = useMemo(
() =>
(experienceSettings?.socialConnectors ?? []).filter(({ id }) => {
return !isNativeWebview() || getLogtoNativeSdk()?.supportedSocialConnectorIds.includes(id);
(experienceSettings?.socialConnectors ?? []).filter(({ target }) => {
return (
!isNativeWebview() || getLogtoNativeSdk()?.supportedSocialConnectorIds.includes(target)
);
}),
[experienceSettings?.socialConnectors]
);

View file

@ -19,7 +19,9 @@ const Callback = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const connectorLabel = useMemo(() => {
const connector = experienceSettings?.socialConnectors.find(({ id }) => id === connectorId);
const connector = experienceSettings?.socialConnectors.find(
({ target }) => target === connectorId
);
if (connector) {
return (

View file

@ -8,7 +8,7 @@ export enum SearchParameters {
bindWithSocial = 'bw',
}
type ConnectorData = Pick<ConnectorMetadata, 'id' | 'logo' | 'name'>;
type ConnectorData = Pick<ConnectorMetadata, 'target' | 'logo' | 'name'>;
export type SignInExperienceSettings = {
branding: Branding;

1896
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff