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

Merge branch 'feature/sie-v2' into merge/sie-v2

This commit is contained in:
wangsijie 2022-11-08 23:03:12 +08:00
commit c8a7efb0cc
No known key found for this signature in database
GPG key ID: C72642FE24F7D42B
20 changed files with 405 additions and 143 deletions

View file

@ -3,7 +3,7 @@ import { AppearanceMode, ConnectorType } from '@logto/schemas';
import classNames from 'classnames';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import useSWR, { useSWRConfig } from 'swr';
@ -14,6 +14,7 @@ import Reset from '@/assets/images/reset.svg';
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
import Button from '@/components/Button';
import Card from '@/components/Card';
import ConfirmModal from '@/components/ConfirmModal';
import CopyToClipboard from '@/components/CopyToClipboard';
import DetailsSkeleton from '@/components/DetailsSkeleton';
import Drawer from '@/components/Drawer';
@ -49,6 +50,18 @@ const ConnectorDetails = () => {
const api = useApi();
const navigate = useNavigate();
const theme = useTheme();
const isSocial = data?.type === ConnectorType.Social;
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
const onDeleteClick = async () => {
if (!isSocial || !inUse) {
await handleDelete();
return;
}
setIsDeleteAlertOpen(true);
};
const handleDelete = async () => {
if (!connectorId) {
@ -65,7 +78,7 @@ const ConnectorDetails = () => {
await mutateGlobal('/api/connectors');
setIsDeleted(true);
if (data?.type === ConnectorType.Social) {
if (isSocial) {
navigate(`/connectors/social`, { replace: true });
} else {
navigate(`/connectors`, { replace: true });
@ -75,16 +88,14 @@ const ConnectorDetails = () => {
return (
<div className={detailsStyles.container}>
<LinkButton
to={data?.type === ConnectorType.Social ? '/connectors/social' : '/connectors'}
to={isSocial ? '/connectors/social' : '/connectors'}
icon={<Back />}
title="connector_details.back_to_connectors"
className={styles.backLink}
/>
{isLoading && <DetailsSkeleton />}
{!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
{data?.type === ConnectorType.Social && (
<ConnectorTabs target={data.target} connectorId={data.id} />
)}
{isSocial && <ConnectorTabs target={data.target} connectorId={data.id} />}
{data && (
<Card className={styles.header}>
<div className={styles.logoContainer}>
@ -137,7 +148,7 @@ const ConnectorDetails = () => {
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'large' }}
title={t('general.more_options')}
>
{data.type !== ConnectorType.Social && (
{!isSocial && (
<ActionMenuItem
icon={<Reset />}
iconClassName={styles.resetIcon}
@ -152,7 +163,7 @@ const ConnectorDetails = () => {
)}
</ActionMenuItem>
)}
<ActionMenuItem icon={<Delete />} type="danger" onClick={handleDelete}>
<ActionMenuItem icon={<Delete />} type="danger" onClick={onDeleteClick}>
{t('general.delete')}
</ActionMenuItem>
</ActionMenu>
@ -183,6 +194,22 @@ const ConnectorDetails = () => {
/>
</Card>
)}
{data && (
<ConfirmModal
isOpen={isDeleteAlertOpen}
confirmButtonText="general.delete"
onCancel={() => {
setIsDeleteAlertOpen(false);
}}
onConfirm={handleDelete}
>
<Trans
t={t}
i18nKey="connector_details.in_use_deletion_description"
components={{ name: <UnnamedTrans resource={data.name} /> }}
/>
</ConfirmModal>
)}
</div>
);
};

View file

@ -0,0 +1,125 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorPlatform } from '@logto/connector-kit';
import type { Connector } from '@logto/schemas';
export const mockMetadata: ConnectorMetadata = {
id: 'id',
target: 'connector',
platform: null,
name: {
en: 'Connector',
'pt-PT': 'Conector',
'zh-CN': '连接器',
'tr-TR': 'Connector',
ko: 'Connector',
},
logo: './logo.png',
logoDark: './logo-dark.png',
description: {
en: 'Connector',
'pt-PT': 'Conector',
'zh-CN': '连接器',
'tr-TR': 'Connector',
ko: 'Connector',
},
readme: 'README.md',
configTemplate: 'config-template.json',
};
export const mockMetadata0: ConnectorMetadata = {
...mockMetadata,
id: 'id0',
target: 'connector_0',
platform: ConnectorPlatform.Universal,
};
export const mockMetadata1: ConnectorMetadata = {
...mockMetadata,
id: 'id1',
target: 'connector_1',
platform: ConnectorPlatform.Universal,
};
export const mockMetadata2: ConnectorMetadata = {
...mockMetadata,
id: 'id2',
target: 'connector_2',
platform: ConnectorPlatform.Universal,
};
export const mockMetadata3: ConnectorMetadata = {
...mockMetadata,
id: 'id3',
target: 'connector_3',
platform: ConnectorPlatform.Universal,
};
export const mockMetadata4: ConnectorMetadata = {
...mockMetadata,
id: 'id4',
target: 'connector_4',
platform: ConnectorPlatform.Universal,
};
export const mockMetadata5: ConnectorMetadata = {
...mockMetadata,
id: 'id5',
target: 'connector_5',
platform: ConnectorPlatform.Universal,
};
export const mockMetadata6: ConnectorMetadata = {
...mockMetadata,
id: 'id6',
target: 'connector_6',
platform: ConnectorPlatform.Universal,
};
export const mockConnector0: Connector = {
id: 'id0',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
};
export const mockConnector1: Connector = {
id: 'id1',
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
};
export const mockConnector2: Connector = {
id: 'id2',
enabled: true,
config: {},
createdAt: 1_234_567_890_345,
};
export const mockConnector3: Connector = {
id: 'id3',
enabled: true,
config: {},
createdAt: 1_234_567_890_456,
};
export const mockConnector4: Connector = {
id: 'id4',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};
export const mockConnector5: Connector = {
id: 'id5',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};
export const mockConnector6: Connector = {
id: 'id6',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};

View file

@ -1,33 +1,29 @@
import { ConnectorPlatform } from '@logto/connector-kit';
import type { Connector, ConnectorMetadata } from '@logto/schemas';
import type { Connector } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { any } from 'zod';
import type { LogtoConnector } from '@/connectors/types';
export const mockMetadata: ConnectorMetadata = {
id: 'id',
target: 'connector',
platform: null,
name: {
en: 'Connector',
'pt-PT': 'Conector',
'zh-CN': '连接器',
'tr-TR': 'Connector',
ko: 'Connector',
},
logo: './logo.png',
logoDark: './logo-dark.png',
description: {
en: 'Connector',
'pt-PT': 'Conector',
'zh-CN': '连接器',
'tr-TR': 'Connector',
ko: 'Connector',
},
readme: 'README.md',
configTemplate: 'config-template.json',
};
import {
mockConnector0,
mockConnector1,
mockConnector2,
mockConnector3,
mockConnector4,
mockConnector5,
mockConnector6,
mockMetadata,
mockMetadata0,
mockMetadata1,
mockMetadata2,
mockMetadata3,
mockMetadata4,
mockMetadata5,
mockMetadata6,
} from './connector-base-data';
export { mockMetadata } from './connector-base-data';
export const mockConnector: Connector = {
id: 'id',
@ -44,104 +40,6 @@ export const mockLogtoConnector = {
configGuard: any(),
};
const mockMetadata0: ConnectorMetadata = {
...mockMetadata,
id: 'id0',
target: 'connector_0',
platform: ConnectorPlatform.Universal,
};
const mockMetadata1: ConnectorMetadata = {
...mockMetadata,
id: 'id1',
target: 'connector_1',
platform: ConnectorPlatform.Universal,
};
const mockMetadata2: ConnectorMetadata = {
...mockMetadata,
id: 'id2',
target: 'connector_2',
platform: ConnectorPlatform.Universal,
};
const mockMetadata3: ConnectorMetadata = {
...mockMetadata,
id: 'id3',
target: 'connector_3',
platform: ConnectorPlatform.Universal,
};
const mockMetadata4: ConnectorMetadata = {
...mockMetadata,
id: 'id4',
target: 'connector_4',
platform: ConnectorPlatform.Universal,
};
const mockMetadata5: ConnectorMetadata = {
...mockMetadata,
id: 'id5',
target: 'connector_5',
platform: ConnectorPlatform.Universal,
};
const mockMetadata6: ConnectorMetadata = {
...mockMetadata,
id: 'id6',
target: 'connector_6',
platform: ConnectorPlatform.Universal,
};
const mockConnector0: Connector = {
id: 'id0',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
};
const mockConnector1: Connector = {
id: 'id1',
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
};
const mockConnector2: Connector = {
id: 'id2',
enabled: true,
config: {},
createdAt: 1_234_567_890_345,
};
const mockConnector3: Connector = {
id: 'id3',
enabled: true,
config: {},
createdAt: 1_234_567_890_456,
};
const mockConnector4: Connector = {
id: 'id4',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};
const mockConnector5: Connector = {
id: 'id5',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};
const mockConnector6: Connector = {
id: 'id6',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
};
export const mockConnectorList: Connector[] = [
mockConnector0,
mockConnector1,
@ -312,3 +210,52 @@ export const mockLogtoConnectors = [
mockWechatConnector,
mockWechatNativeConnector,
];
export const disabledSocialTarget01 = 'disableSocialTarget-id01';
export const disabledSocialTarget02 = 'disableSocialTarget-id02';
export const enabledSocialTarget01 = 'enabledSocialTarget-id01';
export const mockSocialConnectors: LogtoConnector[] = [
{
dbEntry: {
id: 'id0',
enabled: false,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
...mockMetadata,
target: disabledSocialTarget01,
},
type: ConnectorType.Social,
...mockLogtoConnector,
},
{
dbEntry: {
id: 'id1',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
...mockMetadata,
target: enabledSocialTarget01,
},
type: ConnectorType.Social,
...mockLogtoConnector,
},
{
dbEntry: {
id: 'id2',
enabled: false,
config: {},
createdAt: 1_234_567_890_123,
},
metadata: {
...mockMetadata,
target: disabledSocialTarget02,
},
type: ConnectorType.Social,
...mockLogtoConnector,
},
];

View file

@ -1,22 +1,53 @@
import type { LanguageTag } from '@logto/language-kit';
import { builtInLanguages } from '@logto/phrases-ui';
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import { mockBranding } from '@/__mocks__';
import {
disabledSocialTarget01,
disabledSocialTarget02,
enabledSocialTarget01,
mockBranding,
mockSignInExperience,
mockSocialConnectors,
} from '@/__mocks__';
import type { LogtoConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import {
validateBranding,
validateTermsOfUse,
validateLanguageInfo,
removeUnavailableSocialConnectorTargets,
} from '@/lib/sign-in-experience';
import { updateDefaultSignInExperience } from '@/queries/sign-in-experience';
const allCustomLanguageTags: LanguageTag[] = [];
const findAllCustomLanguageTags = jest.fn(async () => allCustomLanguageTags);
const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<LogtoConnector[]>
>;
const findDefaultSignInExperience = jest.fn() as jest.MockedFunction<
() => Promise<SignInExperience>
>;
jest.mock('@/queries/custom-phrase', () => ({
findAllCustomLanguageTags: async () => findAllCustomLanguageTags(),
}));
jest.mock('@/connectors', () => ({
getLogtoConnectors: async () => getLogtoConnectorsPlaceHolder(),
}));
jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
})
),
}));
beforeEach(() => {
jest.clearAllMocks();
});
@ -123,3 +154,25 @@ describe('validate terms of use', () => {
}).toMatchError(new RequestError('sign_in_experiences.empty_content_url_of_terms_of_use'));
});
});
describe('remove unavailable social connector targets', () => {
test('should remove unavailable social connector targets in sign-in experience', async () => {
const mockSocialConnectorTargets = mockSocialConnectors.map(
({ metadata: { target } }) => target
);
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
socialSignInConnectorTargets: mockSocialConnectorTargets,
});
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockSocialConnectors);
expect(mockSocialConnectorTargets).toEqual([
disabledSocialTarget01,
enabledSocialTarget01,
disabledSocialTarget02,
]);
await removeUnavailableSocialConnectorTargets();
expect(updateDefaultSignInExperience).toBeCalledWith({
socialSignInConnectorTargets: [enabledSocialTarget01],
});
});
});

