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

Merge pull request #2547 from logto-io/sijie-remove-connector-enable

refactor: remove connector enabled state in database
This commit is contained in:
wangsijie 2022-11-30 12:00:23 +08:00 committed by GitHub
commit 63f9ec57eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 392 additions and 701 deletions

View file

@ -1,10 +1,9 @@
import type { ConnectorResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { useMemo } from 'react';
import useSWR from 'swr';
import type { RequestError } from '@/hooks/use-api';
import type { ConnectorGroup } from '@/types/connector';
import { getConnectorGroups } from '@/pages/Connectors/utils';
// Group connectors by target
const useConnectorGroups = () => {
@ -15,42 +14,7 @@ const useConnectorGroups = () => {
return;
}
return data.reduce<ConnectorGroup[]>((previous, item) => {
const groupIndex = previous.findIndex(
// Only group social connectors
({ target }) => target === item.target && item.type === ConnectorType.Social
);
if (groupIndex === -1) {
return [
...previous,
{
id: item.id, // Take first connector's id as groupId, only used for indexing.
name: item.name,
logo: item.logo,
logoDark: item.logoDark,
description: item.description,
target: item.target,
type: item.type,
enabled: item.enabled,
connectors: [item],
},
];
}
return previous.map((group, index) => {
if (index !== groupIndex) {
return group;
}
return {
...group,
connectors: [...group.connectors, item],
// Group is enabled when any of its connectors is enabled.
enabled: group.enabled || item.enabled,
};
});
}, []);
return getConnectorGroups(data);
}, [data]);
return {

View file

@ -8,7 +8,7 @@ const useEnabledConnectorTypes = () => {
const { data: connectors } = useSWR<ConnectorResponse[], RequestError>('/api/connectors');
const enabledConnectorTypes = useMemo(
() => connectors?.filter(({ enabled }) => enabled).map(({ type }) => type) ?? [],
() => connectors?.map(({ type }) => type) ?? [],
[connectors]
);

View file

@ -1,7 +1,6 @@
import type { ConnectorResponse } from '@logto/schemas';
import { ConnectorPlatform } from '@logto/schemas';
import classNames from 'classnames';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
@ -18,15 +17,11 @@ type Props = {
const ConnectorTabs = ({ target, connectorId }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data } = useSWR<ConnectorResponse[]>(`/api/connectors?target=${target}`);
const { data: connectors } = useSWR<ConnectorResponse[]>(`/api/connectors?target=${target}`);
const connectors = useMemo(() => {
if (!data) {
return [];
}
return data.filter(({ enabled }) => enabled);
}, [data]);
if (!connectors) {
return null;
}
if (connectors.length === 0) {
return null;

View file

@ -67,11 +67,7 @@ const ConnectorDetails = () => {
return;
}
await api
.patch(`/api/connectors/${connectorId}/enabled`, {
json: { enabled: false },
})
.json<ConnectorResponse>();
await api.delete(`/api/connectors/${connectorId}`).json<ConnectorResponse>();
setIsDeleted(true);

View file

@ -24,8 +24,7 @@ type Props = {
const ConnectorName = ({ type, connectors, onClickSetup }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const enabledConnectors = connectors.filter(({ enabled }) => enabled);
const connector = enabledConnectors[0];
const connector = connectors[0];
const theme = useTheme();
if (!connector) {
@ -59,7 +58,7 @@ const ConnectorName = ({ type, connectors, onClickSetup }: Props) => {
{type !== ConnectorType.Social && connector.id}
{type === ConnectorType.Social && connectors.length > 1 && (
<div className={styles.platforms}>
{enabledConnectors.map(
{connectors.map(
({ id, platform }) =>
platform && (
<div key={id} className={styles.platform}>

View file

@ -19,17 +19,17 @@ type Props = {
const ConnectorRow = ({ type, connectors, onClickSetup }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const firstEnabledConnector = connectors.find(({ enabled }) => enabled);
const inUse = useConnectorInUse(type, firstEnabledConnector?.target);
const firstConnector = connectors[0];
const inUse = useConnectorInUse(type, firstConnector?.target);
const navigate = useNavigate();
const showSetupButton = type !== ConnectorType.Social && !firstEnabledConnector;
const showSetupButton = type !== ConnectorType.Social && !firstConnector;
const handleClickRow = () => {
if (showSetupButton || !firstEnabledConnector) {
if (showSetupButton || !firstConnector) {
return;
}
navigate(`/connectors/${firstEnabledConnector.id}`);
navigate(`/connectors/${firstConnector.id}`);
};
return (

View file

@ -1,3 +1,4 @@
import type { ConnectorFactoryResponse } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import RadioGroup, { Radio } from '@/components/RadioGroup';
@ -8,7 +9,7 @@ import type { ConnectorGroup } from '@/types/connector';
import * as styles from './PlatformSelector.module.scss';
type Props = {
connectorGroup: ConnectorGroup;
connectorGroup: ConnectorGroup<ConnectorFactoryResponse & { added: boolean }>;
connectorId?: string;
onConnectorIdChange: (value: string) => void;
};
@ -28,13 +29,13 @@ const PlatformSelector = ({ connectorGroup, connectorId, onConnectorIdChange }:
</div>
<RadioGroup type="plain" name="connector" value={connectorId} onChange={onConnectorIdChange}>
{connectorGroup.connectors.map(
({ platform, id, enabled }) =>
({ platform, id, added }) =>
platform && (
<Radio
key={id}
value={id}
title={connectorPlatformLabel[platform]}
isDisabled={enabled}
isDisabled={added}
/>
)
)}

View file

@ -1,15 +1,18 @@
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import classNames from 'classnames';
import { useMemo, useState } from 'react';
import Modal from 'react-modal';
import useSWR from 'swr';
import Button from '@/components/Button';
import ModalLayout from '@/components/ModalLayout';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import UnnamedTrans from '@/components/UnnamedTrans';
import useConnectorGroups from '@/hooks/use-connector-groups';
import type { RequestError } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { getConnectorGroups } from '../../utils';
import Guide from '../Guide';
import PlatformSelector from './PlatformSelector';
import * as styles from './index.module.scss';
@ -21,25 +24,45 @@ type Props = {
};
const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
const { data: allGroups, connectors, error } = useConnectorGroups();
const isLoading = !allGroups && !connectors && !error;
const { data: existingConnectors, error: connectorsError } = useSWR<
ConnectorResponse[],
RequestError
>('/api/connectors');
const { data: factories, error: factoriesError } = useSWR<
ConnectorFactoryResponse[],
RequestError
>('/api/connector-factories');
const isLoading = !factories && !existingConnectors && !connectorsError && !factoriesError;
const [activeGroupId, setActiveGroupId] = useState<string>();
const [activeConnectorId, setActiveConnectorId] = useState<string>();
const [activeFactoryId, setActiveFactoryId] = useState<string>();
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
const groups = useMemo(
() => allGroups?.filter((group) => group.type === type),
[allGroups, type]
);
const groups = useMemo(() => {
if (!factories || !existingConnectors) {
return [];
}
const allGroups = getConnectorGroups<ConnectorFactoryResponse>(
factories.filter(({ type: factoryType }) => factoryType === type)
);
return allGroups.map((group) => ({
...group,
connectors: group.connectors.map((connector) => ({
...connector,
added: existingConnectors.some(({ connectorId }) => connector.id === connectorId),
})),
}));
}, [factories, type, existingConnectors]);
const activeGroup = useMemo(
() => groups?.find(({ id }) => id === activeGroupId),
() => groups.find(({ id }) => id === activeGroupId),
[activeGroupId, groups]
);
const activeConnector = useMemo(
() => connectors?.find(({ id }) => id === activeConnectorId),
[activeConnectorId, connectors]
const activeFactory = useMemo(
() => factories?.find(({ id }) => id === activeFactoryId),
[activeFactoryId, factories]
);
const cardTitle = useMemo(() => {
@ -55,10 +78,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
}, [type]);
const handleGroupChange = (groupId: string) => {
if (!groups) {
return;
}
setActiveGroupId(groupId);
const group = groups.find(({ id }) => id === groupId);
@ -67,20 +86,20 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
return;
}
const firstAvailableConnector = group.connectors.find(({ enabled }) => !enabled);
const firstAvailableConnector = group.connectors.find(({ added }) => !added);
setActiveConnectorId(firstAvailableConnector?.id);
setActiveFactoryId(firstAvailableConnector?.id);
};
const closeModal = () => {
setIsGetStartedModalOpen(false);
onClose?.(activeConnectorId);
onClose?.(activeFactoryId);
setActiveGroupId(undefined);
setActiveConnectorId(undefined);
setActiveFactoryId(undefined);
};
const modalSize = useMemo(() => {
if (!groups || groups.length <= 2) {
if (groups.length <= 2) {
return 'medium';
}
@ -103,7 +122,7 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
<Button
title="general.next"
type="primary"
disabled={!activeConnectorId}
disabled={!activeFactoryId}
onClick={() => {
setIsGetStartedModalOpen(true);
}}
@ -114,53 +133,48 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
onClose={onClose}
>
{isLoading && 'Loading...'}
{error?.message}
{groups && (
<RadioGroup
name="group"
value={activeGroupId}
type="card"
className={classNames(styles.connectorGroup, styles[modalSize])}
onChange={handleGroupChange}
>
{groups.map(({ id, name, logo, description, connectors }) => {
const isDisabled = connectors.every(({ enabled }) => enabled);
{factoriesError?.message ?? connectorsError?.message}
<RadioGroup
name="group"
value={activeGroupId}
type="card"
className={classNames(styles.connectorGroup, styles[modalSize])}
onChange={handleGroupChange}
>
{groups.map(({ id, name, logo, description, connectors }) => {
const isDisabled = connectors.every(({ added }) => added);
return (
<Radio key={id} value={id} isDisabled={isDisabled} disabledLabel="general.added">
<div className={styles.connector}>
<div className={styles.logo}>
<img src={logo} alt="logo" />
return (
<Radio key={id} value={id} isDisabled={isDisabled} disabledLabel="general.added">
<div className={styles.connector}>
<div className={styles.logo}>
<img src={logo} alt="logo" />
</div>
<div className={styles.content}>
<div
className={classNames(styles.name, isDisabled && styles.nameWithRightPadding)}
>
<UnnamedTrans resource={name} />
</div>
<div className={styles.content}>
<div
className={classNames(
styles.name,
isDisabled && styles.nameWithRightPadding
)}
>
<UnnamedTrans resource={name} />
</div>
<div className={styles.description}>
<UnnamedTrans resource={description} />
</div>
<div className={styles.description}>
<UnnamedTrans resource={description} />
</div>
</div>
</Radio>
);
})}
</RadioGroup>
)}
</div>
</Radio>
);
})}
</RadioGroup>
{activeGroup && (
<PlatformSelector
connectorGroup={activeGroup}
connectorId={activeConnectorId}
onConnectorIdChange={setActiveConnectorId}
connectorId={activeFactoryId}
onConnectorIdChange={setActiveFactoryId}
/>
)}
{activeConnector && (
{activeFactory && (
<Modal isOpen={isGetStartedModalOpen} className={modalStyles.fullScreen}>
<Guide connector={activeConnector} onClose={closeModal} />
<Guide connector={activeFactory} onClose={closeModal} />
</Modal>
)}
</ModalLayout>

View file

@ -1,5 +1,5 @@
import { isLanguageTag } from '@logto/language-kit';
import type { ConnectorResponse } from '@logto/schemas';
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import i18next from 'i18next';
@ -23,7 +23,7 @@ import { safeParseJson } from '@/utilities/json';
import * as styles from './index.module.scss';
type Props = {
connector: ConnectorResponse;
connector: ConnectorFactoryResponse;
onClose: () => void;
};
@ -57,11 +57,10 @@ const Guide = ({ connector, onClose }: Props) => {
return;
}
const { id: connectorId } = connector;
await api
.patch(`/api/connectors/${connectorId}`, { json: { config: result.data } })
.json<ConnectorResponse>();
await api
.patch(`/api/connectors/${connectorId}/enabled`, { json: { enabled: true } })
.post('/api/connectors', { json: { config: result.data, connectorId } })
.json<ConnectorResponse>();
await updateSettings({

View file

@ -16,13 +16,7 @@ const SignInExperienceSetupNotice = () => {
update,
} = useUserPreferences();
if (!connectors || connectorSieNoticeConfirmed) {
return null;
}
const hasSetupConnector = connectors.some(({ enabled }) => enabled);
if (!hasSetupConnector) {
if (!connectors || connectors.length === 0 || connectorSieNoticeConfirmed) {
return null;
}

View file

@ -35,17 +35,13 @@ const Connectors = () => {
const isLightMode = theme === AppearanceMode.LightMode;
const emailConnector = useMemo(() => {
const emailConnectorGroup = data?.find(
({ enabled, type }) => enabled && type === ConnectorType.Email
);
const emailConnectorGroup = data?.find(({ type }) => type === ConnectorType.Email);
return emailConnectorGroup?.connectors[0];
}, [data]);
const smsConnector = useMemo(() => {
const smsConnectorGroup = data?.find(
({ enabled, type }) => enabled && type === ConnectorType.Sms
);
const smsConnectorGroup = data?.find(({ type }) => type === ConnectorType.Sms);
return smsConnectorGroup?.connectors[0];
}, [data]);
@ -55,7 +51,7 @@ const Connectors = () => {
return;
}
return data?.filter(({ enabled, type }) => enabled && type === ConnectorType.Social);
return data?.filter(({ type }) => type === ConnectorType.Social);
}, [data, isSocial]);
return (

View file

@ -0,0 +1,44 @@
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import type { ConnectorGroup } from '@/types/connector';
export const getConnectorGroups = <
T extends ConnectorResponse | ConnectorFactoryResponse = ConnectorResponse
>(
connectors: T[]
) => {
return connectors.reduce<Array<ConnectorGroup<T>>>((previous, item) => {
const groupIndex = previous.findIndex(
// Only group social connectors
({ target }) => target === item.target && item.type === ConnectorType.Social
);
if (groupIndex === -1) {
return [
...previous,
{
id: item.id, // Take first connector's id as groupId, only used for indexing.
name: item.name,
logo: item.logo,
logoDark: item.logoDark,
description: item.description,
target: item.target,
type: item.type,
connectors: [item],
},
];
}
return previous.map((group, index) => {
if (index !== groupIndex) {
return group;
}
return {
...group,
connectors: [...group.connectors, item],
};
});
}, []);
};

View file

@ -88,18 +88,14 @@ const Preview = ({ signInExperience, className }: Props) => {
>(
(previous, connectorTarget) => [
...previous,
...allConnectors.filter(({ target, enabled }) => target === connectorTarget && enabled),
...allConnectors.filter(({ target }) => target === connectorTarget),
],
[]
);
const hasEmailConnector = allConnectors.some(
({ type, enabled }) => enabled && type === ConnectorType.Email
);
const hasEmailConnector = allConnectors.some(({ type }) => type === ConnectorType.Email);
const hasSmsConnector = allConnectors.some(
({ type, enabled }) => enabled && type === ConnectorType.Sms
);
const hasSmsConnector = allConnectors.some(({ type }) => type === ConnectorType.Sms);
return {
signInExperience: {

View file

@ -54,13 +54,11 @@ const AddButton = ({ options, onSelected, hasSelectedConnectors }: Props) => {
<img src={logo} alt={target} className={styles.logo} />
<UnnamedTrans resource={name} className={styles.name} />
{connectors.length > 1 &&
connectors
.filter(({ enabled }) => enabled)
.map(({ platform }) => (
<div key={platform} className={styles.icon}>
{platform && <ConnectorPlatformIcon platform={platform} />}
</div>
))}
connectors.map(({ platform }) => (
<div key={platform} className={styles.icon}>
{platform && <ConnectorPlatformIcon platform={platform} />}
</div>
))}
</div>
</DropdownItem>
))}

View file

@ -20,13 +20,11 @@ const SelectedConnectorItem = ({ data: { logo, target, name, connectors }, onDel
<img src={logo} alt={target} className={styles.logo} />
<UnnamedTrans resource={name} className={styles.name} />
{connectors.length > 1 &&
connectors
.filter(({ enabled }) => enabled)
.map(({ platform }) => (
<div key={platform} className={styles.icon}>
{platform && <ConnectorPlatformIcon platform={platform} />}
</div>
))}
connectors.map(({ platform }) => (
<div key={platform} className={styles.icon}>
{platform && <ConnectorPlatformIcon platform={platform} />}
</div>
))}
</div>
<IconButton
onClick={() => {

View file

@ -54,8 +54,7 @@ const SocialConnectorEditBox = ({ value, onChange }: Props) => {
.filter((item): item is ConnectorGroup => Boolean(item));
const connectorOptions = connectorData.filter(
({ target, type, enabled }) =>
!value.includes(target) && type === ConnectorType.Social && enabled
({ target, type }) => !value.includes(target) && type === ConnectorType.Social
);
return (

View file

@ -1,15 +1,18 @@
import type { Identities, ConnectorResponse } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import { useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Button from '@/components/Button';
import CopyToClipboard from '@/components/CopyToClipboard';
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
import TableError from '@/components/Table/TableError';
import UnnamedTrans from '@/components/UnnamedTrans';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useConnectorGroups from '@/hooks/use-connector-groups';
import { getConnectorGroups } from '@/pages/Connectors/utils';
import * as styles from './UserConnectors.module.scss';
@ -24,8 +27,9 @@ type DisplayConnector = Pick<ConnectorResponse, 'target' | 'logo' | 'name'> & {
const UserConnectors = ({ userId, connectors, onDelete }: Props) => {
const api = useApi();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: connectorGroups, error, mutate } = useConnectorGroups();
const { data, error, mutate } = useSWR<ConnectorResponse[], RequestError>('/api/connectors');
const [deletingConnector, setDeletingConnector] = useState<DisplayConnector>();
const connectorGroups = conditional(data && getConnectorGroups(data));
const isLoading = !connectorGroups && !error;
const [isSubmitting, setIsSubmitting] = useState(false);

View file

@ -1,10 +1,9 @@
import type { ConnectorResponse } from '@logto/schemas';
export type ConnectorGroup = Pick<
export type ConnectorGroup<T = ConnectorResponse> = Pick<
ConnectorResponse,
'name' | 'logo' | 'logoDark' | 'target' | 'type' | 'description'
> & {
id: string;
enabled: boolean;
connectors: ConnectorResponse[];
connectors: T[];
};

View file

@ -77,7 +77,6 @@ export const mockMetadata6: ConnectorMetadata = {
export const mockConnector0: Connector = {
id: 'id0',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
@ -87,7 +86,6 @@ export const mockConnector0: Connector = {
export const mockConnector1: Connector = {
id: 'id1',
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
syncProfile: false,
@ -97,7 +95,6 @@ export const mockConnector1: Connector = {
export const mockConnector2: Connector = {
id: 'id2',
enabled: true,
config: {},
createdAt: 1_234_567_890_345,
syncProfile: false,
@ -107,7 +104,6 @@ export const mockConnector2: Connector = {
export const mockConnector3: Connector = {
id: 'id3',
enabled: true,
config: {},
createdAt: 1_234_567_890_456,
syncProfile: false,
@ -117,7 +113,6 @@ export const mockConnector3: Connector = {
export const mockConnector4: Connector = {
id: 'id4',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
syncProfile: false,
@ -127,7 +122,6 @@ export const mockConnector4: Connector = {
export const mockConnector5: Connector = {
id: 'id5',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
syncProfile: false,
@ -137,7 +131,6 @@ export const mockConnector5: Connector = {
export const mockConnector6: Connector = {
id: 'id6',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
syncProfile: false,

View file

@ -33,7 +33,6 @@ export {
export const mockConnector: Connector = {
id: 'id',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
@ -205,7 +204,6 @@ export const mockGoogleConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'google',
enabled: false,
},
metadata: {
...mockMetadata,
@ -227,15 +225,13 @@ export const mockLogtoConnectors = [
mockWechatNativeConnector,
];
export const disabledSocialTarget01 = 'disableSocialTarget-id01';
export const disabledSocialTarget02 = 'disableSocialTarget-id02';
export const enabledSocialTarget01 = 'enabledSocialTarget-id01';
export const socialTarget01 = 'socialTarget-id01';
export const socialTarget02 = 'socialTarget-id02';
export const mockSocialConnectors: LogtoConnector[] = [
{
dbEntry: {
id: 'id0',
enabled: false,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
@ -244,7 +240,7 @@ export const mockSocialConnectors: LogtoConnector[] = [
},
metadata: {
...mockMetadata,
target: disabledSocialTarget01,
target: socialTarget01,
},
type: ConnectorType.Social,
...mockLogtoConnector,
@ -252,7 +248,6 @@ export const mockSocialConnectors: LogtoConnector[] = [
{
dbEntry: {
id: 'id1',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
@ -261,24 +256,7 @@ export const mockSocialConnectors: LogtoConnector[] = [
},
metadata: {
...mockMetadata,
target: enabledSocialTarget01,
},
type: ConnectorType.Social,
...mockLogtoConnector,
},
{
dbEntry: {
id: 'id2',
enabled: false,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
metadata: {},
connectorId: 'id2',
},
metadata: {
...mockMetadata,
target: disabledSocialTarget02,
target: socialTarget02,
},
type: ConnectorType.Social,
...mockLogtoConnector,

View file

@ -10,12 +10,12 @@ import { findPackage } from '@logto/shared';
import chalk from 'chalk';
import RequestError from '#src/errors/RequestError/index.js';
import { findAllConnectors, insertConnector } from '#src/queries/connector.js';
import { findAllConnectors } from '#src/queries/connector.js';
import { defaultConnectorMethods } from './consts.js';
import { metaUrl } from './meta-url.js';
import type { ConnectorFactory, LogtoConnector } from './types.js';
import { getConnectorConfig, readUrl, validateConnectorModule } from './utilities/index.js';
import { getConnectorConfig, parseMetadata, validateConnectorModule } from './utilities/index.js';
const currentDirname = path.dirname(fileURLToPath(metaUrl));
@ -52,7 +52,7 @@ export const loadConnectorFactories = async () => {
validateConnectorModule(rawConnector);
return {
metadata: rawConnector.metadata,
metadata: await parseMetadata(rawConnector.metadata, packagePath),
type: rawConnector.type,
createConnector,
path: packagePath,
@ -106,22 +106,13 @@ export const getLogtoConnectors = async (): Promise<LogtoConnector[]> => {
},
});
validateConnectorModule(rawConnector);
const rawMetadata = await parseMetadata(rawConnector.metadata, packagePath);
const connector: AllConnector = {
...defaultConnectorMethods,
...rawConnector,
metadata: {
...rawConnector.metadata,
logo: await readUrl(rawConnector.metadata.logo, packagePath, 'svg'),
logoDark:
rawConnector.metadata.logoDark &&
(await readUrl(rawConnector.metadata.logoDark, packagePath, 'svg')),
readme: await readUrl(rawConnector.metadata.readme, packagePath, 'text'),
configTemplate: await readUrl(
rawConnector.metadata.configTemplate,
packagePath,
'text'
),
...rawMetadata,
...metadata,
},
};
@ -156,29 +147,3 @@ export const getLogtoConnectorById = async (id: string): Promise<LogtoConnector>
return pickedConnector;
};
export const initConnectors = async () => {
const connectors = await findAllConnectors();
const existingConnectors = new Map(
connectors.map((connector) => [connector.connectorId, connector])
);
const allConnectors = await loadConnectorFactories();
const newConnectors = allConnectors.filter(({ metadata: { id } }) => {
const connector = existingConnectors.get(id);
if (!connector) {
return true;
}
return connector.config === JSON.stringify({});
});
await Promise.all(
newConnectors.map(async ({ metadata: { id } }) => {
await insertConnector({
id,
connectorId: id,
});
})
);
};

View file

@ -7,7 +7,6 @@ import { getConnectorConfig } from './index.js';
const connectors: Connector[] = [
{
id: 'id',
enabled: true,
config: { foo: 'bar' },
createdAt: 0,
syncProfile: false,

View file

@ -2,7 +2,7 @@ import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import path from 'path';
import type { BaseConnector } from '@logto/connector-kit';
import type { AllConnector, BaseConnector } from '@logto/connector-kit';
import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit';
import RequestError from '#src/errors/RequestError/index.js';
@ -59,3 +59,13 @@ export const readUrl = async (
return readFile(path.join(baseUrl, url), 'utf8');
};
export const parseMetadata = async (metadata: AllConnector['metadata'], packagePath: string) => {
return {
...metadata,
logo: await readUrl(metadata.logo, packagePath, 'svg'),
logoDark: metadata.logoDark && (await readUrl(metadata.logoDark, packagePath, 'svg')),
readme: await readUrl(metadata.readme, packagePath, 'text'),
configTemplate: await readUrl(metadata.configTemplate, packagePath, 'text'),
};
};

View file

@ -1,7 +1,6 @@
import Koa from 'koa';
import initApp from './app/init.js';
import { initConnectors } from './connectors/index.js';
import { configDotEnv } from './env-set/dot-env.js';
import envSet from './env-set/index.js';
import initI18n from './i18n/init.js';
@ -15,7 +14,6 @@ import initI18n from './i18n/init.js';
const app = new Koa({
proxy: envSet.values.trustProxyHeader,
});
await initConnectors();
await initI18n();
await initApp(app);
} catch (error: unknown) {

View file

@ -53,7 +53,7 @@ export const sendPasscode = async (passcode: Passcode) => {
const connector = connectors.find(
(connector): connector is LogtoConnector<SmsConnector | EmailConnector> =>
connector.dbEntry.enabled && connector.type === expectType
connector.type === expectType
);
assertThat(

View file

@ -4,9 +4,8 @@ import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import {
disabledSocialTarget01,
disabledSocialTarget02,
enabledSocialTarget01,
socialTarget01,
socialTarget02,
mockBranding,
mockSignInExperience,
mockSocialConnectors,
@ -165,14 +164,10 @@ describe('remove unavailable social connector targets', () => {
socialSignInConnectorTargets: mockSocialConnectorTargets,
});
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockSocialConnectors);
expect(mockSocialConnectorTargets).toEqual([
disabledSocialTarget01,
enabledSocialTarget01,
disabledSocialTarget02,
]);
expect(mockSocialConnectorTargets).toEqual([socialTarget01, socialTarget02]);
await removeUnavailableSocialConnectorTargets();
expect(updateDefaultSignInExperience).toBeCalledWith({
socialSignInConnectorTargets: [enabledSocialTarget01],
socialSignInConnectorTargets: [socialTarget01, socialTarget02],
});
});
});

View file

@ -52,7 +52,7 @@ export const removeUnavailableSocialConnectorTargets = async () => {
const connectors = await getLogtoConnectors();
const availableSocialConnectorTargets = new Set(
connectors
.filter(({ type, dbEntry: { enabled } }) => enabled && type === ConnectorType.Social)
.filter(({ type }) => type === ConnectorType.Social)
.map(({ metadata: { target } }) => target)
);

View file

@ -65,8 +65,8 @@ describe('validate sign-in', () => {
});
});
describe('There must be at least one enabled connector for the specific identifier.', () => {
it('throws when there is no enabled email connector and identifiers includes email with verification code checked', () => {
describe('There must be at least one connector for the specific identifier.', () => {
it('throws when there is no email connector and identifiers includes email with verification code checked', () => {
expect(() => {
validateSignIn(
{
@ -89,7 +89,7 @@ describe('validate sign-in', () => {
);
});
it('throws when there is no enabled sms connector and identifiers includes phone with verification code checked', () => {
it('throws when there is no sms connector and identifiers includes phone with verification code checked', () => {
expect(() => {
validateSignIn(
{

View file

@ -13,8 +13,8 @@ jest.mock('#src/lib/session.js', () => ({
}));
describe('validate sign-up', () => {
describe('There must be at least one enabled connector for the specific identifier.', () => {
test('should throw when there is no enabled email connector and identifier is email', async () => {
describe('There must be at least one connector for the specific identifier.', () => {
test('should throw when there is no email connector and identifier is email', async () => {
expect(() => {
validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.Email }, []);
}).toMatchError(
@ -25,7 +25,7 @@ describe('validate sign-up', () => {
);
});
test('should throw when there is no enabled email connector and identifier is email or phone', async () => {
test('should throw when there is no email connector and identifier is email or phone', async () => {
expect(() => {
validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.EmailOrSms }, []);
}).toMatchError(
@ -36,7 +36,7 @@ describe('validate sign-up', () => {
);
});
test('should throw when there is no enabled sms connector and identifier is phone', async () => {
test('should throw when there is no sms connector and identifier is phone', async () => {
expect(() => {
validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.Sms }, []);
}).toMatchError(
@ -47,7 +47,7 @@ describe('validate sign-up', () => {
);
});
test('should throw when there is no enabled email connector and identifier is email or phone', async () => {
test('should throw when there is no email connector and identifier is email or phone', async () => {
expect(() => {
validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.EmailOrSms }, [
mockAliyunDmConnector,

View file

@ -36,7 +36,7 @@ describe('connector queries', () => {
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
order by ${fields.enabled} desc, ${fields.id} asc
order by ${fields.id} asc
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
@ -172,8 +172,8 @@ describe('connector queries', () => {
};
const expectSql = `
insert into "connectors" ("id", "enabled", "sync_profile", "connector_id", "config", "metadata")
values ($1, $2, $3, $4, $5, $6)
insert into "connectors" ("id", "sync_profile", "connector_id", "config", "metadata")
values ($1, $2, $3, $4, $5)
returning *
`;
@ -182,7 +182,6 @@ describe('connector queries', () => {
expect(values).toEqual([
connector.id,
connector.enabled,
connector.syncProfile,
connector.connectorId,
connector.config,
@ -197,27 +196,27 @@ describe('connector queries', () => {
it('updateConnector (with id)', async () => {
const id = 'foo';
const enabled = false;
const syncProfile = false;
const expectSql = sql`
update ${table}
set ${fields.enabled}=$1
set ${fields.syncProfile}=$1
where ${fields.id}=$2
returning *
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([enabled, id]);
expect(values).toEqual([syncProfile, id]);
return createMockQueryResult([{ id, enabled }]);
return createMockQueryResult([{ id, syncProfile }]);
});
await expect(
updateConnector({ where: { id }, set: { enabled }, jsonbMode: 'merge' })
updateConnector({ where: { id }, set: { syncProfile }, jsonbMode: 'merge' })
).resolves.toEqual({
id,
enabled,
syncProfile,
});
});
});

View file

@ -15,7 +15,7 @@ export const findAllConnectors = async () =>
envSet.pool.query<Connector>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
order by ${fields.enabled} desc, ${fields.id} asc
order by ${fields.id} asc
`)
);

View file

@ -75,13 +75,13 @@ describe('connector route', () => {
jest.clearAllMocks();
});
it('throws if more than one email connector is enabled', async () => {
it('throws if more than one email connector exists', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('throws if more than one SMS connector is enabled', async () => {
it('throws if more than one SMS connector exists', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(
mockLogtoConnectorList.filter((connector) => connector.type !== ConnectorType.Email)
);

View file

@ -1,6 +1,6 @@
import { MessageTypes } from '@logto/connector-kit';
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
import type { ConnectorResponse } from '@logto/schemas';
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas';
import { buildIdGenerator } from '@logto/shared';
import { object, string } from 'zod';
@ -51,15 +51,11 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
const connectors = await getLogtoConnectors();
assertThat(
connectors.filter(
(connector) => connector.dbEntry.enabled && connector.type === ConnectorType.Email
).length <= 1,
connectors.filter((connector) => connector.type === ConnectorType.Email).length <= 1,
'connector.more_than_one_email'
);
assertThat(
connectors.filter(
(connector) => connector.dbEntry.enabled && connector.type === ConnectorType.Sms
).length <= 1,
connectors.filter((connector) => connector.type === ConnectorType.Sms).length <= 1,
'connector.more_than_one_sms'
);
@ -75,7 +71,13 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
router.get('/connector-factories', async (ctx, next) => {
const connectorFactories = await loadConnectorFactories();
ctx.body = connectorFactories.map(({ metadata, type }) => ({ type, ...metadata }));
const formatedFactories: ConnectorFactoryResponse[] = connectorFactories.map(
({ metadata, type }) => ({
type,
...metadata,
})
);
ctx.body = formatedFactories;
return next();
});
@ -152,68 +154,16 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
type === connectorFactory.type && id !== insertConnectorId
)
.map(({ dbEntry: { id } }) => id);
await deleteConnectorByIds(conflictingConnectorIds);
if (conflictingConnectorIds.length > 0) {
await deleteConnectorByIds(conflictingConnectorIds);
}
}
return next();
}
);
router.patch(
'/connectors/:id/enabled',
koaGuard({
params: object({ id: string().min(1) }),
body: Connectors.createGuard.pick({ enabled: true }),
}),
async (ctx, next) => {
const {
params: { id },
body: { enabled },
} = ctx.guard;
const {
type,
dbEntry: { config },
metadata,
validateConfig,
} = await getLogtoConnectorById(id);
if (enabled) {
validateConfig(config);
}
// Only allow one enabled connector for SMS and Email.
// disable other connectors before enable this one.
if (enabled && (type === ConnectorType.Sms || type === ConnectorType.Email)) {
const connectors = await getLogtoConnectors();
await Promise.all(
connectors
.filter(
({ dbEntry: { enabled }, type: currentType }) => type === currentType && enabled
)
.map(async ({ dbEntry: { id } }) =>
updateConnector({ set: { enabled: false }, where: { id }, jsonbMode: 'merge' })
)
);
}
const connector = await updateConnector({
set: { enabled },
where: { id },
jsonbMode: 'merge',
});
// Delete the social connector in the sign-in experience if it is disabled.
if (!enabled && type === ConnectorType.Social) {
await removeUnavailableSocialConnectorTargets();
}
ctx.body = { ...connector, metadata, type };
return next();
}
);
router.patch(
'/connectors/:id',
koaGuard({
@ -269,11 +219,10 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
} = ctx.guard;
const { phone, email, config } = body;
const logtoConnectors = await getLogtoConnectors();
const subject = phone ?? email;
assertThat(subject, new RequestError({ code: 'guard.invalid_input' }));
const connector = logtoConnectors.find(({ metadata: { id: currentId } }) => currentId === id);
const connector = await getLogtoConnectorById(id);
const expectType = phone ? ConnectorType.Sms : ConnectorType.Email;
assertThat(

View file

@ -55,189 +55,6 @@ jest.mock('#src/lib/sign-in-experience.js', () => ({
describe('connector PATCH routes', () => {
const connectorRequest = createRequester({ authedRoutes: connectorRoutes });
describe('PATCH /connectors/:id/enabled', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws if connector can not be found (locally)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList.slice(1));
const response = await connectorRequest
.patch('/connectors/findConnector/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 404);
});
it('throws if connector can not be found (remotely)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([]);
const response = await connectorRequest
.patch('/connectors/id0/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 404);
});
it('enables one of the social connectors (with valid config)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Social,
...mockLogtoConnector,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: true },
jsonbMode: 'merge',
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
type: ConnectorType.Social,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the social connectors (with invalid config)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Social,
...mockLogtoConnector,
validateConfig: () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 500);
});
it('disables one of the social connectors', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Social,
...mockLogtoConnector,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: false });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: false },
jsonbMode: 'merge',
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the email/sms connectors (with valid config)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList);
const mockedMetadata = {
...mockMetadata,
id: 'id1',
};
const mockedConnector = {
...mockConnector,
id: 'id1',
};
getLogtoConnectorByIdPlaceholder.mockResolvedValueOnce({
dbEntry: mockedConnector,
metadata: mockedMetadata,
type: ConnectorType.Sms,
...mockLogtoConnector,
});
const response = await connectorRequest
.patch('/connectors/id1/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 200);
expect(updateConnector).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { id: 'id1' },
set: { enabled: false },
jsonbMode: 'merge',
})
);
expect(updateConnector).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: { id: 'id5' },
set: { enabled: false },
jsonbMode: 'merge',
})
);
expect(updateConnector).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
where: { id: 'id1' },
set: { enabled: true },
jsonbMode: 'merge',
})
);
expect(response.body).toMatchObject({
metadata: mockedMetadata,
});
});
it('enables one of the email/sms connectors (with invalid config)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Sms,
...mockLogtoConnector,
validateConfig: () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 500);
});
it('disables one of the email/sms connectors', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Sms,
...mockLogtoConnector,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: false });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: false },
jsonbMode: 'merge',
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('PATCH /connectors/:id', () => {
afterEach(() => {
jest.clearAllMocks();

View file

@ -8,6 +8,7 @@ import { createRequester } from '#src/utils/test-utils.js';
import interactionRoutes, { verificationPrefix } from './index.js';
import { sendPasscodeToIdentifier } from './utils/passcode-validation.js';
// FIXME @Darcy: no more `enabled` for `connectors` table
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
const database = {
enabled: connectorId === 'social_enabled',
@ -125,15 +126,6 @@ describe('session -> interactionRoutes', () => {
expect(response.statusCode).toEqual(400);
});
it('throw error when connector is disabled', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_disabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when no social connector is found', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'others',

View file

@ -10,9 +10,6 @@ jest.mock('#src/lib/social.js', () => ({
jest.mock('#src/connectors.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({
dbEntry: {
enabled: true,
},
metadata: {
id: 'social',
},

View file

@ -15,7 +15,6 @@ export const createSocialAuthorizationUrl = async (payload: SocialAuthorizationU
const connector = await getLogtoConnectorById(connectorId);
assertThat(connector.dbEntry.enabled, 'connector.not_enabled');
assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type');
return connector.getAuthorizationUri({ state, redirectUri });

View file

@ -170,15 +170,6 @@ describe('session -> socialRoutes', () => {
expect(response.statusCode).toEqual(400);
});
it('throw error when connector is disabled', async () => {
const response = await sessionRequest.post(`${signInRoute}`).send({
connectorId: 'social_disabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when no social connector is found', async () => {
const response = await sessionRequest.post(`${signInRoute}`).send({
connectorId: 'others',

View file

@ -46,7 +46,6 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
const { connectorId, state, redirectUri } = ctx.guard.body;
assertThat(state && redirectUri, 'session.insufficient_info');
const connector = await getLogtoConnectorById(connectorId);
assertThat(connector.dbEntry.enabled, 'connector.not_enabled');
assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type');
const redirectTo = await connector.getAuthorizationUri({ state, redirectUri });
ctx.body = { redirectTo };

View file

@ -94,7 +94,7 @@ describe('PATCH /sign-in-exp', () => {
status: 200,
body: {
...mockSignInExperience,
socialSignInConnectorTargets: ['github', 'facebook'],
socialSignInConnectorTargets: ['github', 'facebook', 'google'],
},
});
});
@ -118,18 +118,12 @@ describe('PATCH /sign-in-exp', () => {
signUp: mockSignUp,
signIn: mockSignIn,
});
const connectors = [
mockFacebookConnector,
mockGithubConnector,
mockWechatConnector,
mockAliyunSmsConnector,
];
expect(validateBranding).toHaveBeenCalledWith(mockBranding);
expect(validateLanguageInfo).toHaveBeenCalledWith(mockLanguageInfo);
expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse);
expect(validateSignUp).toHaveBeenCalledWith(mockSignUp, connectors);
expect(validateSignIn).toHaveBeenCalledWith(mockSignIn, mockSignUp, connectors);
expect(validateSignUp).toHaveBeenCalledWith(mockSignUp, logtoConnectors);
expect(validateSignIn).toHaveBeenCalledWith(mockSignIn, mockSignUp, logtoConnectors);
expect(response).toMatchObject({
status: 200,

View file

@ -50,25 +50,24 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
}
const connectors = await getLogtoConnectors();
const enabledConnectors = connectors.filter(({ dbEntry: { enabled } }) => enabled);
// Remove unavailable connectors
const filteredSocialSignInConnectorTargets = socialSignInConnectorTargets?.filter((target) =>
enabledConnectors.some(
connectors.some(
(connector) =>
connector.metadata.target === target && connector.type === ConnectorType.Social
)
);
if (signUp) {
validateSignUp(signUp, enabledConnectors);
validateSignUp(signUp, connectors);
}
if (signIn && signUp) {
validateSignIn(signIn, signUp, enabledConnectors);
validateSignIn(signIn, signUp, connectors);
} else if (signIn) {
const signInExperience = await findDefaultSignInExperience();
validateSignIn(signIn, signInExperience.signUp, enabledConnectors);
validateSignIn(signIn, signInExperience.signUp, connectors);
}
ctx.body = await updateDefaultSignInExperience(
filteredSocialSignInConnectorTargets

View file

@ -22,12 +22,8 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
]);
const forgotPassword = {
sms: logtoConnectors.some(
({ type, dbEntry: { enabled } }) => type === ConnectorType.Sms && enabled
),
email: logtoConnectors.some(
({ type, dbEntry: { enabled } }) => type === ConnectorType.Email && enabled
),
sms: logtoConnectors.some(({ type }) => type === ConnectorType.Sms),
email: logtoConnectors.some(({ type }) => type === ConnectorType.Email),
};
const socialConnectors =
@ -37,8 +33,7 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
Array<ConnectorMetadata & { id: string }>
>((previous, connectorTarget) => {
const connectors = logtoConnectors.filter(
({ metadata: { target }, dbEntry: { enabled } }) =>
target === connectorTarget && enabled
({ metadata: { target } }) => target === connectorTarget
);
return [

View file

@ -1,4 +1,4 @@
import type { ConnectorResponse } from '@logto/schemas';
import type { Connector, ConnectorResponse } from '@logto/schemas';
import { authedAdminApi } from './api';
@ -15,7 +15,10 @@ export const postConnector = async (connectorId: string) =>
url: `connectors`,
json: { connectorId },
})
.json();
.json<Connector>();
export const deleteConnectorById = async (id: string) =>
authedAdminApi.delete({ url: `connectors/${id}` }).json();
export const updateConnectorConfig = async (connectorId: string, config: Record<string, unknown>) =>
authedAdminApi
@ -25,20 +28,6 @@ export const updateConnectorConfig = async (connectorId: string, config: Record<
})
.json<ConnectorResponse>();
export const enableConnector = async (connectorId: string) =>
updateConnectorEnabledProperty(connectorId, true);
export const disableConnector = async (connectorId: string) =>
updateConnectorEnabledProperty(connectorId, false);
const updateConnectorEnabledProperty = (connectorId: string, enabled: boolean) =>
authedAdminApi
.patch({
url: `connectors/${connectorId}/enabled`,
json: { enabled },
})
.json<ConnectorResponse>();
export const sendSmsTestMessage = async (
connectorId: string,
phone: string,

View file

@ -9,19 +9,14 @@ import {
createUser,
registerUserWithUsernameAndPassword,
signInWithPassword,
updateConnectorConfig,
enableConnector,
bindWithSocial,
getAuthWithSocial,
postConnector,
signInWithSocial,
updateSignInExperience,
} from '@/api';
import MockClient from '@/client';
import { generateUsername, generatePassword } from '@/utils';
import { mockSocialConnectorId } from './__mocks__/connectors-mock';
export const createUserByAdmin = (username?: string, password?: string, primaryEmail?: string) => {
return createUser({
username: username ?? generateUsername(),
@ -72,17 +67,6 @@ export const signIn = async ({ username, email, password }: SignInHelper) => {
assert(client.isAuthenticated, new Error('Sign in failed'));
};
export const setUpConnector = async (connectorId: string, config: Record<string, unknown>) => {
try {
await updateConnectorConfig(connectorId, config);
} catch {
await postConnector(connectorId);
await updateConnectorConfig(connectorId, config);
}
const connector = await enableConnector(connectorId);
assert(connector.enabled, new Error('Connector Setup Failed'));
};
export const setSignUpIdentifier = async (
identifier: SignUpIdentifier,
password = true,
@ -115,7 +99,7 @@ export const readPasscode = async (): Promise<PasscodeRecord> => {
return JSON.parse(content) as PasscodeRecord;
};
export const bindSocialToNewCreatedUser = async () => {
export const bindSocialToNewCreatedUser = async (connectorId: string) => {
const username = generateUsername();
const password = generatePassword();
@ -130,13 +114,10 @@ export const bindSocialToNewCreatedUser = async () => {
await client.initSession();
assert(client.interactionCookie, new Error('Session not found'));
await signInWithSocial(
{ state, connectorId: mockSocialConnectorId, redirectUri },
client.interactionCookie
);
await signInWithSocial({ state, connectorId, redirectUri }, client.interactionCookie);
const response = await getAuthWithSocial(
{ connectorId: mockSocialConnectorId, data: { state, redirectUri, code } },
{ connectorId, data: { state, redirectUri, code } },
client.interactionCookie
).catch((error: unknown) => error);
@ -152,7 +133,7 @@ export const bindSocialToNewCreatedUser = async () => {
interactionCookie: client.interactionCookie,
});
await bindWithSocial(mockSocialConnectorId, client.interactionCookie);
await bindWithSocial(connectorId, client.interactionCookie);
await client.processSession(redirectTo);

View file

@ -12,8 +12,10 @@ import {
deleteUser,
updateUserPassword,
deleteUserIdentity,
postConnector,
updateConnectorConfig,
} from '@/api';
import { createUserByAdmin, bindSocialToNewCreatedUser, setUpConnector } from '@/helpers';
import { createUserByAdmin, bindSocialToNewCreatedUser } from '@/helpers';
describe('admin console user management', () => {
it('should create user successfully', async () => {
@ -66,9 +68,10 @@ describe('admin console user management', () => {
});
it('should delete user identities successfully', async () => {
await setUpConnector(mockSocialConnectorId, mockSocialConnectorConfig);
const { id } = await postConnector(mockSocialConnectorId);
await updateConnectorConfig(id, mockSocialConnectorConfig);
const createdUserId = await bindSocialToNewCreatedUser();
const createdUserId = await bindSocialToNewCreatedUser(id);
const userInfo = await getUser(createdUserId);
expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget);

View file

@ -1,4 +1,3 @@
import { ConnectorType } from '@logto/schemas';
import { HTTPError } from 'got';
import {
@ -10,8 +9,7 @@ import {
mockSocialConnectorId,
} from '@/__mocks__/connectors-mock';
import {
disableConnector,
enableConnector,
deleteConnectorById,
getConnector,
listConnectors,
postConnector,
@ -20,29 +18,38 @@ import {
updateConnectorConfig,
} from '@/api/connector';
const connectorIdMap = new Map();
/*
* We'd better only use mock connectors in integration tests.
* Since we will refactor connectors soon, keep using some real connectors
* for testing updating configs and enabling/disabling for now.
*/
test('connector set-up flow', async () => {
const connectors = await listConnectors();
await Promise.all(
connectors.map(async ({ id }) => {
await deleteConnectorById(id);
})
);
connectorIdMap.clear();
/*
* Set up social/SMS/email connectors
*/
await Promise.all(
[
{ id: mockSmsConnectorId, config: mockSmsConnectorConfig },
{ id: mockEmailConnectorId, config: mockEmailConnectorConfig },
{ id: mockSocialConnectorId, config: mockSocialConnectorConfig },
].map(async ({ id, config }) => {
{ connectorId: mockSmsConnectorId, config: mockSmsConnectorConfig },
{ connectorId: mockEmailConnectorId, config: mockEmailConnectorConfig },
{ connectorId: mockSocialConnectorId, config: mockSocialConnectorConfig },
].map(async ({ connectorId, config }) => {
const { id } = await postConnector(connectorId);
connectorIdMap.set(connectorId, id);
const updatedConnector = await updateConnectorConfig(id, config);
expect(updatedConnector.config).toEqual(config);
const enabledConnector = await enableConnector(id);
expect(enabledConnector.enabled).toBeTruthy();
// The result of getting a connector should be same as the result of updating a connector above.
const connector = await getConnector(id);
expect(connector.enabled).toBeTruthy();
expect(connector.config).toEqual(config);
})
);
@ -52,52 +59,25 @@ test('connector set-up flow', async () => {
* We will test updating to the invalid connector config, that is the case not covered above.
*/
await expect(
updateConnectorConfig(mockSocialConnectorId, mockSmsConnectorConfig)
updateConnectorConfig(connectorIdMap.get(mockSocialConnectorId), mockSmsConnectorConfig)
).rejects.toThrow(HTTPError);
// To confirm the failed updating request above did not modify the original config,
// we check: the mock connector config should stay the same.
const mockSocialConnector = await getConnector(mockSocialConnectorId);
const mockSocialConnector = await getConnector(connectorIdMap.get(mockSocialConnectorId));
expect(mockSocialConnector.config).toEqual(mockSocialConnectorConfig);
/*
* Change to another SMS/Email connector
*/
await Promise.all(
[
{ id: mockSmsConnectorId, config: mockSmsConnectorConfig, type: ConnectorType.Sms },
{ id: mockEmailConnectorId, config: mockEmailConnectorConfig, type: ConnectorType.Email },
].map(async ({ id, config, type }) => {
// FIXME @Darcy: fix use of `id` and `connectorId`
try {
await getConnector(id);
} catch {
await postConnector(id);
}
const updatedConnector = await updateConnectorConfig(id, config);
expect(updatedConnector.config).toEqual(config);
const enabledConnector = await enableConnector(id);
expect(enabledConnector.enabled).toBeTruthy();
// There should be exactly one enabled SMS/email connector after changing to another SMS/email connector.
const connectorsAfterChanging = await listConnectors();
const enabledConnectors = connectorsAfterChanging.filter(
(connector) => connector.type === type && connector.enabled
);
expect(enabledConnectors.length).toEqual(1);
expect(enabledConnectors[0]?.id).toEqual(id);
})
);
// FIXME @Darcy [LOG-4750,4751]: complete this IT after add another mock sms/email connector (or other current existing connector could be affected)
/*
* Delete (i.e. disable) a connector
*
* We have not provided the API to delete a connector for now.
* Deleting a connector using Admin Console means disabling a connector using Management API.
*/
const disabledMockEmailConnector = await disableConnector(mockEmailConnectorId);
expect(disabledMockEmailConnector.enabled).toBeFalsy();
const mockEmailConnector = await getConnector(mockEmailConnectorId);
expect(mockEmailConnector.enabled).toBeFalsy();
await expect(
deleteConnectorById(connectorIdMap.get(mockEmailConnectorId))
).resolves.not.toThrow();
connectorIdMap.delete(mockEmailConnectorId);
/**
* List connectors after manually setting up connectors.
@ -106,39 +86,48 @@ test('connector set-up flow', async () => {
expect(await listConnectors()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: mockEmailConnectorId,
config: mockEmailConnectorConfig,
enabled: false,
}),
expect.objectContaining({
id: mockSmsConnectorId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
id: connectorIdMap.get(mockSmsConnectorId),
connectorId: mockSmsConnectorId,
config: mockSmsConnectorConfig,
enabled: true,
}),
expect.objectContaining({
id: mockSocialConnectorId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
id: connectorIdMap.get(mockSocialConnectorId),
connectorId: mockSocialConnectorId,
config: mockSocialConnectorConfig,
enabled: true,
}),
])
);
});
describe('send SMS/email test message', () => {
test('send SMS/email test message', async () => {
const connectors = await listConnectors();
await Promise.all(
connectors.map(async ({ id }) => {
await deleteConnectorById(id);
})
);
connectorIdMap.clear();
await Promise.all(
[{ connectorId: mockSmsConnectorId }, { connectorId: mockEmailConnectorId }].map(
async ({ connectorId }) => {
const { id } = await postConnector(connectorId);
connectorIdMap.set(connectorId, id);
}
)
);
const phone = '8612345678901';
const email = 'test@example.com';
it('should send the test message successfully when the config is valid', async () => {
await expect(
sendSmsTestMessage(mockSmsConnectorId, phone, mockSmsConnectorConfig)
).resolves.not.toThrow();
await expect(
sendEmailTestMessage(mockEmailConnectorId, email, mockEmailConnectorConfig)
).resolves.not.toThrow();
});
it('should fail to send the test message when the config is invalid', async () => {
await expect(sendSmsTestMessage(mockSmsConnectorId, phone, {})).rejects.toThrow(HTTPError);
await expect(sendEmailTestMessage(mockEmailConnectorId, email, {})).rejects.toThrow(HTTPError);
});
await expect(
sendSmsTestMessage(connectorIdMap.get(mockSmsConnectorId), phone, mockSmsConnectorConfig)
).resolves.not.toThrow();
await expect(
sendEmailTestMessage(connectorIdMap.get(mockEmailConnectorId), email, mockEmailConnectorConfig)
).resolves.not.toThrow();
await expect(sendSmsTestMessage(mockSmsConnectorId, phone, {})).rejects.toThrow(HTTPError);
await expect(sendEmailTestMessage(mockEmailConnectorId, email, {})).rejects.toThrow(HTTPError);
});

View file

@ -17,15 +17,17 @@ import {
verifyRegisterUserWithSmsPasscode,
sendSignInUserWithSmsPasscode,
verifySignInUserWithSmsPasscode,
disableConnector,
signInWithPassword,
createUser,
listConnectors,
deleteConnectorById,
postConnector,
updateConnectorConfig,
} from '@/api';
import MockClient from '@/client';
import {
registerNewUser,
signIn,
setUpConnector,
readPasscode,
createUserByAdmin,
setSignUpIdentifier,
@ -33,6 +35,8 @@ import {
} from '@/helpers';
import { generateUsername, generatePassword, generateEmail, generatePhone } from '@/utils';
const connectorIdMap = new Map();
describe('username and password flow', () => {
const username = generateUsername();
const password = generatePassword();
@ -63,7 +67,10 @@ describe('email and password flow', () => {
assert(localPart && domain, new Error('Email address local part or domain is empty'));
beforeAll(async () => {
await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig);
const { id } = await postConnector(mockEmailConnectorId);
await updateConnectorConfig(id, mockEmailConnectorConfig);
connectorIdMap.set(mockEmailConnectorId, id);
await setSignUpIdentifier(SignUpIdentifier.Email, true);
await setSignInMethod([
{
@ -93,7 +100,18 @@ describe('email and password flow', () => {
describe('email passwordless flow', () => {
beforeAll(async () => {
await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig);
const connectors = await listConnectors();
await Promise.all(
connectors.map(async ({ id }) => {
await deleteConnectorById(id);
})
);
connectorIdMap.clear();
const { id } = await postConnector(mockEmailConnectorId);
await updateConnectorConfig(id, mockEmailConnectorConfig);
connectorIdMap.set(mockEmailConnectorId, id);
await setSignUpIdentifier(SignUpIdentifier.Email, false);
await setSignInMethod([
{
@ -175,13 +193,24 @@ describe('email passwordless flow', () => {
});
afterAll(async () => {
void disableConnector(mockEmailConnectorId);
await deleteConnectorById(connectorIdMap.get(mockEmailConnectorId));
});
});
describe('sms passwordless flow', () => {
beforeAll(async () => {
await setUpConnector(mockSmsConnectorId, mockSmsConnectorConfig);
const connectors = await listConnectors();
await Promise.all(
connectors.map(async ({ id }) => {
await deleteConnectorById(id);
})
);
connectorIdMap.clear();
const { id } = await postConnector(mockSmsConnectorId);
await updateConnectorConfig(id, mockSmsConnectorConfig);
connectorIdMap.set(mockSmsConnectorId, id);
await setSignUpIdentifier(SignUpIdentifier.Sms, false);
await setSignInMethod([
{
@ -263,7 +292,7 @@ describe('sms passwordless flow', () => {
});
afterAll(async () => {
void disableConnector(mockSmsConnectorId);
await deleteConnectorById(connectorIdMap.get(mockSmsConnectorId));
});
});

View file

@ -14,20 +14,27 @@ import {
bindWithSocial,
signInWithPassword,
getUser,
postConnector,
updateConnectorConfig,
} from '@/api';
import MockClient from '@/client';
import { setUpConnector, createUserByAdmin, setSignUpIdentifier } from '@/helpers';
import { createUserByAdmin, setSignUpIdentifier } from '@/helpers';
import { generateUsername, generatePassword } from '@/utils';
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';
const code = 'auth_code_foo';
const connectorIdMap = new Map<string, string>();
describe('social sign-in and register', () => {
const socialUserId = crypto.randomUUID();
beforeAll(async () => {
await setUpConnector(mockSocialConnectorId, mockSocialConnectorConfig);
const { id } = await postConnector(mockSocialConnectorId);
connectorIdMap.set(mockSocialConnectorId, id);
await updateConnectorConfig(id, mockSocialConnectorConfig);
await setSignUpIdentifier(SignUpIdentifier.None, false);
});
@ -39,14 +46,14 @@ describe('social sign-in and register', () => {
await expect(
signInWithSocial(
{ state, connectorId: mockSocialConnectorId, redirectUri },
{ state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri },
client.interactionCookie
)
).resolves.toBeTruthy();
const response = await getAuthWithSocial(
{
connectorId: mockSocialConnectorId,
connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '',
data: { state, redirectUri, code, userId: socialUserId },
},
client.interactionCookie
@ -57,7 +64,7 @@ describe('social sign-in and register', () => {
// Register with social
const { redirectTo } = await registerWithSocial(
mockSocialConnectorId,
connectorIdMap.get(mockSocialConnectorId) ?? '',
client.interactionCookie
);
@ -78,14 +85,14 @@ describe('social sign-in and register', () => {
await expect(
signInWithSocial(
{ state, connectorId: mockSocialConnectorId, redirectUri },
{ state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri },
client.interactionCookie
)
).resolves.toBeTruthy();
const { redirectTo } = await getAuthWithSocial(
{
connectorId: mockSocialConnectorId,
connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '',
data: { state, redirectUri, code, userId: socialUserId },
},
client.interactionCookie
@ -113,13 +120,16 @@ describe('social bind account', () => {
await expect(
signInWithSocial(
{ state, connectorId: mockSocialConnectorId, redirectUri },
{ state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri },
client.interactionCookie
)
).resolves.toBeTruthy();
const response = await getAuthWithSocial(
{ connectorId: mockSocialConnectorId, data: { state, redirectUri, code } },
{
connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '',
data: { state, redirectUri, code },
},
client.interactionCookie
).catch((error: unknown) => error);
@ -133,7 +143,7 @@ describe('social bind account', () => {
});
await expect(
bindWithSocial(mockSocialConnectorId, client.interactionCookie)
bindWithSocial(connectorIdMap.get(mockSocialConnectorId) ?? '', client.interactionCookie)
).resolves.not.toThrow();
await client.processSession(redirectTo);

View file

@ -0,0 +1,20 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
DELETE FROM connectors WHERE enabled = false;
ALTER TABLE connectors DROP COLUMN enabled;
`);
},
down: async (pool) => {
await pool.query(sql`
ALTER TABLE connectors ADD COLUMN enabled boolean NOT NULL DEFAULT false;
UPDATE connectors SET enabled = true;
`);
},
};
export default alteration;

View file

@ -8,3 +8,9 @@ export { ConnectorType, ConnectorPlatform } from '@logto/connector-kit';
export type ConnectorResponse = Omit<Connector, 'metadata'> &
Omit<BaseConnector<ConnectorType>, 'configGuard' | 'metadata'> &
ConnectorMetadata;
export type ConnectorFactoryResponse = Omit<
BaseConnector<ConnectorType>,
'configGuard' | 'metadata'
> &
ConnectorMetadata;

View file

@ -1,6 +1,5 @@
create table connectors (
id varchar(128) not null,
enabled boolean not null default FALSE,
sync_profile boolean not null default FALSE,
connector_id varchar(128) not null,
config jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,