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

refactor(ui): replace continue api with patchProfile (#2744)

This commit is contained in:
simeng-li 2022-12-28 15:33:31 +08:00 committed by GitHub
parent 06ec86d831
commit e02f7e5ac2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 110 additions and 175 deletions

View file

@ -153,7 +153,7 @@ describe('verifyUserAccount', () => {
}; };
await expect(verifyUserAccount(interaction)).rejects.toMatchError( await expect(verifyUserAccount(interaction)).rejects.toMatchError(
new RequestError({ code: 'user.user_not_exist', status: 404 }, { identity: 'email' }) new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: 'email' })
); );
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });

View file

@ -26,7 +26,7 @@ const identifyUserByVerifiedEmailOrPhone = async (
assertThat( assertThat(
user, user,
new RequestError({ code: 'user.user_not_exist', status: 404 }, { identity: identifier.value }) new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: identifier.value })
); );
const { id, isSuspended } = user; const { id, isSuspended } = user;

View file

@ -62,7 +62,7 @@ const errors = {
email_or_phone_required_in_profile: email_or_phone_required_in_profile:
'You need to add an email address or phone number before signing-in.', 'You need to add an email address or phone number before signing-in.',
suspended: 'This account is suspended.', suspended: 'This account is suspended.',
user_not_exist: 'User with {{ identity }} does not exist.', user_not_exist: 'User with {{ identifier }} does not exist.',
missing_profile: 'You need to provide additional info before signing-in.', missing_profile: 'You need to provide additional info before signing-in.',
}, },
password: { password: {

View file

@ -62,7 +62,7 @@ const errors = {
email_or_phone_required_in_profile: email_or_phone_required_in_profile:
'Você precisa adicionar um endereço de e-mail ou número de telefone antes de fazer login.', 'Você precisa adicionar um endereço de e-mail ou número de telefone antes de fazer login.',
suspended: 'Esta conta está suspensa.', suspended: 'Esta conta está suspensa.',
user_not_exist: 'O usuário com {{ identity }} não existe', user_not_exist: 'O usuário com {{ identifier }} não existe',
missing_profile: 'Você precisa fornecer informações adicionais antes de fazer login.', missing_profile: 'Você precisa fornecer informações adicionais antes de fazer login.',
}, },
password: { password: {

View file

@ -59,7 +59,7 @@ const errors = {
phone_exists_in_profile: '当前用户已绑定手机号,无需重复操作。', phone_exists_in_profile: '当前用户已绑定手机号,无需重复操作。',
email_or_phone_required_in_profile: '请绑定邮箱地址或手机号码。', email_or_phone_required_in_profile: '请绑定邮箱地址或手机号码。',
suspended: '账号已被禁用。', suspended: '账号已被禁用。',
user_not_exist: '未找到与 {{ identity }} 相关联的用户。', user_not_exist: '未找到与 {{ identifier }} 相关联的用户。',
missing_profile: '请于登录时提供必要的用户补充信息。', missing_profile: '请于登录时提供必要的用户补充信息。',
}, },
password: { password: {

View file

@ -1,64 +0,0 @@
import ky from 'ky';
import { continueApi } from './continue';
jest.mock('ky', () => ({
extend: () => ky,
post: jest.fn(() => ({
json: jest.fn(),
})),
}));
describe('continue API', () => {
const mockKyPost = ky.post as jest.Mock;
beforeEach(() => {
mockKyPost.mockReturnValueOnce({
json: () => ({
redirectTo: '/',
}),
});
});
afterEach(() => {
mockKyPost.mockClear();
});
it('continue with password', async () => {
await continueApi('password', 'password');
expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/password', {
json: {
password: 'password',
},
});
});
it('continue with username', async () => {
await continueApi('username', 'username');
expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/username', {
json: {
username: 'username',
},
});
});
it('continue with email', async () => {
await continueApi('email', 'email');
expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/email', {
json: {
email: 'email',
},
});
});
it('continue with phone', async () => {
await continueApi('phone', 'phone');
expect(ky.post).toBeCalledWith('/api/session/sign-in/continue/sms', {
json: {
phone: 'phone',
},
});
});
});

View file

