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

refactor(ui): replace social flow api (#2751)

This commit is contained in:
simeng-li 2022-12-28 18:24:58 +08:00 committed by GitHub
parent e02f7e5ac2
commit be2c65dc0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 140 additions and 174 deletions

View file

@ -2,12 +2,21 @@ import { maskUserInfo } from './format.js';
describe('maskUserInfo', () => {
it('phone', () => {
expect(maskUserInfo({ type: 'phone', value: '1234567890' })).toBe('****7890');
expect(maskUserInfo({ type: 'phone', value: '1234567890' })).toEqual({
type: 'phone',
value: '****7890',
});
});
it('email with name less than 5', () => {
expect(maskUserInfo({ type: 'email', value: 'test@logto.io' })).toBe('****@logto.io');
expect(maskUserInfo({ type: 'email', value: 'test@logto.io' })).toEqual({
type: 'email',
value: '****@logto.io',
});
});
it('email with name more than 4', () => {
expect(maskUserInfo({ type: 'email', value: 'foo_test@logto.io' })).toBe('foo_****@logto.io');
expect(maskUserInfo({ type: 'email', value: 'foo_test@logto.io' })).toEqual({
type: 'email',
value: 'foo_****@logto.io',
});
});
});

View file

@ -1,17 +1,26 @@
export const maskUserInfo = ({ type, value }: { type: 'email' | 'phone'; value: string }) => {
export const maskUserInfo = (info: { type: 'email' | 'phone'; value: string }) => {
const { type, value } = info;
if (!value) {
return value;
return info;
}
if (type === 'phone') {
return `****${value.slice(-4)}`;
return {
type,
value: `****${value.slice(-4)}`,
};
}
// Email
const [name = '', domain = ''] = value.split('@');
const preview = name.length > 4 ? `${name.slice(0, 4)}` : '';
return `${preview}****@${domain}`;
return {
type,
value: `${preview}****@${domain}`,
};
};
export const stringifyError = (error: Error) =>

View file

@ -1,13 +1,6 @@
import ky from 'ky';
import { consent } from './consent';
import {
invokeSocialSignIn,
signInWithSocial,
bindSocialAccount,
bindSocialRelatedUser,
registerWithSocial,
} from './social';
jest.mock('ky', () => ({
extend: () => ky,
@ -17,10 +10,6 @@ jest.mock('ky', () => ({
}));
describe('api', () => {
const phone = '18888888';
const code = '111111';
const email = 'foo@logto.io';
const mockKyPost = ky.post as jest.Mock;
afterEach(() => {
@ -31,56 +20,4 @@ describe('api', () => {
await consent();
expect(ky.post).toBeCalledWith('/api/session/consent');
});
it('invokeSocialSignIn', async () => {
await invokeSocialSignIn('connectorId', 'state', 'redirectUri');
expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {
json: {
connectorId: 'connectorId',
state: 'state',
redirectUri: 'redirectUri',
},
});
});
it('signInWithSocial', async () => {
const parameters = {
connectorId: 'connectorId',
data: {
redirectUri: 'redirectUri',
code: 'code',
},
};
await signInWithSocial(parameters);
expect(ky.post).toBeCalledWith('/api/session/sign-in/social/auth', {
json: parameters,
});
});
it('bindSocialAccount', async () => {
await bindSocialAccount('connectorId');
expect(ky.post).toBeCalledWith('/api/session/bind-social', {
json: {
connectorId: 'connectorId',
},
});
});
it('bindSocialRelatedUser', async () => {
await bindSocialRelatedUser('connectorId');
expect(ky.post).toBeCalledWith('/api/session/sign-in/bind-social-related-user', {
json: {
connectorId: 'connectorId',
},
});
});
it('registerWithSocial', async () => {
await registerWithSocial('connectorId');
expect(ky.post).toBeCalledWith('/api/session/register/social', {
json: {
connectorId: 'connectorId',
},
});
});
});

View file

@ -7,6 +7,8 @@ import type {
PhonePasswordPayload,
EmailPasscodePayload,
PhonePasscodePayload,
SocialConnectorPayload,
SocialIdentityPayload,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
@ -28,15 +30,20 @@ export const signInWithPasswordIdentifier = async (
payload: PasswordSignInPayload,
socialToBind?: string
) => {
await api.put(`${interactionPrefix}`, {
json: {
event: InteractionEvent.SignIn,
identifier: payload,
},
});
if (socialToBind) {
// TODO: bind social account
await api.patch(`${interactionPrefix}/identifiers`, {
json: payload,
});
await api.patch(`${interactionPrefix}/profile`, {
json: { connectorId: socialToBind },
});
} else {
await api.put(`${interactionPrefix}`, {
json: {
event: InteractionEvent.SignIn,
identifier: payload,
},
});
}
return api.post(`${interactionPrefix}/submit`).json<Response>();
@ -89,9 +96,9 @@ export const signInWithPasscodeIdentifier = async (
json: payload,
});
if (socialToBind) {
// TODO: bind social account
}
await api.patch(`${interactionPrefix}/profile`, {
json: { connectorId: socialToBind },
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
@ -110,9 +117,9 @@ export const addProfileWithPasscodeIdentifier = async (
json: identifier,
});
if (socialToBind) {
// TODO: bind social account
}
await api.patch(`${interactionPrefix}/profile`, {
json: { connectorId: socialToBind },
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
@ -159,9 +166,65 @@ export const addProfile = async (
) => {
await api.patch(`${interactionPrefix}/profile`, { json: payload });
if (socialToBind) {
// TODO: bind social account
}
await api.patch(`${interactionPrefix}/profile`, {
json: { connectorId: socialToBind },
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const getSocialAuthorizationUrl = async (
connectorId: string,
state: string,
redirectUri: string
) => {
await api.put(`${interactionPrefix}`, { json: { event: InteractionEvent.SignIn } });
return api
.post(`${interactionPrefix}/${verificationPath}/social-authorization-uri`, {
json: {
connectorId,
state,
redirectUri,
},
})
.json<Response>();
};
export const signInWithSocial = async (payload: SocialConnectorPayload) => {
await api.patch(`${interactionPrefix}/identifiers`, {
json: payload,
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const registerWithVerifiedSocial = async (connectorId: string) => {
await api.put(`${interactionPrefix}/event`, {
json: {
event: InteractionEvent.Register,
},
});
await api.patch(`${interactionPrefix}/profile`, {
json: {
connectorId,
},
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const bindSocialRelatedUser = async (payload: SocialIdentityPayload) => {
await api.patch(`${interactionPrefix}/identifiers`, {
json: payload,
});
await api.patch(`${interactionPrefix}/profile`, {
json: {
connectorId: payload.connectorId,
},
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};

View file

@ -1,37 +1,5 @@
import api from './api';
export const invokeSocialSignIn = async (
connectorId: string,
state: string,
redirectUri: string
) => {
type Response = {
redirectTo: string;
};
return api
.post('/api/session/sign-in/social', {
json: {
connectorId,
state,
redirectUri,
},
})
.json<Response>();
};
export const signInWithSocial = async (json: { connectorId: string; data: unknown }) => {
type Response = {
redirectTo: string;
};
return api
.post('/api/session/sign-in/social/auth', {
json,
})
.json<Response>();
};
export const bindSocialAccount = async (connectorId: string) => {
return api
.post('/api/session/bind-social', {
@ -41,31 +9,3 @@ export const bindSocialAccount = async (connectorId: string) => {
})
.json();
};
export const bindSocialRelatedUser = async (connectorId: string) => {
type Response = {
redirectTo: string;
};
return api
.post('/api/session/sign-in/bind-social-related-user', {
json: {
connectorId,
},
})
.json<Response>();
};
export const registerWithSocial = async (connectorId: string) => {
type Response = {
redirectTo: string;
};
return api
.post('/api/session/register/social', {
json: {
connectorId,
},
})
.json<Response>();
};

View file

@ -1,6 +1,7 @@
import { InteractionEvent } from '@logto/schemas';
import { UserFlow } from '@/types';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import type { SendPasscodePayload } from './interaction';
import { putInteraction, sendPasscode } from './interaction';
@ -10,7 +11,10 @@ export const getSendPasscodeApi = (type: UserFlow) => async (payload: SendPassco
await putInteraction(InteractionEvent.ForgotPassword);
}
if (type === UserFlow.signIn) {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
// Init a new interaction only if the user is not binding with a social
if (type === UserFlow.signIn && !socialToBind) {
await putInteraction(InteractionEvent.SignIn);
}

View file

@ -2,7 +2,7 @@ import { fireEvent, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
import { registerWithVerifiedSocial, bindSocialRelatedUser } from '@/apis/interaction';
import SocialCreateAccount from '.';
@ -11,12 +11,12 @@ const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
useLocation: () => ({ state: { relatedUser: 'foo@logto.io' } }),
useLocation: () => ({ state: { relatedUser: { type: 'email', value: 'foo@logto.io' } } }),
}));
jest.mock('@/apis/social', () => ({
registerWithSocial: jest.fn(async () => 0),
bindSocialRelatedUser: jest.fn(async () => 0),
jest.mock('@/apis/interaction', () => ({
registerWithVerifiedSocial: jest.fn(async () => ({ redirectTo: '/' })),
bindSocialRelatedUser: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('SocialCreateAccount', () => {
@ -31,7 +31,7 @@ describe('SocialCreateAccount', () => {
expect(queryByText('secondary.social_bind_with')).not.toBeNull();
});
it('should call registerWithSocial when click create button', async () => {
it('should call registerWithVerifiedSocial when click create button', async () => {
const { getByText } = renderWithPageContext(<SocialCreateAccount connectorId="github" />);
const createButton = getByText('action.create');
@ -39,7 +39,7 @@ describe('SocialCreateAccount', () => {
fireEvent.click(createButton);
});
expect(registerWithSocial).toBeCalledWith('github');
expect(registerWithVerifiedSocial).toBeCalledWith('github');
});
it('should render bindUser Button when relatedUserInfo found', async () => {
@ -48,6 +48,6 @@ describe('SocialCreateAccount', () => {
await waitFor(() => {
fireEvent.click(bindButton);
});
expect(bindSocialRelatedUser).toBeCalledWith('github');
expect(bindSocialRelatedUser).toBeCalledWith({ connectorId: 'github', identityType: 'email' });
});
});

View file

@ -27,9 +27,9 @@ const SocialCreateAccount = ({ connectorId, className }: Props) => {
<div className={styles.desc}>{t('description.social_bind_with_existing')}</div>
<Button
title="action.bind"
i18nProps={{ address: relatedUser }}
i18nProps={{ address: relatedUser.value }}
onClick={() => {
bindSocialRelatedUser(connectorId);
bindSocialRelatedUser({ connectorId, identityType: relatedUser.type });
}}
/>
</>

View file

@ -1,9 +1,10 @@
import type { SocialIdentityPayload } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useCallback, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
import { registerWithVerifiedSocial, bindSocialRelatedUser } from '@/apis/interaction';
import useApi from '@/hooks/use-api';
import { bindSocialStateGuard } from '@/types/guard';
@ -14,7 +15,7 @@ const useBindSocial = () => {
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(
registerWithSocial,
registerWithVerifiedSocial,
requiredProfileErrorHandlers
);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(
@ -30,8 +31,8 @@ const useBindSocial = () => {
);
const bindRelatedUserHandler = useCallback(
(connectorId: string) => {
void asyncBindSocialRelatedUser(connectorId);
(payload: SocialIdentityPayload) => {
void asyncBindSocialRelatedUser(payload);
},
[asyncBindSocialRelatedUser]
);

View file

@ -3,7 +3,7 @@ import { useEffect, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useNavigate } from 'react-router-dom';
import { signInWithSocial } from '@/apis/social';
import { signInWithSocial } from '@/apis/interaction';
import { parseQueryParameters } from '@/utils';
import { stateValidation } from '@/utils/social-connectors';
@ -57,7 +57,7 @@ const useSocialSignInListener = () => {
async (connectorId: string, data: Record<string, unknown>) => {
void asyncSignInWithSocial({
connectorId,
data: {
connectorData: {
redirectUri: `${window.location.origin}/callback/${connectorId}`, // For validation use only
...data,
},

View file

@ -1,7 +1,7 @@
import type { ConnectorMetadata } from '@logto/schemas';
import { useCallback, useContext } from 'react';
import { invokeSocialSignIn } from '@/apis/social';
import { getSocialAuthorizationUrl } from '@/apis/interaction';
import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk';
import { generateState, storeState, buildSocialLandingUri } from '@/utils/social-connectors';
@ -13,7 +13,7 @@ const useSocial = () => {
const { experienceSettings, theme } = useContext(PageContext);
const { termsValidation } = useTerms();
const { run: asyncInvokeSocialSignIn } = useApi(invokeSocialSignIn);
const { run: asyncInvokeSocialSignIn } = useApi(getSocialAuthorizationUrl);
const nativeSignInHandler = useCallback((redirectTo: string, connector: ConnectorMetadata) => {
const { id: connectorId, platform } = connector;

View file

@ -3,18 +3,18 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import * as socialSignInApi from '@/apis/social';
import { signInWithSocial } from '@/apis/interaction';
import { generateState, storeState } from '@/utils/social-connectors';
import SocialCallback from '.';
const origin = 'http://localhost:3000';
describe('SocialCallbackPage with code', () => {
const signInWithSocialSpy = jest
.spyOn(socialSignInApi, 'signInWithSocial')
.mockResolvedValue({ redirectTo: `/sign-in` });
jest.mock('@/apis/interaction', () => ({
signInWithSocial: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }),
}));
describe('SocialCallbackPage with code', () => {
it('callback validation and signIn with social', async () => {
const state = generateState();
storeState(state, 'github');
@ -43,7 +43,7 @@ describe('SocialCallbackPage with code', () => {
await act(async () => {
await waitFor(() => {
expect(signInWithSocialSpy).toBeCalled();
expect(signInWithSocial).toBeCalled();
});
});
});

View file

@ -2,7 +2,10 @@ import { SignInIdentifier, MissingProfile } from '@logto/schemas';
import * as s from 'superstruct';
export const bindSocialStateGuard = s.object({
relatedUser: s.optional(s.string()),
relatedUser: s.object({
type: s.union([s.literal('email'), s.literal('phone')]),
value: s.string(),
}),
});
export const passcodeStateGuard = s.object({