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

feat(core,console): social connector targets (#851)

* feat(core,console): social connector targets

* fix: add test
This commit is contained in:
Wang Sijie 2022-05-17 17:09:42 +08:00 committed by GitHub
parent 3031e3a6f1
commit 127664a62f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 186 additions and 108 deletions

View file

@ -0,0 +1,54 @@
import { ConnectorDTO } from '@logto/schemas';
import { useMemo } from 'react';
import useSWR from 'swr';
import { RequestError } from '@/hooks/use-api';
import { ConnectorGroup } from '@/types/connector';
// Group connectors by target
const useConnectorGroups = () => {
const { data, ...rest } = useSWR<ConnectorDTO[], RequestError>('/api/connectors');
const groups = useMemo(() => {
if (!data) {
return;
}
return data.reduce<ConnectorGroup[]>((previous, item) => {
const groupIndex = previous.findIndex(({ target }) => target === item.target);
if (groupIndex === -1) {
return [
...previous,
{
name: item.metadata.name,
logo: item.metadata.logo,
target: item.metadata.target,
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,
};
});
}, []);
}, [data]);
return {
...rest,
data: groups,
};
};
export default useConnectorGroups;

View file

@ -1,14 +1,12 @@
import { ConnectorDTO } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import Alert from '@/components/Alert';
import Transfer from '@/components/Transfer';
import UnnamedTrans from '@/components/UnnamedTrans';
import { RequestError } from '@/hooks/use-api';
import useConnectorGroups from '@/hooks/use-connector-groups';
import * as styles from './ConnectorsTransfer.module.scss';
@ -18,7 +16,7 @@ type Props = {
};
const ConnectorsTransfer = ({ value, onChange }: Props) => {
const { data, error } = useSWR<ConnectorDTO[], RequestError>('/api/connectors');
const { data, error } = useConnectorGroups();
const isLoading = !data && !error;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -31,8 +29,8 @@ const ConnectorsTransfer = ({ value, onChange }: Props) => {
}
const datasource = data
? data.map(({ id, metadata: { name }, enabled }) => ({
value: id,
? data.map(({ target, name, enabled }) => ({
value: target,
title: (
<UnnamedTrans
resource={name}

View file

@ -87,7 +87,7 @@ const SignInMethodsForm = () => {
{primaryMethod === SignInMethodKey.Social && (
<div className={styles.primarySocial}>
<Controller
name="socialSignInConnectorIds"
name="socialSignInConnectorTargets"
control={control}
render={({ field: { value, onChange } }) => (
<ConnectorsTransfer value={value} onChange={onChange} />
@ -107,7 +107,7 @@ const SignInMethodsForm = () => {
{social && (
<FormField title="admin_console.sign_in_exp.sign_in_methods.define_social_methods">
<Controller
name="socialSignInConnectorIds"
name="socialSignInConnectorTargets"
control={control}
render={({ field: { value, onChange } }) => (
<ConnectorsTransfer value={value} onChange={onChange} />

View file

@ -1,10 +1,9 @@
import { ConnectorDTO, SignInExperience, SignInMethodKey, SignInMethodState } from '@logto/schemas';
import { SignInExperience, SignInMethodKey, SignInMethodState } from '@logto/schemas';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import UnnamedTrans from '@/components/UnnamedTrans';
import { RequestError } from '@/hooks/use-api';
import useConnectorGroups from '@/hooks/use-connector-groups';
import * as styles from './SaveAlert.module.scss';
@ -13,37 +12,33 @@ type Props = {
};
const SignInMethodsPreview = ({ data }: Props) => {
const { data: connectors, error } = useSWR<ConnectorDTO[], RequestError>('/api/connectors');
const { signInMethods, socialSignInConnectorIds } = data;
const { data: groups, error } = useConnectorGroups();
const { signInMethods, socialSignInConnectorTargets } = data;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const connectorNames = useMemo(() => {
if (!connectors) {
if (!groups) {
return null;
}
return socialSignInConnectorIds.map((connectorId) => {
const connector = connectors.find(({ id }) => id === connectorId);
return socialSignInConnectorTargets.map((connectorTarget) => {
const group = groups.find(({ target }) => target === connectorTarget);
if (!connector) {
if (!group) {
return null;
}
return (
<UnnamedTrans
key={connectorId}
className={styles.connector}
resource={connector.metadata.name}
/>
<UnnamedTrans key={connectorTarget} className={styles.connector} resource={group.name} />
);
});
}, [connectors, socialSignInConnectorIds]);
}, [groups, socialSignInConnectorTargets]);
return (
<div>
{!connectors && !error && <div>loading</div>}
{!connectors && error && <div>{error.body?.message ?? error.message}</div>}
{connectors &&
{!groups && !error && <div>loading</div>}
{!groups && error && <div>{error.body?.message ?? error.message}</div>}
{groups &&
Object.values(SignInMethodKey)
.filter((key) => signInMethods[key] !== SignInMethodState.Disabled)
.map((key) => (

View file

@ -81,11 +81,15 @@ export const compareSignInMethods = (
before: SignInExperience,
after: SignInExperience
): boolean => {
if (before.socialSignInConnectorIds.length !== after.socialSignInConnectorIds.length) {
if (before.socialSignInConnectorTargets.length !== after.socialSignInConnectorTargets.length) {
return false;
}
if (before.socialSignInConnectorIds.some((id) => !after.socialSignInConnectorIds.includes(id))) {
if (
before.socialSignInConnectorTargets.some(
(target) => !after.socialSignInConnectorTargets.includes(target)
)
) {
return false;
}

View file

@ -0,0 +1,6 @@
import { ConnectorDTO } from '@logto/schemas';
export type ConnectorGroup = Pick<ConnectorDTO['metadata'], 'name' | 'logo' | 'target'> & {
enabled: boolean;
connectors: ConnectorDTO[];
};

View file

@ -252,6 +252,36 @@ export const mockGithubConnectorInstance = {
},
};
export const mockWechatConnectorInstance = {
connector: {
...mockConnector,
id: 'wechat',
target: 'wechat',
platform: ConnectorPlatform.Web,
},
metadata: {
...mockMetadata,
target: 'wechat',
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
},
};
export const mockWechatNativeConnectorInstance = {
connector: {
...mockConnector,
id: 'wechat-native',
target: 'wechat',
platform: ConnectorPlatform.Native,
},
metadata: {
...mockMetadata,
target: 'wechat',
type: ConnectorType.Social,
platform: ConnectorPlatform.Native,
},
};
export const mockGoogleConnectorInstance = {
connector: {
...mockConnector,

View file

@ -33,7 +33,7 @@ export const mockSignInExperience: SignInExperience = {
sms: SignInMethodState.Disabled,
social: SignInMethodState.Secondary,
},
socialSignInConnectorIds: ['github', 'facebook'],
socialSignInConnectorTargets: ['github', 'facebook', 'wechat'],
};
export const mockBranding: Branding = {

View file

@ -27,7 +27,7 @@ export const isEnabled = (state: SignInMethodState) => state !== SignInMethodSta
export const validateSignInMethods = (
signInMethods: SignInMethods,
socialSignInConnectorIds: Optional<string[]>,
socialSignInConnectorTargets: Optional<string[]>,
enabledConnectorInstances: ConnectorInstance[]
) => {
const signInMethodStates = Object.values(signInMethods);
@ -60,17 +60,17 @@ export const validateSignInMethods = (
);
assertThat(
socialSignInConnectorIds && socialSignInConnectorIds.length > 0,
socialSignInConnectorTargets && socialSignInConnectorTargets.length > 0,
'sign_in_experiences.empty_social_connectors'
);
const enabledSocialConnectorIds = new Set(
enabledConnectorInstances
.filter((instance) => instance.metadata.type === ConnectorType.Social)
.map((instance) => instance.connector.id)
);
assertThat(
socialSignInConnectorIds.every((id) => enabledSocialConnectorIds.has(id)),
socialSignInConnectorTargets.every((connectorTarget) =>
enabledConnectorInstances.some(
({ metadata: { target, type } }) =>
target === connectorTarget && type === ConnectorType.Social
)
),
'sign_in_experiences.invalid_social_connectors'
);
}

View file

@ -24,14 +24,14 @@ describe('sign-in-experience query', () => {
branding: JSON.stringify(mockSignInExperience.branding),
termsOfUse: JSON.stringify(mockSignInExperience.termsOfUse),
languageInfo: JSON.stringify(mockSignInExperience.languageInfo),
signInMethods: JSON.stringify(mockSignInExperience.socialSignInConnectorIds),
socialSignInConnectorIds: JSON.stringify(mockSignInExperience.socialSignInConnectorIds),
signInMethods: JSON.stringify(mockSignInExperience.socialSignInConnectorTargets),
socialSignInConnectorTargets: JSON.stringify(mockSignInExperience.socialSignInConnectorTargets),
};
it('findDefaultSignInExperience', async () => {
/* eslint-disable sql/no-unsafe-query */
const expectSql = `
select "id", "branding", "language_info", "terms_of_use", "sign_in_methods", "social_sign_in_connector_ids"
select "id", "branding", "language_info", "terms_of_use", "sign_in_methods", "social_sign_in_connector_targets"
from "sign_in_experiences"
where "id" = $1
`;

View file

@ -10,6 +10,8 @@ import {
mockFacebookConnectorInstance,
mockGithubConnectorInstance,
mockGoogleConnectorInstance,
mockWechatConnectorInstance,
mockWechatNativeConnectorInstance,
} from '@/__mocks__';
import { ConnectorType } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
@ -111,6 +113,8 @@ const getConnectorInstances = jest.fn(async () => [
mockFacebookConnectorInstance,
mockGithubConnectorInstance,
mockGoogleConnectorInstance,
mockWechatConnectorInstance,
mockWechatNativeConnectorInstance,
]);
jest.mock('@/connectors', () => ({
getSocialConnectorInstanceById: async (connectorId: string) => {
@ -925,6 +929,14 @@ describe('sessionRoutes', () => {
...mockFacebookConnectorInstance.metadata,
id: mockFacebookConnectorInstance.connector.id,
},
{
...mockWechatConnectorInstance.metadata,
id: mockWechatConnectorInstance.connector.id,
},
{
...mockWechatNativeConnectorInstance.metadata,
id: mockWechatNativeConnectorInstance.connector.id,
},
],
})
);

View file

@ -1,6 +1,7 @@
/* eslint-disable max-lines */
import path from 'path';
import { ConnectorMetadata } from '@logto/connector-types';
import { LogtoErrorCode } from '@logto/phrases';
import { PasscodeType, userInfoSelectFields } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
@ -581,12 +582,18 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
router.get('/sign-in-settings', async (ctx, next) => {
const signInExperience = await findDefaultSignInExperience();
const connectorInstances = await getConnectorInstances();
const instanceMap = new Map(
connectorInstances.map((instance) => [instance.connector.id, instance])
);
const socialConnectors = signInExperience.socialSignInConnectorIds.map((id) => {
return { ...instanceMap.get(id)?.metadata, id };
});
const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
Array<ConnectorMetadata & { id: string }>
>((previous, connectorTarget) => {
const connectors = connectorInstances.filter(
({ metadata: { target } }) => target === connectorTarget
);
return [
...previous,
...connectors.map(({ metadata, connector: { id } }) => ({ ...metadata, id })),
];
}, []);
ctx.body = { ...signInExperience, socialConnectors };
return next();

View file

@ -195,7 +195,7 @@ describe('signInMethods', () => {
sms: state,
social: SignInMethodState.Primary,
},
socialSignInConnectorIds: ['github'],
socialSignInConnectorTargets: ['github'],
};
await expectPatchResponseStatus(signInExperience, 200);
});
@ -211,7 +211,7 @@ describe('signInMethods', () => {
sms: state,
social: SignInMethodState.Primary,
},
socialSignInConnectorIds: ['github'],
socialSignInConnectorTargets: ['github'],
};
await expectPatchResponseStatus(signInExperience, 400);
});
@ -229,7 +229,7 @@ describe('signInMethods', () => {
sms: SignInMethodState.Disabled,
social: state,
},
socialSignInConnectorIds: ['github'],
socialSignInConnectorTargets: ['github'],
};
await expectPatchResponseStatus(signInExperience, 200);
});
@ -245,21 +245,21 @@ describe('signInMethods', () => {
sms: SignInMethodState.Disabled,
social: state,
},
socialSignInConnectorIds: ['github'],
socialSignInConnectorTargets: ['github'],
};
await expectPatchResponseStatus(signInExperience, 400);
});
});
});
describe('socialSignInConnectorIds', () => {
describe('socialSignInConnectorTargets', () => {
test.each([[['facebook']], [['facebook', 'github']]])(
'%p should success',
async (socialSignInConnectorIds) => {
async (socialSignInConnectorTargets) => {
await expectPatchResponseStatus(
{
signInMethods: { ...mockSignInMethods, social: SignInMethodState.Secondary },
socialSignInConnectorIds,
socialSignInConnectorTargets,
},
200
);
@ -268,11 +268,11 @@ describe('socialSignInConnectorIds', () => {
test.each([[[]], [[null, undefined]], [['', ' \t\n\r']], [[123, 456]]])(
'%p should fail',
async (socialSignInConnectorIds: any[]) => {
async (socialSignInConnectorTargets: any[]) => {
await expectPatchResponseStatus(
{
signInMethods: { ...mockSignInMethods, social: SignInMethodState.Secondary },
socialSignInConnectorIds,
socialSignInConnectorTargets,
},
400
);

View file

@ -59,23 +59,6 @@ describe('GET /sign-in-exp', () => {
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockSignInExperience);
});
it('should filter enabled social connectors', async () => {
const signInExperience = {
...mockSignInExperience,
signInMethods: { ...mockSignInMethods, social: SignInMethodState.Secondary },
socialSignInConnectorIds: ['facebook', 'github', 'google'],
};
findDefaultSignInExperience.mockImplementationOnce(async () => signInExperience);
const response = await signInExperienceRequester.get('/sign-in-exp');
expect(response.status).toEqual(200);
expect(response.body).toEqual({
...signInExperience,
socialSignInConnectorIds: ['facebook', 'github'],
});
});
});
describe('PATCH /sign-in-exp', () => {
@ -83,7 +66,7 @@ describe('PATCH /sign-in-exp', () => {
const signInMethods = { ...mockSignInMethods, social: SignInMethodState.Disabled };
const response = await signInExperienceRequester.patch('/sign-in-exp').send({
signInMethods,
socialSignInConnectorIds: ['facebook'],
socialSignInConnectorTargets: ['facebook'],
});
expect(response).toMatchObject({
status: 200,
@ -96,10 +79,10 @@ describe('PATCH /sign-in-exp', () => {
it('should update enabled social connector IDs only when social sign-in is enabled', async () => {
const signInMethods = { ...mockSignInMethods, social: SignInMethodState.Secondary };
const socialSignInConnectorIds = ['facebook'];
const socialSignInConnectorTargets = ['facebook'];
const signInExperience = {
signInMethods,
socialSignInConnectorIds,
socialSignInConnectorTargets,
};
const response = await signInExperienceRequester.patch('/sign-in-exp').send(signInExperience);
expect(response).toMatchObject({
@ -107,17 +90,17 @@ describe('PATCH /sign-in-exp', () => {
body: {
...mockSignInExperience,
signInMethods,
socialSignInConnectorIds,
socialSignInConnectorTargets,
},
});
});
it('should update social connector IDs in correct sorting order', async () => {
const signInMethods = { ...mockSignInMethods, social: SignInMethodState.Secondary };
const socialSignInConnectorIds = ['github', 'facebook'];
const socialSignInConnectorTargets = ['github', 'facebook'];
const signInExperience = {
signInMethods,
socialSignInConnectorIds,
socialSignInConnectorTargets,
};
const response = await signInExperienceRequester.patch('/sign-in-exp').send(signInExperience);
expect(response).toMatchObject({
@ -125,14 +108,14 @@ describe('PATCH /sign-in-exp', () => {
body: {
...mockSignInExperience,
signInMethods,
socialSignInConnectorIds,
socialSignInConnectorTargets,
},
});
});
it('should succeed to update when the input is valid', async () => {
const termsOfUse: TermsOfUse = { enabled: false };
const socialSignInConnectorIds = ['github', 'facebook'];
const socialSignInConnectorTargets = ['github', 'facebook', 'wechat'];
const validateBranding = jest.spyOn(signInExpLib, 'validateBranding');
const validateTermsOfUse = jest.spyOn(signInExpLib, 'validateTermsOfUse');
@ -142,14 +125,14 @@ describe('PATCH /sign-in-exp', () => {
branding: mockBranding,
termsOfUse,
signInMethods: mockSignInMethods,
socialSignInConnectorIds,
socialSignInConnectorTargets,
});
expect(validateBranding).toHaveBeenCalledWith(mockBranding);
expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse);
expect(validateSignInMethods).toHaveBeenCalledWith(
mockSignInMethods,
socialSignInConnectorIds,
socialSignInConnectorTargets,
[mockFacebookConnectorInstance, mockGithubConnectorInstance]
);
@ -160,7 +143,7 @@ describe('PATCH /sign-in-exp', () => {
branding: mockBranding,
termsOfUse,
signInMethods: mockSignInMethods,
socialSignInConnectorIds,
socialSignInConnectorTargets,
},
});
});

View file

@ -1,6 +1,6 @@
import { SignInExperiences } from '@logto/schemas';
import { getConnectorInstances, getEnabledSocialConnectorIds } from '@/connectors';
import { getConnectorInstances } from '@/connectors';
import {
validateBranding,
validateTermsOfUse,
@ -21,22 +21,7 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
* always return the default settings in DB for the /sign-in-exp get method
*/
router.get('/sign-in-exp', async (ctx, next) => {
const [signInExperience, enabledSocialConnectorIds] = await Promise.all([
findDefaultSignInExperience(),
getEnabledSocialConnectorIds(),
]);
const { socialSignInConnectorIds: selectedSocialSignInConnectorIds } = signInExperience;
const enabledSocialConnectorIdSet = new Set(enabledSocialConnectorIds);
const socialSignInConnectorIds = selectedSocialSignInConnectorIds.filter((id) =>
enabledSocialConnectorIdSet.has(id)
);
ctx.body = {
...signInExperience,
socialSignInConnectorIds,
};
ctx.body = await findDefaultSignInExperience();
return next();
});
@ -47,7 +32,7 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
body: SignInExperiences.createGuard.omit({ id: true }).partial(),
}),
async (ctx, next) => {
const { socialSignInConnectorIds, ...rest } = ctx.guard.body;
const { socialSignInConnectorTargets, ...rest } = ctx.guard.body;
const { branding, termsOfUse, signInMethods } = rest;
if (branding) {
@ -65,10 +50,14 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
(instance) => instance.connector.enabled
);
validateSignInMethods(signInMethods, socialSignInConnectorIds, enabledConnectorInstances);
validateSignInMethods(
signInMethods,
socialSignInConnectorTargets,
enabledConnectorInstances
);
}
// Update socialSignInConnectorIds only when social sign-in is enabled.
// Update socialSignInConnectorTargets only when social sign-in is enabled.
const signInExperience =
signInMethods && isEnabled(signInMethods.social) ? ctx.guard.body : rest;

View file

@ -126,9 +126,9 @@ export const signInMethodsGuard = z.object({
export type SignInMethods = z.infer<typeof signInMethodsGuard>;
export const connectorIdsGuard = z.string().array();
export const connectorTargetsGuard = z.string().array();
export type ConnectorIds = z.infer<typeof connectorIdsGuard>;
export type ConnectorTargets = z.infer<typeof connectorTargetsGuard>;
/**
* Settings

View file

@ -26,5 +26,5 @@ export const defaultSignInExperience: Readonly<CreateSignInExperience> = {
sms: SignInMethodState.Disabled,
social: SignInMethodState.Disabled,
},
socialSignInConnectorIds: [],
socialSignInConnectorTargets: [],
};

View file

@ -4,6 +4,6 @@ create table sign_in_experiences (
language_info jsonb /* @use LanguageInfo */ not null,
terms_of_use jsonb /* @use TermsOfUse */ not null,
sign_in_methods jsonb /* @use SignInMethods */ not null,
social_sign_in_connector_ids jsonb /* @use ConnectorIds */ not null default '[]'::jsonb,
social_sign_in_connector_targets jsonb /* @use ConnectorTargets */ not null default '[]'::jsonb,
primary key (id)
);

View file

@ -139,7 +139,7 @@ export const mockSignInExperience: SignInExperience = {
sms: SignInMethodState.Secondary,
social: SignInMethodState.Secondary,
},
socialSignInConnectorIds: ['BE8QXN0VsrOH7xdWFDJZ9', 'lcXT4o2GSjbV9kg2shZC7'],
socialSignInConnectorTargets: ['BE8QXN0VsrOH7xdWFDJZ9', 'lcXT4o2GSjbV9kg2shZC7'],
};
export const mockSignInExperienceSettings: SignInExperienceSettings = {