@ -1,24 +0,0 @@
import api from './api';
import { bindSocialAccount } from './social';
type Response = {
redirectTo: string;
};
const continueApiPrefix = '/api/session/sign-in/continue';
type ContinueKey = 'password' | 'username' | 'email' | 'phone';
export const continueApi = async (key: ContinueKey, value: string, socialToBind?: string) => {
const result = await api
.post(`${continueApiPrefix}/${key === 'phone' ? 'sms' : key}`, {
json: { [key]: value },
})
.json<Response>();
if (result.redirectTo && socialToBind) {
await bindSocialAccount(socialToBind);
}
return result;
};

View file

@ -152,3 +152,16 @@ export const registerWithVerifiedIdentifier = async (payload: SendPasscodePayloa
return api.post(`${interactionPrefix}/submit`).json<Response>(); return api.post(`${interactionPrefix}/submit`).json<Response>();
}; };
export const addProfile = async (
payload: { username: string } | { password: string },
socialToBind?: string
) => {
await api.patch(`${interactionPrefix}/profile`, { json: payload });
if (socialToBind) {
// TODO: bind social account
}
return api.post(`${interactionPrefix}/submit`).json<Response>();
};

View file

@ -24,7 +24,7 @@ const useContinueSetEmailPasscodeValidation = (email: string, errorCallback?: ()
const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo( const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
'user.email_not_exist': identifierNotExistErrorHandler, 'user.email_already_in_use': identifierNotExistErrorHandler,
...requiredProfileErrorHandler, ...requiredProfileErrorHandler,
...sharedErrorHandlers, ...sharedErrorHandlers,
callback: errorCallback, callback: errorCallback,

View file

@ -16,7 +16,7 @@ const useContinueSetSmsPasscodeValidation = (phone: string, errorCallback?: () =
const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true); const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true);
const identifierNotExistErrorHandler = useIdentifierErrorAlert( const identifierExistErrorHandler = useIdentifierErrorAlert(
UserFlow.continue, UserFlow.continue,
SignInIdentifier.Sms, SignInIdentifier.Sms,
phone phone
@ -24,17 +24,12 @@ const useContinueSetSmsPasscodeValidation = (phone: string, errorCallback?: () =
const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo( const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
'user.phone_not_exist': identifierNotExistErrorHandler, 'user.phone_already_in_use': identifierExistErrorHandler,
...requiredProfileErrorHandler, ...requiredProfileErrorHandler,
...sharedErrorHandlers, ...sharedErrorHandlers,
callback: errorCallback, callback: errorCallback,
}), }),
[ [errorCallback, identifierExistErrorHandler, requiredProfileErrorHandler, sharedErrorHandlers]
errorCallback,
identifierNotExistErrorHandler,
requiredProfileErrorHandler,
sharedErrorHandlers,
]
); );
const { run: verifyPasscode } = useApi( const { run: verifyPasscode } = useApi(

View file

@ -22,7 +22,7 @@ const useForgotPasswordEmailPasscodeValidation = (email: string, errorCallback?:
const errorHandlers: ErrorHandlers = useMemo( const errorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
'user.email_not_exist': identifierNotExistErrorHandler, 'user.user_not_exist': identifierNotExistErrorHandler,
'user.new_password_required_in_profile': () => { 'user.new_password_required_in_profile': () => {
navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true }); navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true });
}, },

View file

@ -22,7 +22,7 @@ const useForgotPasswordSmsPasscodeValidation = (phone: string, errorCallback?: (
const errorHandlers: ErrorHandlers = useMemo( const errorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
'user.phone_not_exist': identifierNotExistErrorHandler, 'user.user_not_exist': identifierNotExistErrorHandler,
'user.new_password_required_in_profile': () => { 'user.new_password_required_in_profile': () => {
navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true }); navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true });
}, },

View file

