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

refactor(core,ui): refactor social sign-in flow (#2958)

This commit is contained in:
simeng-li 2023-01-18 14:11:22 +08:00 committed by GitHub
parent d71c407548
commit ff2abdceeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 498 additions and 327 deletions

View file

@ -112,7 +112,7 @@ describe('verifyUserAccount', () => {
code: 'user.identity_not_exist',
status: 422,
},
{ email: 'email@logto.io' }
{}
)
);

View file

@ -3,7 +3,6 @@ import { deduplicate } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { maskUserInfo } from '#src/utils/format.js';
import type {
SocialIdentifier,
@ -55,9 +54,7 @@ const identifyUserBySocialIdentifier = async (
status: 422,
},
{
...(relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) }),
...(userInfo.email && { email: userInfo.email }),
...(userInfo.phone && { phone: userInfo.phone }),
...(relatedInfo && { relatedUser: relatedInfo[0] }),
}
);
}

View file

@ -1,22 +0,0 @@
import { maskUserInfo } from './format.js';
describe('maskUserInfo', () => {
it('phone', () => {
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' })).toEqual({
type: 'email',
value: '****@logto.io',
});
});
it('email with name more than 4', () => {
expect(maskUserInfo({ type: 'email', value: 'foo_test@logto.io' })).toEqual({
type: 'email',
value: 'foo_****@logto.io',
});
});
});

View file

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

View file

@ -1,13 +1,7 @@
{
"extends": "./tsconfig.base",
"compilerOptions": {
"types": [
"node",
"jest",
"jest-matcher-specific-error"
]
"types": ["node", "jest", "jest-matcher-specific-error"]
},
"include": [
"src"
]
"include": ["src"]
}

View file