View file

@ -1,9 +1,14 @@
import { builtInLanguages } from '@logto/phrases-ui';
import type { Branding, LanguageInfo, TermsOfUse } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import { ConnectorType, BrandingStyle } from '@logto/schemas';
import { getLogtoConnectors } from '@/connectors';
import RequestError from '@/errors/RequestError';
import { findAllCustomLanguageTags } from '@/queries/custom-phrase';
import {
findDefaultSignInExperience,
updateDefaultSignInExperience,
} from '@/queries/sign-in-experience';
import assertThat from '@/utils/assert-that';
export * from './sign-up';
@ -35,3 +40,19 @@ export const validateTermsOfUse = (termsOfUse: TermsOfUse) => {
'sign_in_experiences.empty_content_url_of_terms_of_use'
);
};
export const removeUnavailableSocialConnectorTargets = async () => {
const connectors = await getLogtoConnectors();
const availableSocialConnectorTargets = new Set(
connectors
.filter(({ type, dbEntry: { enabled } }) => enabled && type === ConnectorType.Social)
.map(({ metadata: { target } }) => target)
);
const { socialSignInConnectorTargets } = await findDefaultSignInExperience();
await updateDefaultSignInExperience({
socialSignInConnectorTargets: socialSignInConnectorTargets.filter((target) =>
availableSocialConnectorTargets.has(target)
),
});
};