@ -20,7 +20,7 @@ const useIdentifierErrorAlert = (
await show({ await show({
type: 'alert', type: 'alert',
ModalContent: t( ModalContent: t(
flow === UserFlow.register flow === UserFlow.register || flow === UserFlow.continue
? 'description.create_account_id_exists_alert' ? 'description.create_account_id_exists_alert'
: 'description.sign_in_id_does_not_exist_alert', : 'description.sign_in_id_does_not_exist_alert',
{ {

View file

@ -62,7 +62,7 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: ()
const errorHandlers = useMemo<ErrorHandlers>( const errorHandlers = useMemo<ErrorHandlers>(
() => ({ () => ({
'user.email_not_exist': 'user.user_not_exist':
// Block user auto register if is bind social or sign-in only flow // Block user auto register if is bind social or sign-in only flow
signInMode === SignInMode.SignIn || socialToBind signInMode === SignInMode.SignIn || socialToBind
? identifierNotExistErrorHandler ? identifierNotExistErrorHandler

View file

@ -62,7 +62,7 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () =>
const errorHandlers = useMemo<ErrorHandlers>( const errorHandlers = useMemo<ErrorHandlers>(
() => ({ () => ({
'user.phone_not_exist': 'user.user_not_exist':
// Block user auto register if is bind social or sign-in only flow // Block user auto register if is bind social or sign-in only flow
signInMode === SignInMode.SignIn || socialToBind signInMode === SignInMode.SignIn || socialToBind
? identifierNotExistErrorHandler ? identifierNotExistErrorHandler

View file

@ -1,7 +1,7 @@
import { fireEvent, act, waitFor } from '@testing-library/react'; import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { continueApi } from '@/apis/continue'; import { addProfile } from '@/apis/interaction';
import SetUsername from '.'; import SetUsername from '.';
@ -12,8 +12,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('@/apis/continue', () => ({ jest.mock('@/apis/interaction', () => ({
continueApi: jest.fn(async () => ({})), addProfile: jest.fn(async () => ({})),
})); }));
describe('<UsernameRegister />', () => { describe('<UsernameRegister />', () => {
@ -37,7 +37,7 @@ describe('<UsernameRegister />', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(continueApi).toBeCalledWith('username', 'username', undefined); expect(addProfile).toBeCalledWith({ username: 'username' }, undefined);
}); });
}); });
}); });

View file

@ -1,7 +1,7 @@
import { useState, useCallback, useMemo, useEffect } from 'react'; import { useState, useCallback, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { continueApi } from '@/apis/continue'; import { addProfile } from '@/apis/interaction';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-api';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
@ -28,14 +28,14 @@ const useSetUsername = () => {
[requiredProfileErrorHandler] [requiredProfileErrorHandler]
); );
const { result, run: setUsername } = useApi(continueApi, errorHandlers); const { result, run: asyncAddProfile } = useApi(addProfile, errorHandlers);
const onSubmit = useCallback( const onSubmit = useCallback(
async (username: string) => { async (username: string) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
await setUsername('username', username, socialToBind); await asyncAddProfile({ username }, socialToBind);
}, },
[setUsername] [asyncAddProfile]
); );
useEffect(() => { useEffect(() => {

View file

@ -1,60 +1,63 @@
import { useMemo } from 'react'; import { MissingProfile } from '@logto/schemas';
import { useMemo, useContext } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { validate } from 'superstruct';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
import { missingProfileErrorDataGuard } from '@/types/guard';
import type { ErrorHandlers } from './use-api';
import { PageContext } from './use-page-context';
const useRequiredProfileErrorHandler = (replace?: boolean) => { const useRequiredProfileErrorHandler = (replace?: boolean) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { setToast } = useContext(PageContext);
const requiredProfileErrorHandler = useMemo( const requiredProfileErrorHandler = useMemo<ErrorHandlers>(
() => ({ () => ({
'user.password_required_in_profile': () => { 'user.missing_profile': (error) => {
navigate( const [, data] = validate(error.data, missingProfileErrorDataGuard);
{ const missingProfile = data?.missingProfile[0];
pathname: `/${UserFlow.continue}/password`,
search: location.search, switch (missingProfile) {
}, case MissingProfile.password:
{ replace } case MissingProfile.username:
); case MissingProfile.email:
}, navigate(
'user.username_required_in_profile': () => { {
navigate( pathname: `/${UserFlow.continue}/${missingProfile}`,
{ search: location.search,
pathname: `/${UserFlow.continue}/username`, },
search: location.search, { replace }
}, );
{ replace } break;
); case MissingProfile.phone:
}, navigate(
'user.email_required_in_profile': () => { {
navigate( pathname: `/${UserFlow.continue}/sms`,
{ search: location.search,
pathname: `/${UserFlow.continue}/email`, },
search: location.search, { replace }
}, );
{ replace } break;
); case MissingProfile.emailOrPhone:
}, navigate(
'user.phone_required_in_profile': () => { {
navigate( pathname: `/${UserFlow.continue}/email-or-sms/email`,
{ search: location.search,
pathname: `/${UserFlow.continue}/sms`, },
search: location.search, { replace }
}, );
{ replace } break;
);
}, default: {
'user.email_or_phone_required_in_profile': () => { setToast(error.message);
navigate( break;
{ }
pathname: `/${UserFlow.continue}/email-or-sms/email`, }
search: location.search,
},
{ replace }
);
}, },
}), }),
[navigate, replace] [navigate, replace, setToast]
); );
return requiredProfileErrorHandler; return requiredProfileErrorHandler;

View file

@ -2,7 +2,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { continueApi } from '@/apis/continue'; import { addProfile } from '@/apis/interaction';
import SetPassword from '.'; import SetPassword from '.';
@ -13,8 +13,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('@/apis/continue', () => ({ jest.mock('@/apis/interaction', () => ({
continueApi: jest.fn(async () => ({ redirectTo: '/' })), addProfile: jest.fn(async () => ({ redirectTo: '/' })),
})); }));
describe('SetPassword', () => { describe('SetPassword', () => {
@ -52,7 +52,7 @@ describe('SetPassword', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(continueApi).toBeCalledWith('password', '123456', undefined); expect(addProfile).toBeCalledWith({ password: '123456' }, undefined);
}); });
}); });
}); });

View file

@ -1,7 +1,7 @@
import { useMemo, useEffect, useCallback } from 'react'; import { useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { continueApi } from '@/apis/continue'; import { addProfile } from '@/apis/interaction';
import type { ErrorHandlers } from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
@ -26,14 +26,14 @@ const useSetPassword = () => {
[navigate, requiredProfileErrorHandler, show] [navigate, requiredProfileErrorHandler, show]
); );
const { result, run: asyncSetPassword } = useApi(continueApi, errorHandlers); const { result, run: asyncAddProfile } = useApi(addProfile, errorHandlers);
const setPassword = useCallback( const setPassword = useCallback(
async (password: string) => { async (password: string) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
await asyncSetPassword('password', password, socialToBind); await asyncAddProfile({ password }, socialToBind);
}, },
[asyncSetPassword] [asyncAddProfile]
); );
useEffect(() => { useEffect(() => {

View file

@ -2,7 +2,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { continueApi } from '@/apis/continue'; import { addProfile } from '@/apis/interaction';
import SetUsername from '.'; import SetUsername from '.';
@ -13,8 +13,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('@/apis/continue', () => ({ jest.mock('@/apis/interaction', () => ({
continueApi: jest.fn(async () => ({ redirectTo: '/' })), addProfile: jest.fn(async () => ({ redirectTo: '/' })),
})); }));
describe('SetPassword', () => { describe('SetPassword', () => {
@ -46,7 +46,7 @@ describe('SetPassword', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(continueApi).toBeCalledWith('username', 'username', undefined); expect(addProfile).toBeCalledWith({ username: 'username' }, undefined);
}); });
}); });
}); });

View file

@ -1,4 +1,4 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier, MissingProfile } from '@logto/schemas';
import * as s from 'superstruct'; import * as s from 'superstruct';
export const bindSocialStateGuard = s.object({ export const bindSocialStateGuard = s.object({
@ -38,3 +38,15 @@ export const continueMethodGuard = s.union([
export const usernameGuard = s.object({ export const usernameGuard = s.object({
username: s.string(), username: s.string(),
}); });
export const missingProfileErrorDataGuard = s.object({
missingProfile: s.array(
s.union([
s.literal(MissingProfile.password),
s.literal(MissingProfile.email),
s.literal(MissingProfile.phone),
s.literal(MissingProfile.username),
s.literal(MissingProfile.emailOrPhone),
])
),
});