@ -24,6 +24,7 @@ const translation = {
cancel: 'Abbrechen',
save_password: 'Speichern',
bind: 'Mit {{address}} verknüpfen',
bind_and_continue: 'Link and continue', // UNTRANSLATED
back: 'Gehe zurück',
nav_back: 'Zurück',
agree: 'Zustimmen',
@ -33,6 +34,7 @@ const translation = {
switch_to: 'Zu {{method}} wechseln',
sign_in_via_passcode: 'Sign in with verification code', // UNTRANSLATED
sign_in_via_password: 'Sign in with password', // UNTRANSLATED
change: 'Change {{method}}', // UNTRANSLATED
},
description: {
email: 'Email',
@ -50,6 +52,8 @@ const translation = {
resend_passcode: 'Bestätigungscode erneut senden',
create_account_id_exists:
'Das Konto mit {{type}} {{value}} existiert bereits, möchtest du dich anmelden?',
link_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to link?', // UNTRANSLATED
sign_in_id_does_not_exist:
'Das Konto mit {{type}} {{value}} existiert nicht, möchtest du ein neues Konto erstellen?',
sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED

View file

@ -22,6 +22,7 @@ const translation = {
cancel: 'Cancel',
save_password: 'Save',
bind: 'Link with {{address}}',
bind_and_continue: 'Link and continue',
back: 'Go back',
nav_back: 'Back',
agree: 'Agree',
@ -31,6 +32,7 @@ const translation = {
switch_to: 'Switch to {{method}}',
sign_in_via_passcode: 'Sign in with verification code',
sign_in_via_password: 'Sign in with password',
change: 'Change {{method}}',
},
description: {
email: 'email',
@ -48,6 +50,8 @@ const translation = {
resend_passcode: 'Resend verification code',
create_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to sign in?',
link_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to link?',
sign_in_id_does_not_exist:
'The account with {{type}} {{value}} does not exist, would you like to create a new account?',
sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.',

View file

@ -24,6 +24,7 @@ const translation = {
cancel: 'Annuler',
save_password: 'Save', // UNTRANSLATED
bind: 'Lier avec {{address}}',
bind_and_continue: 'Link and continue', // UNTRANSLATED
back: 'Aller en arrière',
nav_back: 'Retour',
agree: 'Accepter',
@ -33,6 +34,7 @@ const translation = {
switch_to: 'Passer au {{method}}',
sign_in_via_passcode: 'Sign in with verification code', // UNTRANSLATED
sign_in_via_password: 'Sign in with password', // UNTRANSLATED
change: 'Change {{method}}', // UNTRANSLATED
},
description: {
email: 'email',
@ -50,6 +52,8 @@ const translation = {
resend_passcode: 'Renvoyer le code',
create_account_id_exists:
'Le compte avec {{type}} {{value}} existe déjà, voulez-vous vous connecter ?',
link_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to link?', // UNTRANSLATED
sign_in_id_does_not_exist:
"Le compte avec {{type}} {{value}} n'existe pas, voulez-vous créer un nouveau compte ?",
sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED

View file

@ -24,6 +24,7 @@ const translation = {
cancel: '취소',
save_password: '저장',
bind: '{{address}}로 연동',
bind_and_continue: 'Link and continue', // UNTRANSLATED
back: '뒤로 가기',
nav_back: '뒤로',
agree: '동의',
@ -33,6 +34,7 @@ const translation = {
switch_to: '{{method}}로 전환',
sign_in_via_passcode: '인증번호로 로그인',
sign_in_via_password: '비밀번호로 로그인',
change: 'Change {{change}}', // UNTRANSLATED,
},
description: {
email: '이메일',
@ -49,6 +51,8 @@ const translation = {
resend_after_seconds: '<span>{{seconds}}</span> 초 후에 재전송',
resend_passcode: '비밀번호 재전송',
create_account_id_exists: '{{type}} {{value}} 계정이 이미 존재해요. 로그인하시겠어요?',
link_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to link?', // UNTRANSLATED
sign_in_id_does_not_exist: '{type}} {{value}} 계정이 존재하지 않아요. 새로 만드시겠어요?',
sign_in_id_does_not_exist_alert: '{{type}} {{value}} 계정이 존재하지 않아요.',
create_account_id_exists_alert: '{{type}} {{value}} 이미 존재해요.',

View file

@ -24,6 +24,7 @@ const translation = {
cancel: 'Cancelar',
save_password: 'Salvar',
bind: 'Link com {{address}}',
bind_and_continue: 'Link and continue', // UNTRANSLATED
back: 'Voltar',
nav_back: 'Voltar',
agree: 'Aceito',
@ -33,6 +34,7 @@ const translation = {
switch_to: 'Trocar para {{method}}',
sign_in_via_passcode: 'Entrar com código de verificação',
sign_in_via_password: 'Entrar com senha',
change: 'Change {{change}}', // UNTRANSLATED,
},
description: {
email: 'e-mail',
@ -49,6 +51,8 @@ const translation = {
resend_after_seconds: 'Reenviar depois <span>{{seconds}}</span> segundos',
resend_passcode: 'Reenviar código de verificação',
create_account_id_exists: 'A conta com {{type}} {{value}} já existe, gostaria de entrar?',
link_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to link?', // UNTRANSLATED
sign_in_id_does_not_exist:
'A conta com {{type}} {{value}} não existe, gostaria de criar uma nova conta?',
sign_in_id_does_not_exist_alert: 'A conta com {{type}} {{value}} não existe.',

View file

@ -24,6 +24,7 @@ const translation = {
cancel: 'Cancelar',
save_password: 'Save', // UNTRANSLATED
bind: 'Agregar a {{address}}',
bind_and_continue: 'Link and continue', // UNTRANSLATED
back: 'Voltar',
nav_back: 'Anterior',
agree: 'Aceito',
@ -33,6 +34,7 @@ const translation = {
switch_to: 'Mudar para {{method}}',
sign_in_via_passcode: 'Sign in with verification code', // UNTRANSLATED
sign_in_via_password: 'Sign in with password', // UNTRANSLATED
change: 'Change {{method}}', // UNTRANSLATED
},
description: {
email: 'email',
@ -49,6 +51,8 @@ const translation = {
resend_after_seconds: 'Reenviar após <span>{{seconds}}</span> segundos',
resend_passcode: 'Reenviar senha',
create_account_id_exists: 'A conta com {{type}} {{value}} já existe, gostaria de fazer login?',
link_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to link?', // UNTRANSLATED
sign_in_id_does_not_exist: 'A conta com {{type}} {{value}} não existe, gostaria de criar uma?',
sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED

View file

@ -24,6 +24,7 @@ const translation = {
cancel: 'İptal Et',
save_password: 'Save', // UNTRANSLATED
bind: '{{address}} ile birleştir',
bind_and_continue: 'Link and continue', // UNTRANSLATED
back: 'Geri Dön',
nav_back: 'Geri',
agree: 'Kabul Et',
@ -33,6 +34,7 @@ const translation = {
switch_to: 'Switch to {{method}}', // UNTRANSLATED
sign_in_via_passcode: 'Sign in with verification code', // UNTRANSLATED
sign_in_via_password: 'Sign in with password', // UNTRANSLATED
change: 'Change {{method}}', // UNTRANSLATED
},
description: {
email: 'e-posta adresi',
@ -49,6 +51,8 @@ const translation = {
resend_after_seconds: '<span>{{seconds}}</span> saniye sonra tekrar gönder',
resend_passcode: 'Kodu Yeniden Gönder',
create_account_id_exists: '{{type}} {{value}} ile hesap mevcut, giriş yapmak ister misiniz?',
link_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to link?', // UNTRANSLATED
sign_in_id_does_not_exist:
'{{type}} {{value}} ile hesap mevcut değil, yeni bir hesap oluşturmak ister misiniz?',
sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED

View file

@ -24,6 +24,7 @@ const translation = {
confirm: '确认',
save_password: '保存密码',
bind: '绑定到 {{address}}',
bind_and_continue: 'Link and continue', // UNTRANSLATED
back: '返回',
nav_back: '返回',
agree: '同意',
@ -33,6 +34,7 @@ const translation = {
switch_to: '切换到{{method}}',
sign_in_via_passcode: '用验证码登录',
sign_in_via_password: '密码登录',
change: '更改{{method}}',
},
description: {
email: '邮箱',
@ -49,6 +51,8 @@ const translation = {
resend_after_seconds: '在 <span>{{ seconds }}</span> 秒后重发',
resend_passcode: '重发验证码',
create_account_id_exists: '{{ type }}为 {{ value }} 的帐号已存在,你要登录吗?',
link_account_id_exists:
'The account with {{type}} {{value}} already exists, would you like to link?', // UNTRANSLATED
sign_in_id_does_not_exist: '{{ type }}为 {{ value }} 的帐号不存在,你要创建一个新帐号吗?',
sign_in_id_does_not_exist_alert: '{{ type }}为 {{ value }} 的帐号不存在。',
create_account_id_exists_alert: '{{ type }}为 {{ value }} 的帐号已存在',

View file

@ -22,7 +22,7 @@ import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
import SignInPassword from './pages/SignInPassword';
import SocialLanding from './pages/SocialLanding';
import SocialRegister from './pages/SocialRegister';
import SocialLinkAccount from './pages/SocialLinkAccount';
import SocialSignIn from './pages/SocialSignInCallback';
import VerificationCode from './pages/VerificationCode';
import { getSignInExperienceSettings } from './utils/sign-in-experience';
@ -104,7 +104,7 @@ const App = () => {
{/* Social sign-in pages */}
<Route path="/callback/:connector" element={<Callback />} />
<Route path="/social/register/:connector" element={<SocialRegister />} />
<Route path="/social/link/:connector" element={<SocialLinkAccount />} />
<Route path="/social/landing/:connector" element={<SocialLanding />} />
{/* Always keep route path with param as the last one */}

View file

@ -200,3 +200,21 @@ export const bindSocialRelatedUser = async (payload: SocialEmailPayload | Social
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const linkWithSocial = async (connectorId: string) => {
// Sign-in with pre-verified email/phone identifier instead and replace the email/phone profile with connectorId.
await api.put(`${interactionPrefix}/event`, {
json: {
event: InteractionEvent.SignIn,
},
});
await api.put(`${interactionPrefix}/profile`, {
json: {
connectorId,
},
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};

View file

@ -18,6 +18,8 @@ const AcModal = ({
children,
cancelText = 'action.cancel',
confirmText = 'action.confirm',
confirmTextI18nProps,
cancelTextI18nProps,
onConfirm,
onClose,
}: ModalProps) => {
@ -56,8 +58,21 @@ const AcModal = ({
</div>
<div className={styles.content}>{children}</div>
<div className={styles.footer}>
<Button title={cancelText} type="secondary" size="small" onClick={onClose} />
{onConfirm && <Button title={confirmText} size="small" onClick={onConfirm} />}
<Button
title={cancelText}
type="secondary"
i18nProps={cancelTextI18nProps}
size="small"
onClick={onClose}
/>
{onConfirm && (
<Button
title={confirmText}
i18nProps={confirmTextI18nProps}
size="small"
onClick={onConfirm}
/>
)}
</div>
</div>
</ReactModal>

View file

@ -13,6 +13,8 @@ const MobileModal = ({
children,
cancelText = 'action.cancel',
confirmText = 'action.confirm',
cancelTextI18nProps,
confirmTextI18nProps,
onConfirm,
onClose,
}: ModalProps) => {
@ -28,8 +30,15 @@ const MobileModal = ({
<div className={styles.container}>
<div className={styles.content}>{children}</div>
<div className={styles.footer}>
<Button title={cancelText} type="secondary" onClick={onClose} />
{onConfirm && <Button title={confirmText} onClick={onConfirm} />}
<Button
title={cancelText}
i18nProps={cancelTextI18nProps}
type="secondary"
onClick={onClose}
/>
{onConfirm && (
<Button title={confirmText} i18nProps={confirmTextI18nProps} onClick={onConfirm} />
)}
</div>
</div>
</ReactModal>

View file

@ -7,6 +7,8 @@ export type ModalProps = {
children: ReactNode;
cancelText?: TFuncKey;
confirmText?: TFuncKey;
cancelTextI18nProps?: Record<string, string>;
confirmTextI18nProps?: Record<string, string>;
onConfirm?: () => void;
onClose: () => void;
};

View file

@ -43,7 +43,7 @@ describe('EmailContinue', () => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/continue/email/verification-code' },
{ pathname: '/continue/email/verification-code', search: '' },
{ state: { email } }
);
});

View file

@ -44,7 +44,7 @@ describe('EmailRegister', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.Register);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/register/email/verification-code' },
{ pathname: '/register/email/verification-code', search: '' },
{ state: { email } }
);
});

View file

@ -47,6 +47,7 @@ describe('EmailRegister', () => {
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Email}/verification-code`,
search: '',
},
{ state: { email } }
);

View file

@ -126,7 +126,7 @@ describe('EmailSignIn', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/verification-code' },
{ pathname: '/sign-in/email/verification-code', search: '' },
{ state: { email } }
);
});
@ -162,7 +162,7 @@ describe('EmailSignIn', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/verification-code' },
{ pathname: '/sign-in/email/verification-code', search: '' },
{ state: { email } }
);
});

View file

@ -87,6 +87,7 @@ describe('PasswordSignInForm', () => {
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Email}/verification-code`,
search: '',
},
{
state: { email },
@ -132,6 +133,7 @@ describe('PasswordSignInForm', () => {
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Phone}/verification-code`,
search: '',
},
{
state: { phone },

View file

@ -51,7 +51,7 @@ describe('PhoneContinue', () => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/continue/phone/verification-code' },
{ pathname: '/continue/phone/verification-code', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});

View file

@ -52,7 +52,7 @@ describe('PhoneRegister', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.Register);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/register/phone/verification-code' },
{ pathname: '/register/phone/verification-code', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});

View file

@ -55,6 +55,7 @@ describe('PhoneRegister', () => {
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Phone}/verification-code`,
search: '',
},
{ state: { phone: fullPhoneNumber } }
);

View file

@ -134,7 +134,7 @@ describe('PhoneSignIn', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/verification-code' },
{ pathname: '/sign-in/phone/verification-code', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
@ -170,7 +170,7 @@ describe('PhoneSignIn', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/verification-code' },
{ pathname: '/sign-in/phone/verification-code', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});

View file

@ -1,56 +0,0 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import useBindSocial from '@/hooks/use-bind-social';
import { useSieMethods } from '@/hooks/use-sie';
import * as styles from './index.module.scss';
type Props = {
className?: string;
connectorId: string;
};
const SocialCreateAccount = ({ connectorId, className }: Props) => {
const { t } = useTranslation();
const { relatedUser, socialIdentity, registerWithSocial, bindSocialRelatedUser } =
useBindSocial();
const { signInMethods } = useSieMethods();
const relatedIdentifier = relatedUser && socialIdentity?.[relatedUser.type];
return (
<div className={classNames(styles.container, className)}>
{relatedIdentifier && (
<>
<div className={styles.desc}>{t('description.social_bind_with_existing')}</div>
<Button
title="action.bind"
i18nProps={{ address: relatedUser.value }}
onClick={() => {
bindSocialRelatedUser({
connectorId,
...(relatedUser.type === 'email'
? { email: relatedIdentifier }
: { phone: relatedIdentifier }),
});
}}
/>
</>
)}
<div className={styles.desc}>{t('description.social_create_account')}</div>
<Button
title="action.create"
type={relatedUser ? 'secondary' : 'primary'}
onClick={() => {
registerWithSocial(connectorId);
}}
/>
</div>
);
};
export default SocialCreateAccount;

View file

@ -12,12 +12,11 @@
.desc {
@include _.text-hint;
text-align: left;
&:not(:first-child) {
margin-top: _.unit(8);
}
}
.divider {
margin: _.unit(5) 0;
}
:global(body.mobile) {
.desc {

View file

@ -1,19 +1,15 @@
import { fireEvent, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { registerWithVerifiedSocial, bindSocialRelatedUser } from '@/apis/interaction';
import SocialCreateAccount from '.';
import SocialLinkAccount from '.';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
useLocation: () => ({
state: { relatedUser: { type: 'email', value: 'foo@logto.io' }, email: 'email@logto.io' },
}),
}));
jest.mock('@/apis/interaction', () => ({
@ -21,19 +17,33 @@ jest.mock('@/apis/interaction', () => ({
bindSocialRelatedUser: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('SocialCreateAccount', () => {
it('should render secondary sign-in methods', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<SocialCreateAccount connectorId="github" />
</SettingsProvider>
describe('SocialLinkAccount', () => {
const relatedUser = Object.freeze({ type: 'email', value: 'foo@logto.io' });
afterEach(() => {
jest.clearAllMocks();
});
it('should render bindUser Button', async () => {
const { getByText } = renderWithPageContext(
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
);
expect(queryByText('description.social_create_account')).not.toBeNull();
expect(queryByText('description.social_bind_with_existing')).not.toBeNull();
const bindButton = getByText('action.bind');
await waitFor(() => {
fireEvent.click(bindButton);
});
expect(bindSocialRelatedUser).toBeCalledWith({
connectorId: 'github',
email: 'foo@logto.io',
});
});
it('should call registerWithVerifiedSocial when click create button', async () => {
const { getByText } = renderWithPageContext(<SocialCreateAccount connectorId="github" />);
const { getByText } = renderWithPageContext(
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
);
const createButton = getByText('action.create');
await waitFor(() => {
@ -42,16 +52,4 @@ describe('SocialCreateAccount', () => {
expect(registerWithVerifiedSocial).toBeCalledWith('github');
});
it('should render bindUser Button when relatedUserInfo found', async () => {
const { getByText } = renderWithPageContext(<SocialCreateAccount connectorId="github" />);
const bindButton = getByText('action.bind');
await waitFor(() => {
fireEvent.click(bindButton);
});
expect(bindSocialRelatedUser).toBeCalledWith({
connectorId: 'github',
email: 'email@logto.io',
});
});
});

View file

@ -0,0 +1,57 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import Divider from '@/components/Divider';
import useBindSocialRelatedUser from '@/hooks/use-social-link-related-user';
import useSocialRegister from '@/hooks/use-social-register';
import type { SocialRelatedUserInfo } from '@/types/guard';
import { maskEmail, maskPhone } from '@/utils/format';
import * as styles from './index.module.scss';
type Props = {
className?: string;
connectorId: string;
relatedUser: SocialRelatedUserInfo;
};
const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => {
const { t } = useTranslation();
const bindSocialRelatedUser = useBindSocialRelatedUser();
const registerWithSocial = useSocialRegister(connectorId);
const { type, value } = relatedUser;
return (
<div className={classNames(styles.container, className)}>
<div className={styles.desc}>{t('description.social_bind_with_existing')}</div>
<Button
title="action.bind"
i18nProps={{ address: type === 'email' ? maskEmail(value) : maskPhone(value) }}
onClick={() => {
void bindSocialRelatedUser({
connectorId,
...(type === 'email' ? { email: value } : { phone: value }),
});
}}
/>
<Divider label="description.or" className={styles.divider} />
<div className={styles.desc}>{t('description.social_create_account')}</div>
<Button
title="action.create"
type="secondary"
onClick={() => {
void registerWithSocial(connectorId);
}}
/>
</div>
);
};
export default SocialLinkAccount;

View file

@ -1,5 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import { act, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import {
@ -272,7 +273,13 @@ describe('<VerificationCode />', () => {
}));
const { container } = renderWithPageContext(
<VerificationCode type={UserFlow.continue} method={SignInIdentifier.Email} target={email} />
<MemoryRouter>
<VerificationCode
type={UserFlow.continue}
method={SignInIdentifier.Email}
target={email}
/>
</MemoryRouter>
);
const inputs = container.querySelectorAll('input');
@ -301,7 +308,13 @@ describe('<VerificationCode />', () => {
}));
const { container } = renderWithPageContext(
<VerificationCode type={UserFlow.continue} method={SignInIdentifier.Phone} target={phone} />
<MemoryRouter>
<VerificationCode
type={UserFlow.continue}
method={SignInIdentifier.Phone}
target={phone}
/>
</MemoryRouter>
);
const inputs = container.querySelectorAll('input');

View file

@ -1,15 +1,18 @@
import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { addProfileWithVerificationCodeIdentifier } from '@/apis/interaction';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import type { VerificationCodeIdentifier } from '@/types';
import { SearchParameters } from '@/types';
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
import useLinkSocialConfirmModal from './use-link-social-confirm-modal';
const useContinueFlowCodeVerification = (
_method: VerificationCodeIdentifier,
@ -18,26 +21,36 @@ const useContinueFlowCodeVerification = (
) => {
const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } =
useGeneralVerificationCodeErrorHandler();
const [searchParameters] = useSearchParams();
const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true);
const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace: true });
const identifierErrorHandler = useIdentifierErrorAlert();
const showIdentifierErrorAlert = useIdentifierErrorAlert();
const showLinkSocialConfirmModal = useLinkSocialConfirmModal();
const identifierExistErrorHandler = useCallback(
async (method: VerificationCodeIdentifier, target: string) => {
const linkSocial = searchParameters.get(SearchParameters.linkSocial);
// Show bind with social confirm modal
if (linkSocial) {
await showLinkSocialConfirmModal(method, target, linkSocial);
return;
}
await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, method, target);
},
[searchParameters, showIdentifierErrorAlert, showLinkSocialConfirmModal]
);
const verifyVerificationCodeErrorHandlers: ErrorHandlers = useMemo(
() => ({
'user.phone_already_in_use': () => {
void identifierErrorHandler(
IdentifierErrorType.IdentifierAlreadyExists,
SignInIdentifier.Phone,
target
);
void identifierExistErrorHandler(SignInIdentifier.Phone, target);
},
'user.email_already_in_use': () => {
void identifierErrorHandler(
IdentifierErrorType.IdentifierAlreadyExists,
SignInIdentifier.Email,
target
);
void identifierExistErrorHandler(SignInIdentifier.Email, target);
},
...requiredProfileErrorHandler,
...generalVerificationCodeErrorHandlers,
@ -46,7 +59,7 @@ const useContinueFlowCodeVerification = (
[
errorCallback,
target,
identifierErrorHandler,
identifierExistErrorHandler,
requiredProfileErrorHandler,
generalVerificationCodeErrorHandlers,
]

View file

@ -34,13 +34,14 @@ const useIdentifierErrorAlert = () => {
type: t(
`description.${identifierType === SignInIdentifier.Email ? 'email' : 'phone_number'}`
),
identifier:
identifierType === SignInIdentifier.Email
? identifier
: formatPhoneNumberWithCountryCallingCode(identifier),
value:
identifierType === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(identifier)
: identifier,
}
),
cancelText: 'action.got_it',
cancelText: 'action.change',
cancelTextI18nProps: { method: identifierType },
});
navigate(-1);
},

View file

@ -0,0 +1,46 @@
import { SignInIdentifier } from '@logto/schemas';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useLinkSocial from '@/hooks/use-social-link-account';
import type { VerificationCodeIdentifier } from '@/types';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
const useLinkSocialConfirmModal = () => {
const { show } = useConfirmModal();
const { t } = useTranslation();
const linkWithSocial = useLinkSocial();
const navigate = useNavigate();
return useCallback(
async (method: VerificationCodeIdentifier, target: string, connectorId: string) => {
const [confirm] = await show({
confirmText: 'action.bind_and_continue',
cancelText: 'action.change',
cancelTextI18nProps: {
method: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
},
ModalContent: t('description.link_account_id_exists', {
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
value:
method === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(target)
: target,
}),
});
if (!confirm) {
navigate(-1);
return;
}
await linkWithSocial(connectorId);
},
[linkWithSocial, navigate, show, t]
);
};
export default useLinkSocialConfirmModal;

View file

@ -32,7 +32,7 @@ const useRegisterFlowCodeVerification = (
const { signInMode } = useSieMethods();
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({ replace: true });
const { run: signInWithIdentifierAsync } = useApi(
signInWithVerifiedIdentifier,
@ -54,9 +54,9 @@ const useRegisterFlowCodeVerification = (
ModalContent: t('description.create_account_id_exists', {
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
value:
method === SignInIdentifier.Email
? target
: formatPhoneNumberWithCountryCallingCode(target),
method === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(target)
: target,
}),
});

View file

@ -33,7 +33,7 @@ const useSignInFlowCodeVerification = (
const { signInMode } = useSieMethods();
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(true);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({ replace: true });
const { run: registerWithIdentifierAsync } = useApi(
registerWithVerifiedIdentifier,
@ -55,9 +55,9 @@ const useSignInFlowCodeVerification = (
ModalContent: t('description.sign_in_id_does_not_exist', {
ype: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
value:
method === SignInIdentifier.Email
? target
: formatPhoneNumberWithCountryCallingCode(target),
method === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(target)
: target,
}),
});

View file

@ -1,66 +0,0 @@
import type { SocialEmailPayload, SocialPhonePayload } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useCallback, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import { registerWithVerifiedSocial, bindSocialRelatedUser } from '@/apis/interaction';
import useApi from '@/hooks/use-api';
import { bindSocialStateGuard } from '@/types/guard';
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
const useBindSocial = () => {
const { state } = useLocation();
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(
registerWithVerifiedSocial,
requiredProfileErrorHandlers
);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(
bindSocialRelatedUser,
requiredProfileErrorHandlers
);
const createAccountHandler = useCallback(
(connectorId: string) => {
void asyncRegisterWithSocial(connectorId);
},
[asyncRegisterWithSocial]
);
const bindRelatedUserHandler = useCallback(
(payload: SocialEmailPayload | SocialPhonePayload) => {
void asyncBindSocialRelatedUser(payload);
},
[asyncBindSocialRelatedUser]
);
useEffect(() => {
if (registerResult?.redirectTo) {
window.location.replace(registerResult.redirectTo);
}
}, [registerResult]);
useEffect(() => {
if (bindUserResult?.redirectTo) {
window.location.replace(bindUserResult.redirectTo);
}
}, [bindUserResult]);
return {
relatedUser: conditional(is(state, bindSocialStateGuard) && state.relatedUser),
socialIdentity: conditional(
is(state, bindSocialStateGuard) && {
email: state.email,
phone: state.phone,
}
),
registerWithSocial: createAccountHandler,
bindSocialRelatedUser: bindRelatedUserHandler,
};
};
export default useBindSocial;

View file

@ -3,13 +3,19 @@ import { useMemo, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { validate } from 'superstruct';
import { UserFlow } from '@/types';
import { UserFlow, SearchParameters } from '@/types';
import { missingProfileErrorDataGuard } from '@/types/guard';
import { queryStringify } from '@/utils';
import type { ErrorHandlers } from './use-api';
import { PageContext } from './use-page-context';
const useRequiredProfileErrorHandler = (replace?: boolean) => {
type Options = {
replace?: boolean;
linkSocial?: string;
};
const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) => {
const navigate = useNavigate();
const { setToast } = useContext(PageContext);
@ -19,10 +25,13 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => {
const [, data] = validate(error.data, missingProfileErrorDataGuard);
const missingProfile = data?.missingProfile[0];
const linkSocialQueryString = linkSocial
? `?${queryStringify({ [SearchParameters.linkSocial]: linkSocial })}`
: undefined;
switch (missingProfile) {
case MissingProfile.password:
case MissingProfile.username:
case MissingProfile.email:
navigate(
{
pathname: `/${UserFlow.continue}/${missingProfile}`,
@ -30,10 +39,12 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => {
{ replace }
);
break;
case MissingProfile.email:
case MissingProfile.phone:
navigate(
{
pathname: `/${UserFlow.continue}/phone`,
pathname: `/${UserFlow.continue}/${missingProfile}`,
search: linkSocialQueryString,
},
{ replace }
);
@ -42,6 +53,7 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => {
navigate(
{
pathname: `/${UserFlow.continue}/email-or-phone/email`,
search: linkSocialQueryString,
},
{ replace }
);
@ -54,7 +66,7 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => {
}
},
}),
[navigate, replace, setToast]
[linkSocial, navigate, replace, setToast]
);
return requiredProfileErrorHandler;

View file

@ -45,6 +45,7 @@ const useSendVerificationCode = <T extends SignInIdentifier.Email | SignInIdenti
navigate(
{
pathname: `/${flow}/${method}/verification-code`,
search: location.search,
},
{
state: payload,

View file

@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { linkWithSocial } from '@/apis/interaction';
import useApi from '@/hooks/use-api';
const useLinkSocial = () => {
const { result: linkResult, run: asyncLinkWithSocial } = useApi(linkWithSocial);
useEffect(() => {
if (linkResult?.redirectTo) {
window.location.replace(linkResult.redirectTo);
}
}, [linkResult]);
return asyncLinkWithSocial;
};
export default useLinkSocial;

View file

@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { bindSocialRelatedUser } from '@/apis/interaction';
import useApi from '@/hooks/use-api';
const useBindSocialRelatedUser = () => {
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(bindSocialRelatedUser);
useEffect(() => {
if (bindUserResult?.redirectTo) {
window.location.replace(bindUserResult.redirectTo);
}
}, [bindUserResult]);
return asyncBindSocialRelatedUser;
};
export default useBindSocialRelatedUser;

View file

@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { registerWithVerifiedSocial } from '@/apis/interaction';
import useApi from './use-api';
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
const useSocialRegister = (connectorId?: string) => {
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({ linkSocial: connectorId });
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(
registerWithVerifiedSocial,
requiredProfileErrorHandlers
);
useEffect(() => {
if (registerResult?.redirectTo) {
window.location.replace(registerResult.redirectTo);
}
}, [registerResult]);
return asyncRegisterWithSocial;
};
export default useSocialRegister;

View file

@ -1,9 +1,12 @@
import type { RequestErrorBody } from '@logto/schemas';
import { SignInMode } from '@logto/schemas';
import { useEffect, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { validate } from 'superstruct';
import { signInWithSocial } from '@/apis/interaction';
import { socialAccountNotExistErrorDataGuard } from '@/types/guard';
import { parseQueryParameters } from '@/utils';
import { stateValidation } from '@/utils/social-connectors';
@ -11,41 +14,59 @@ import type { ErrorHandlers } from './use-api';
import useApi from './use-api';
import { PageContext } from './use-page-context';
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
import { useSieMethods } from './use-sie';
import useSocialRegister from './use-social-register';
const useSocialSignInListener = () => {
const { setToast, experienceSettings } = useContext(PageContext);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const useSocialSignInListener = (connectorId?: string) => {
const { setToast } = useContext(PageContext);
const { signInMode, signUpMethods } = useSieMethods();
const { t } = useTranslation();
const parameters = useParams();
const navigate = useNavigate();
const registerWithSocial = useSocialRegister(connectorId);
const accountNotExistErrorHandler = useCallback(
async (error: RequestErrorBody) => {
const [, data] = validate(error.data, socialAccountNotExistErrorDataGuard);
const { relatedUser } = data ?? {};
if (!connectorId) {
return;
}
if (relatedUser) {
navigate(`/social/link/${connectorId}`, {
replace: true,
state: { relatedUser },
});
return;
}
// Register with social
await registerWithSocial(connectorId);
},
[connectorId, navigate, registerWithSocial]
);
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const signInWithSocialErrorHandlers: ErrorHandlers = useMemo(
() => ({
'user.identity_not_exist': (error) => {
// Should not let user register under sign-in only mode
if (experienceSettings?.signInMode === SignInMode.SignIn) {
'user.identity_not_exist': async (error) => {
// Should not let user register new social account under sign-in only mode
if (signInMode === SignInMode.SignIn) {
setToast(error.message);
return;
}
if (parameters.connector) {
navigate(`/social/register/${parameters.connector}`, {
replace: true,
state: error.data,
});
}
await accountNotExistErrorHandler(error);
},
...requiredProfileErrorHandlers,
}),
[
experienceSettings?.signInMode,
navigate,
parameters.connector,
requiredProfileErrorHandlers,
setToast,
]
[requiredProfileErrorHandlers, signInMode, accountNotExistErrorHandler, setToast]
);
const { result, run: asyncSignInWithSocial } = useApi(
@ -58,7 +79,8 @@ const useSocialSignInListener = () => {
void asyncSignInWithSocial({
connectorId,
connectorData: {
redirectUri: `${window.location.origin}/callback/${connectorId}`, // For validation use only
// For validation use only
redirectUri: `${window.location.origin}/callback/${connectorId}`,
...data,
},
});
@ -74,20 +96,20 @@ const useSocialSignInListener = () => {
// Social Sign-In Callback Handler
useEffect(() => {
if (!parameters.connector) {
if (!connectorId) {
return;
}
const { state, ...rest } = parseQueryParameters(window.location.search);
if (!state || !stateValidation(state, parameters.connector)) {
if (!state || !stateValidation(state, connectorId)) {
setToast(t('error.invalid_connector_auth'));
return;
}
void signInWithSocialHandler(parameters.connector, rest);
}, [parameters.connector, setToast, signInWithSocialHandler, t]);
void signInWithSocialHandler(connectorId, rest);
}, [connectorId, setToast, signInWithSocialHandler, t]);
};
export default useSocialSignInListener;

View file

@ -11,7 +11,7 @@ const useSetPassword = () => {
const navigate = useNavigate();
const { show } = useConfirmModal();
const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true);
const requiredProfileErrorHandler = useRequiredProfileErrorHandler();
const errorHandlers: ErrorHandlers = useMemo(
() => ({

View file

@ -49,9 +49,9 @@ const SignInPassword = () => {
descriptionProps={{
method: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
value:
method === SignInIdentifier.Email
? value
: formatPhoneNumberWithCountryCallingCode(value),
method === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(value)
: value,
}}
>
<PasswordSignInForm

View file

@ -3,12 +3,19 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import SocialRegister from '.';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(() => ({
state: { relatedUser: { type: 'email', value: 'foo@logto.io' } },
})),
}));
describe('SocialRegister', () => {
it('render', () => {
const { queryByText } = render(
<MemoryRouter initialEntries={['/social/register/github']}>
<MemoryRouter initialEntries={['/social/link/github']}>
<Routes>
<Route path="/social/register/:connector" element={<SocialRegister />} />
<Route path="/social/link/:connector" element={<SocialRegister />} />
</Routes>
</MemoryRouter>
);

View file

@ -0,0 +1,34 @@
import { useParams, useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import SocialLinkAccountContainer from '@/containers/SocialLinkAccount';
import ErrorPage from '@/pages/ErrorPage';
import { socialAccountNotExistErrorDataGuard } from '@/types/guard';
type Parameters = {
connector: string;
};
const SocialLinkAccount = () => {
const { connector } = useParams<Parameters>();
const { state } = useLocation();
if (!is(state, socialAccountNotExistErrorDataGuard)) {
return <ErrorPage rawMessage="Missing relate account info" />;
}
if (!connector) {
return <ErrorPage rawMessage="Connector not found" />;
}
const { relatedUser } = state;
return (
<SecondaryPageWrapper title="description.bind_account_title">
<SocialLinkAccountContainer connectorId={connector} relatedUser={relatedUser} />
</SecondaryPageWrapper>
);
};
export default SocialLinkAccount;

View file

@ -1,24 +0,0 @@
import { useParams } from 'react-router-dom';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import SocialCreateAccount from '@/containers/SocialCreateAccount';
type Parameters = {
connector: string;
};
const SocialRegister = () => {
const { connector } = useParams<Parameters>();
if (!connector) {
return null;
}
return (
<SecondaryPageWrapper title="description.bind_account_title">
<SocialCreateAccount connectorId={connector} />
</SecondaryPageWrapper>
);
};
export default SocialRegister;

View file

@ -1,9 +1,13 @@
import { useParams } from 'react-router-dom';
import useSocialSignInListener from '@/hooks/use-social-sign-in-listener';
import SignIn from '../SignIn';
const SocialSignInCallback = () => {
useSocialSignInListener();
const parameters = useParams<{ connector: string }>();
useSocialSignInListener(parameters.connector);
return <SignIn />;
};

View file

@ -52,7 +52,7 @@ const VerificationCode = () => {
description="description.enter_passcode"
descriptionProps={{
address: t(`description.${method === 'email' ? 'email' : 'phone_number'}`),
target: method === 'email' ? target : formatPhoneNumberWithCountryCallingCode(target),
target: method === 'phone' ? formatPhoneNumberWithCountryCallingCode(target) : target,
}}
>
<VerificationCodeContainer

View file

@ -3,15 +3,6 @@ import * as s from 'superstruct';
import { UserFlow } from '.';
export const bindSocialStateGuard = s.object({
relatedUser: s.object({
type: s.union([s.literal('email'), s.literal('phone')]),
value: s.string(),
}),
email: s.optional(s.string()),
phone: s.optional(s.string()),
});
export const verificationCodeStateGuard = s.object({
email: s.optional(s.string()),
phone: s.optional(s.string()),
@ -57,3 +48,14 @@ export const missingProfileErrorDataGuard = s.object({
])
),
});
export const socialAccountNotExistErrorDataGuard = s.object({
relatedUser: s.object({
type: s.union([s.literal('email'), s.literal('phone')]),
value: s.string(),
}),
});
export type SocialRelatedUserInfo = s.Infer<
typeof socialAccountNotExistErrorDataGuard
>['relatedUser'];

View file

@ -15,6 +15,7 @@ export enum UserFlow {
export enum SearchParameters {
nativeCallbackLink = 'native_callback',
redirectTo = 'redirect_to',
linkSocial = 'link_social',
}
export type Platform = 'web' | 'mobile';

View file

@ -0,0 +1,13 @@
import { maskEmail, maskPhone } from './format';
describe('maskUserInfo', () => {
it('maskPhone', () => {
expect(maskPhone('1234567890')).toEqual('****7890');
});
it('email with name less than 5', () => {
expect(maskEmail('test@logto.io')).toEqual('****@logto.io');
});
it('email with name more than 4', () => {
expect(maskEmail('foo_test@logto.io')).toEqual('foo_****@logto.io');
});
});

View file

@ -0,0 +1,9 @@
export const maskEmail = (email: string) => {
const [name = '', domain = ''] = email.split('@');
const preview = name.length > 4 ? `${name.slice(0, 4)}` : '';
return `${preview}****@${domain}`;
};
export const maskPhone = (phone: string) => `****${phone.slice(-4)}`;