View file

@ -7,6 +7,7 @@ import { object, string } from 'zod';
import { getLogtoConnectorById, getLogtoConnectors } from '@/connectors';
import type { LogtoConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { removeUnavailableSocialConnectorTargets } from '@/lib/sign-in-experience';
import koaGuard from '@/middleware/koa-guard';
import { updateConnector } from '@/queries/connector';
import assertThat from '@/utils/assert-that';
@ -115,6 +116,12 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
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();

View file

@ -46,6 +46,10 @@ jest.mock('@/connectors', () => ({
getLogtoConnectorById: async (connectorId: string) =>
getLogtoConnectorByIdPlaceholder(connectorId),
}));
jest.mock('@/lib/sign-in-experience', () => ({
// eslint-disable-next-line @typescript-eslint/no-empty-function
removeUnavailableSocialConnectorTargets: async () => {},
}));
describe('connector PATCH routes', () => {
const connectorRequest = createRequester({ authedRoutes: connectorRoutes });

View file

@ -1,3 +1,4 @@
import { PasscodeType } from '@logto/schemas';
import { addDays, subSeconds } from 'date-fns';
import { Provider } from 'oidc-provider';
@ -138,6 +139,7 @@ describe('session -> continueRoutes', () => {
continueSignIn: {
userId: mockUser.id,
expiresAt: getTomorrowIsoString(),
type: PasscodeType.Continue,
},
},
});
@ -169,6 +171,7 @@ describe('session -> continueRoutes', () => {
continueSignIn: {
userId: mockUser.id,
expiresAt: getTomorrowIsoString(),
type: PasscodeType.Continue,
},
},
});

View file

@ -17,7 +17,7 @@ import {
import assertThat from '@/utils/assert-that';
import type { AnonymousRouter } from '../types';
import { emailSessionResultGuard, smsSessionResultGuard } from './types';
import { continueEmailSessionResultGuard, continueSmsSessionResultGuard } from './types';
import {
checkRequiredProfile,
getContinueSignInResult,
@ -104,7 +104,7 @@ export default function continueRoutes<T extends AnonymousRouter>(router: T, pro
const { email } = await getVerificationStorageFromInteraction(
ctx,
provider,
emailSessionResultGuard
continueEmailSessionResultGuard
);
const user = await findUserById(userId);
@ -138,7 +138,7 @@ export default function continueRoutes<T extends AnonymousRouter>(router: T, pro
const { phone } = await getVerificationStorageFromInteraction(
ctx,
provider,
smsSessionResultGuard
continueSmsSessionResultGuard
);
const user = await findUserById(userId);

View file

@ -117,13 +117,21 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
return next();
}
await assignVerificationResult(ctx, provider, { flow, phone });
if (flow === PasscodeType.SignIn) {
await assignVerificationResult(ctx, provider, { flow, phone });
return smsSignInAction(provider)(ctx, next);
}
return smsRegisterAction(provider)(ctx, next);
if (flow === PasscodeType.Register) {
await assignVerificationResult(ctx, provider, { flow, phone });
return smsRegisterAction(provider)(ctx, next);
}
await assignVerificationResult(ctx, provider, { flow, phone });
return next();
}
);
@ -161,13 +169,21 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
return next();
}
await assignVerificationResult(ctx, provider, { flow, email });
if (flow === PasscodeType.SignIn) {
await assignVerificationResult(ctx, provider, { flow, email });
return emailSignInAction(provider)(ctx, next);
}
return emailRegisterAction(provider)(ctx, next);
if (flow === PasscodeType.Register) {
await assignVerificationResult(ctx, provider, { flow, email });
return emailRegisterAction(provider)(ctx, next);
}
await assignVerificationResult(ctx, provider, { flow, email });
return next();
}
);

