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:
parent
d71c407548
commit
ff2abdceeb
55 changed files with 498 additions and 327 deletions
|
@ -112,7 +112,7 @@ describe('verifyUserAccount', () => {
|
|||
code: 'user.identity_not_exist',
|
||||
status: 422,
|
||||
},
|
||||
{ email: 'email@logto.io' }
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
@ -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] }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}} 이미 존재해요.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }} 的帐号已存在',
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -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>();
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -47,6 +47,7 @@ describe('EmailRegister', () => {
|
|||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Email}/verification-code`,
|
||||
search: '',
|
||||
},
|
||||
{ state: { email } }
|
||||
);
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -55,6 +55,7 @@ describe('PhoneRegister', () => {
|
|||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Phone}/verification-code`,
|
||||
search: '',
|
||||
},
|
||||
{ state: { phone: fullPhoneNumber } }
|
||||
);
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
|
@ -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 {
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
57
packages/ui/src/containers/SocialLinkAccount/index.tsx
Normal file
57
packages/ui/src/containers/SocialLinkAccount/index.tsx
Normal 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;
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -45,6 +45,7 @@ const useSendVerificationCode = <T extends SignInIdentifier.Email | SignInIdenti
|
|||
navigate(
|
||||
{
|
||||
pathname: `/${flow}/${method}/verification-code`,
|
||||
search: location.search,
|
||||
},
|
||||
{
|
||||
state: payload,
|
||||
|
|
18
packages/ui/src/hooks/use-social-link-account.ts
Normal file
18
packages/ui/src/hooks/use-social-link-account.ts
Normal 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;
|
18
packages/ui/src/hooks/use-social-link-related-user.ts
Normal file
18
packages/ui/src/hooks/use-social-link-related-user.ts
Normal 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;
|
25
packages/ui/src/hooks/use-social-register.ts
Normal file
25
packages/ui/src/hooks/use-social-register.ts
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -11,7 +11,7 @@ const useSetPassword = () => {
|
|||
const navigate = useNavigate();
|
||||
const { show } = useConfirmModal();
|
||||
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true);
|
||||
const requiredProfileErrorHandler = useRequiredProfileErrorHandler();
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
34
packages/ui/src/pages/SocialLinkAccount/index.tsx
Normal file
34
packages/ui/src/pages/SocialLinkAccount/index.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -15,6 +15,7 @@ export enum UserFlow {
|
|||
export enum SearchParameters {
|
||||
nativeCallbackLink = 'native_callback',
|
||||
redirectTo = 'redirect_to',
|
||||
linkSocial = 'link_social',
|
||||
}
|
||||
|
||||
export type Platform = 'web' | 'mobile';
|
||||
|
|
13
packages/ui/src/utils/format.test.ts
Normal file
13
packages/ui/src/utils/format.test.ts
Normal 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');
|
||||
});
|
||||
});
|
9
packages/ui/src/utils/format.ts
Normal file
9
packages/ui/src/utils/format.ts
Normal 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)}`;
|
Loading…
Add table
Reference in a new issue