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:
parent
e7faf32b5f
commit
5e251bdc08
15 changed files with 161 additions and 40 deletions
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -65,7 +65,7 @@ describe('<PasscodeValidation />', () => {
|
|||
});
|
||||
}
|
||||
|
||||
expect(verifyPasscodeApi).toBeCalledWith(email, '111111');
|
||||
expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
.desc {
|
||||
@include _.text-hint;
|
||||
margin-bottom: _.unit(2);
|
||||
text-align: left;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: _.unit(8);
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -79,6 +79,6 @@ describe('<UsernameSignin>', () => {
|
|||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(signInBasic).toBeCalledWith('username', 'password');
|
||||
expect(signInBasic).toBeCalledWith('username', 'password', undefined);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]];
|
||||
|
|
Loading…
Add table
Reference in a new issue