View file

@ -45,10 +45,36 @@ export const forgotPasswordSessionResultGuard = z.object({
verification: forgotPasswordSessionStorageGuard,
});
const continueEmailSessionStorageGuard = z.object({
flow: z.literal(PasscodeType.Continue),
expiresAt: z.string(),
email: z.string(),
});
export type ContinueEmailSessionStorage = z.infer<typeof continueEmailSessionStorageGuard>;
export const continueEmailSessionResultGuard = z.object({
verification: continueEmailSessionStorageGuard,
});
const continueSmsSessionStorageGuard = z.object({
flow: z.literal(PasscodeType.Continue),
expiresAt: z.string(),
phone: z.string(),
});
export type ContinueSmsSessionStorage = z.infer<typeof continueSmsSessionStorageGuard>;
export const continueSmsSessionResultGuard = z.object({
verification: continueSmsSessionStorageGuard,
});
export type VerificationStorage =
| SmsSessionStorage
| EmailSessionStorage
| ForgotPasswordSessionStorage;
| ForgotPasswordSessionStorage
| ContinueEmailSessionStorage
| ContinueSmsSessionStorage;
export type VerificationResult<T = VerificationStorage> = { verification: T };

View file

@ -17,6 +17,8 @@ const connector_details = {
type_email: 'E-Mail connector',
type_sms: 'SMS connector',
type_social: 'Social connector',
in_use_deletion_description:
'This connector is in use in your sign in experience. By deleting, <name/> sign in experience will be deleted in sign in experience settings.', // UNTRANSLATED
};
export default connector_details;

