0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-03 21:48:55 -05:00

feat(ui): add bind social account flow (#671)

* refactor(ui): passdown bindWithSocial query params

passdown bindWithSocial query params

* feat(ui): add bindSocialAccount to the sign-in request

add bindSocialAccount to the sign-in requeset

* fix(ui): cr fix

cr fix
This commit is contained in:
simeng-li 2022-04-27 14:05:10 +08:00 committed by GitHub
parent e7faf32b5f
commit 5e251bdc08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 161 additions and 40 deletions

View file

@ -43,6 +43,11 @@ describe('api', () => {
});
it('signInBasic', async () => {
mockKyPost.mockReturnValueOnce({
json: () => ({
redirectTo: '/',
}),
});
await signInBasic(username, password);
expect(ky.post).toBeCalledWith('/api/session/sign-in/username-password', {
json: {
@ -52,6 +57,26 @@ describe('api', () => {
});
});
it('signInBasic with bind social account', async () => {
mockKyPost.mockReturnValueOnce({
json: () => ({
redirectTo: '/',
}),
});
await signInBasic(username, password, 'github');
expect(ky.post).toHaveBeenNthCalledWith(1, '/api/session/sign-in/username-password', {
json: {
username,
password,
},
});
expect(ky.post).toHaveBeenNthCalledWith(2, '/api/session/sign-in/bind-social', {
json: {
connectorId: 'github',
},
});
});
it('sendSignInSmsPasscode', async () => {
await sendSignInSmsPasscode(phone);
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms/send-passcode', {
@ -62,6 +87,11 @@ describe('api', () => {
});
it('verifySignInSmsPasscode', async () => {
mockKyPost.mockReturnValueOnce({
json: () => ({
redirectTo: '/',
}),
});
await verifySignInSmsPasscode(phone, passcode);
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms/verify-passcode', {
json: {
@ -81,6 +111,11 @@ describe('api', () => {
});
it('verifySignInEmailPasscode', async () => {
mockKyPost.mockReturnValueOnce({
json: () => ({
redirectTo: '/',
}),
});
await verifySignInEmailPasscode(email, passcode);
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email/verify-passcode', {
json: {

View file

@ -1,11 +1,13 @@
import ky from 'ky';
export const signInBasic = async (username: string, password: string) => {
import { bindSocialAccount } from './social';
export const signInBasic = async (username: string, password: string, socialToBind?: string) => {
type Response = {
redirectTo: string;
};
return ky
const result = await ky
.post('/api/session/sign-in/username-password', {
json: {
username,
@ -13,6 +15,12 @@ export const signInBasic = async (username: string, password: string) => {
},
})
.json<Response>();
if (result.redirectTo && socialToBind) {
await bindSocialAccount(socialToBind);
}
return result;
};
export const sendSignInSmsPasscode = async (phone: string) => {
@ -25,12 +33,16 @@ export const sendSignInSmsPasscode = async (phone: string) => {
.json();
};
export const verifySignInSmsPasscode = async (phone: string, passcode: string) => {
export const verifySignInSmsPasscode = async (
phone: string,
passcode: string,
socialToBind?: string
) => {
type Response = {
redirectTo: string;
};
return ky
const result = await ky
.post('/api/session/sign-in/passwordless/sms/verify-passcode', {
json: {
phone,
@ -38,6 +50,12 @@ export const verifySignInSmsPasscode = async (phone: string, passcode: string) =
},
})
.json<Response>();
if (result.redirectTo && socialToBind) {
await bindSocialAccount(socialToBind);
}
return result;
};
export const sendSignInEmailPasscode = async (email: string) => {
@ -50,12 +68,16 @@ export const sendSignInEmailPasscode = async (email: string) => {
.json();
};
export const verifySignInEmailPasscode = async (email: string, passcode: string) => {
export const verifySignInEmailPasscode = async (
email: string,
passcode: string,
socialToBind?: string
) => {
type Response = {
redirectTo: string;
};
return ky
const result = await ky
.post('/api/session/sign-in/passwordless/email/verify-passcode', {
json: {
email,
@ -63,4 +85,10 @@ export const verifySignInEmailPasscode = async (email: string, passcode: string)
},
})
.json<Response>();
if (result.redirectTo && socialToBind) {
await bindSocialAccount(socialToBind);
}
return result;
};

View file

@ -65,7 +65,7 @@ describe('<PasscodeValidation />', () => {
});
}
expect(verifyPasscodeApi).toBeCalledWith(email, '111111');
expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined);
});
});
});

View file

@ -10,7 +10,8 @@ import Passcode, { defaultLength } from '@/components/Passcode';
import TextLink from '@/components/TextLink';
import useApi from '@/hooks/use-api';
import { PageContext } from '@/hooks/use-page-context';
import { UserFlow } from '@/types';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import * as styles from './index.module.scss';
@ -55,7 +56,8 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
useEffect(() => {
if (code.length === defaultLength && code.every(Boolean)) {
void verifyPassCode(target, code.join(''));
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
void verifyPassCode(target, code.join(''), socialToBind);
}
}, [code, target, verifyPassCode]);

View file

@ -55,7 +55,13 @@ const EmailPasswordless = ({ type, className }: Props) => {
useEffect(() => {
if (result) {
navigate(`/${type}/email/passcode-validation`, { state: { email: fieldValue.email } });
navigate(
{
pathname: `/${type}/email/passcode-validation`,
search: location.search,
},
{ state: { email: fieldValue.email } }
);
}
}, [fieldValue.email, navigate, result, type]);

View file

@ -73,7 +73,10 @@ const PhonePasswordless = ({ type, className }: Props) => {
useEffect(() => {
if (result) {
navigate(`/${type}/sms/passcode-validation`, { state: { phone: fieldValue.phone } });
navigate(
{ pathname: `/${type}/sms/passcode-validation`, search: location.search },
{ state: { phone: fieldValue.phone } }
);
}
}, [fieldValue.phone, navigate, result, type]);

View file

@ -15,6 +15,7 @@
.desc {
@include _.text-hint;
margin-bottom: _.unit(2);
text-align: left;
&:not(:first-child) {
margin-top: _.unit(8);

View file

@ -1,7 +1,8 @@
import { render, fireEvent, waitFor } from '@testing-library/react';
import { fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
import SocialCreateAccount from '.';
@ -20,18 +21,15 @@ jest.mock('@/apis/social', () => ({
}));
describe('SocialCreateAccount', () => {
it('should match snapshot', () => {
const { queryByText } = render(<SocialCreateAccount connectorId="github" />);
it('should render secondary sigin-in methods', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<SocialCreateAccount connectorId="github" />
</SettingsProvider>
);
expect(queryByText('description.social_create_account')).not.toBeNull();
expect(queryByText('description.social_bind_account')).not.toBeNull();
});
it('should redirect to sign in page when click sign-in button', () => {
const { getByText } = render(<SocialCreateAccount connectorId="github" />);
const signInButton = getByText('action.sign_in');
fireEvent.click(signInButton);
expect(mockNavigate).toBeCalledWith('/sign-in/username/github');
expect(queryByText('description.social_bind_with_existing')).not.toBeNull();
expect(queryByText('secondary.social_bind_with')).not.toBeNull();
});
it('should call registerWithSocial when click create button', async () => {

View file

@ -1,11 +1,13 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button';
import useBindSocial from '@/hooks/use-bind-social';
import { SearchParameters } from '@/types';
import { queryStringify } from '@/utils';
import SignInMethodsLink from '../SignInMethodsLink';
import * as styles from './index.module.scss';
type Props = {
@ -14,14 +16,9 @@ type Props = {
};
const SocialCreateAccount = ({ connectorId, className }: Props) => {
const navigate = useNavigate();
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { relatedUser, registerWithSocial, bindSocialRelatedUser } = useBindSocial();
const signInHandler = useCallback(() => {
// TODO: redirect to desired sign-in page
navigate('/sign-in/username/' + connectorId);
}, [connectorId, navigate]);
const { relatedUser, localSignInMethods, registerWithSocial, bindSocialRelatedUser } =
useBindSocial();
return (
<div className={classNames(styles.container, className)}>
@ -46,10 +43,12 @@ const SocialCreateAccount = ({ connectorId, className }: Props) => {
>
{t('action.create')}
</Button>
<div className={styles.desc}>{t('description.social_bind_account')}</div>
<Button type="secondary" onClick={signInHandler}>
{t('action.sign_in')}
</Button>
<SignInMethodsLink
signInMethods={localSignInMethods}
template="social_bind_with"
className={styles.desc}
search={queryStringify({ [SearchParameters.bindWithSocial]: connectorId })}
/>
</div>
);
};

View file

@ -79,6 +79,6 @@ describe('<UsernameSignin>', () => {
fireEvent.click(submitButton);
});
expect(signInBasic).toBeCalledWith('username', 'password');
expect(signInBasic).toBeCalledWith('username', 'password', undefined);
});
});

View file

@ -16,6 +16,8 @@ import useApi from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import useTerms from '@/hooks/use-terms';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import { usernameValidation, passwordValidation } from '@/utils/field-validations';
import * as styles from './index.module.scss';
@ -50,7 +52,9 @@ const UsernameSignin = ({ className }: Props) => {
return;
}
void asyncSignInBasic(fieldValue.username, fieldValue.password);
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
void asyncSignInBasic(fieldValue.username, fieldValue.password, socialToBind);
}, [validateForm, termsValidation, asyncSignInBasic, fieldValue]);
useEffect(() => {

View file

@ -1,9 +1,11 @@
import { Optional } from '@silverhand/essentials';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useContext, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
import useApi from '@/hooks/use-api';
import { PageContext } from '@/hooks/use-page-context';
import { LocalSignInMethod } from '@/types';
type LocationState = {
relatedUser?: string;
@ -11,6 +13,7 @@ type LocationState = {
const useBindSocial = () => {
const state = useLocation().state as Optional<LocationState>;
const { experienceSettings } = useContext(PageContext);
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(registerWithSocial);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(bindSocialRelatedUser);
@ -28,6 +31,15 @@ const useBindSocial = () => {
[asyncBindSocialRelatedUser]
);
const localSignInMethods = useMemo(() => {
const primaryMethod = experienceSettings?.primarySignInMethod;
const secondaryMethods = experienceSettings?.secondarySignInMethods ?? [];
return [primaryMethod, ...secondaryMethods].filter(
(method): method is LocalSignInMethod => Boolean(method) && method !== 'social'
);
}, [experienceSettings]);
useEffect(() => {
if (registerResult?.redirectTo) {
window.location.assign(registerResult.redirectTo);
@ -41,6 +53,7 @@ const useBindSocial = () => {
}, [bindUserResult]);
return {
localSignInMethods,
relatedUser: state?.relatedUser,
registerWithSocial: createAccountHandler,
bindSocialRelatedUser: bindRelatedUserHandler,

View file

@ -4,6 +4,10 @@ export type UserFlow = 'sign-in' | 'register';
export type SignInMethod = 'username' | 'email' | 'sms' | 'social';
export type LocalSignInMethod = 'username' | 'email' | 'sms';
export enum SearchParameters {
bindWithSocial = 'bw',
}
type ConnectorData = Pick<ConnectorMetadata, 'id' | 'logo' | 'name'>;
export type SignInExperienceSettings = {

View file

@ -1,4 +1,4 @@
import { generateRandomString, parseQueryParameters } from '.';
import { generateRandomString, parseQueryParameters, queryStringify, getSearchParameters } from '.';
describe('util methods', () => {
it('generateRandomString', () => {
@ -10,4 +10,16 @@ describe('util methods', () => {
const parameters = parseQueryParameters('?foo=test&bar=test2');
expect(parameters).toEqual({ foo: 'test', bar: 'test2' });
});
it('queryStringify', () => {
expect(queryStringify('foo=test')).toEqual('foo=test');
expect(queryStringify(new URLSearchParams({ foo: 'test' }))).toEqual('foo=test');
});
it('getSearchParameters', () => {
expect(getSearchParameters('?foo=test&bar=test2', 'foo')).toEqual('test');
expect(getSearchParameters(new URLSearchParams({ foo: 'test', bar: 'test2' }), 'foo')).toEqual(
'test'
);
});
});

View file

@ -10,6 +10,22 @@ export const parseQueryParameters = (parameters: string | URLSearchParams) => {
return Object.fromEntries(searchParameters.entries());
};
export const queryStringify = (
parameters: string | URLSearchParams | Record<string, string | string>
) => {
const searchParameters =
parameters instanceof URLSearchParams ? parameters : new URLSearchParams(parameters);
return searchParameters.toString();
};
export const getSearchParameters = (parameters: string | URLSearchParams, key: string) => {
const searchParameters =
parameters instanceof URLSearchParams ? parameters : new URLSearchParams(parameters);
return searchParameters.get(key) ?? undefined;
};
type Entries<T> = Array<
{
[K in keyof T]: [K, T[K]];