View file

@ -17,6 +17,8 @@ const connector_details = {
type_email: 'Email connector',
type_sms: 'SMS connector',
type_social: 'Social connector',
in_use_deletion_description:
'This connector is in use in your sign in experience. By deleting, <name/> sign in experience will be deleted in sign in experience settings.',
};
export default connector_details;

View file

@ -17,6 +17,8 @@ const connector_details = {
type_email: 'Connecteur Email',
type_sms: 'Connecteur SMS',
type_social: 'Connecteur Social',
in_use_deletion_description:
'This connector is in use in your sign in experience. By deleting, <name/> sign in experience will be deleted in sign in experience settings.', // UNTRANSLATED
};
export default connector_details;

View file

@ -17,6 +17,8 @@ const connector_details = {
type_email: '이메일 연동',
type_sms: 'SMS 연동',
type_social: '소셜 연동',
in_use_deletion_description:
'This connector is in use in your sign in experience. By deleting, <name/> sign in experience will be deleted in sign in experience settings.', // UNTRANSLATED
};
export default connector_details;

View file

@ -17,6 +17,8 @@ const connector_details = {
type_email: 'Conector de Email',
type_sms: 'Conector de SMS',
type_social: 'Conector Social',
in_use_deletion_description:
'This connector is in use in your sign in experience. By deleting, <name/> sign in experience will be deleted in sign in experience settings.', // UNTRANSLATED
};
export default connector_details;

View file

@ -17,6 +17,8 @@ const connector_details = {
type_email: 'Eposta connectorı',
type_sms: 'SMS connectorı',
type_social: 'Social connector',
in_use_deletion_description:
'This connector is in use in your sign in experience. By deleting, <name/> sign in experience will be deleted in sign in experience settings.', // UNTRANSLATED
};
export default connector_details;

View file

@ -17,6 +17,8 @@ const connector_details = {
type_email: '邮件连接器',
type_sms: '短信连接器',
type_social: '社交连接器',
in_use_deletion_description:
'This connector is in use in your sign in experience. By deleting, <name/> sign in experience will be deleted in sign in experience settings.', // UNTRANSLATED
};
export default connector_details;

View file

@ -0,0 +1,19 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter type passcode_type add value 'Continue'
`);
},
down: async (pool) => {
await pool.query(sql`
drop type passcode_type
create type passcode_type as enum ('SignIn', 'Register', 'ForgotPassword');
`);
},
};
export default alteration;

View file

@ -1,4 +1,4 @@
create type passcode_type as enum ('SignIn', 'Register', 'ForgotPassword');
create type passcode_type as enum ('SignIn', 'Register', 'ForgotPassword', 'Continue');
create table passcodes (
id varchar(21) not null,