From ff2abdceeb1d16e6d14922a16036423040f92a14 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 18 Jan 2023 14:11:22 +0800 Subject: [PATCH] refactor(core,ui): refactor social sign-in flow (#2958) --- .../user-identity-verification.test.ts | 2 +- .../user-identity-verification.ts | 5 +- packages/core/src/utils/format.test.ts | 22 ------ packages/core/src/utils/format.ts | 25 ------ packages/core/tsconfig.json | 10 +-- packages/phrases-ui/src/locales/de.ts | 4 + packages/phrases-ui/src/locales/en.ts | 4 + packages/phrases-ui/src/locales/fr.ts | 4 + packages/phrases-ui/src/locales/ko.ts | 4 + packages/phrases-ui/src/locales/pt-br.ts | 4 + packages/phrases-ui/src/locales/pt-pt.ts | 4 + packages/phrases-ui/src/locales/tr-tr.ts | 4 + packages/phrases-ui/src/locales/zh-cn.ts | 4 + packages/ui/src/App.tsx | 4 +- packages/ui/src/apis/interaction.ts | 18 +++++ .../src/components/ConfirmModal/AcModal.tsx | 19 ++++- .../components/ConfirmModal/MobileModal.tsx | 13 +++- .../ui/src/components/ConfirmModal/type.ts | 2 + .../EmailForm/EmailContinue.test.tsx | 2 +- .../EmailForm/EmailRegister.test.tsx | 2 +- .../EmailForm/EmailResetPassword.test.tsx | 1 + .../containers/EmailForm/EmailSignIn.test.tsx | 4 +- .../PasswordSignInForm/index.test.tsx | 2 + .../PhoneForm/PhoneContinue.test.tsx | 2 +- .../PhoneForm/PhoneRegister.test.tsx | 2 +- .../PhoneForm/PhoneResetPassword.test.tsx | 1 + .../containers/PhoneForm/PhoneSignIn.test.tsx | 4 +- .../containers/SocialCreateAccount/index.tsx | 56 -------------- .../index.module.scss | 7 +- .../index.test.tsx | 50 ++++++------ .../containers/SocialLinkAccount/index.tsx | 57 ++++++++++++++ .../VerificationCode/index.test.tsx | 17 ++++- .../use-continue-flow-code-verification.ts | 39 ++++++---- .../use-identifier-error-alert.ts | 11 +-- .../use-link-social-confirm-modal.ts | 46 +++++++++++ .../use-register-flow-code-verification.ts | 8 +- .../use-sign-in-flow-code-verification.ts | 8 +- packages/ui/src/hooks/use-bind-social.ts | 66 ---------------- .../use-required-profile-error-handler.ts | 22 ++++-- .../src/hooks/use-send-verification-code.ts | 1 + .../ui/src/hooks/use-social-link-account.ts | 18 +++++ .../src/hooks/use-social-link-related-user.ts | 18 +++++ packages/ui/src/hooks/use-social-register.ts | 25 ++++++ .../src/hooks/use-social-sign-in-listener.ts | 76 ++++++++++++------- .../Continue/SetPassword/use-set-password.ts | 2 +- .../ui/src/pages/SignInPassword/index.tsx | 6 +- .../index.test.tsx | 11 ++- .../ui/src/pages/SocialLinkAccount/index.tsx | 34 +++++++++ .../ui/src/pages/SocialRegister/index.tsx | 24 ------ .../src/pages/SocialSignInCallback/index.tsx | 6 +- .../ui/src/pages/VerificationCode/index.tsx | 2 +- packages/ui/src/types/guard.ts | 20 ++--- packages/ui/src/types/index.ts | 1 + packages/ui/src/utils/format.test.ts | 13 ++++ packages/ui/src/utils/format.ts | 9 +++ 55 files changed, 498 insertions(+), 327 deletions(-) delete mode 100644 packages/core/src/utils/format.test.ts delete mode 100644 packages/ui/src/containers/SocialCreateAccount/index.tsx rename packages/ui/src/containers/{SocialCreateAccount => SocialLinkAccount}/index.module.scss (84%) rename packages/ui/src/containers/{SocialCreateAccount => SocialLinkAccount}/index.test.tsx (53%) create mode 100644 packages/ui/src/containers/SocialLinkAccount/index.tsx create mode 100644 packages/ui/src/containers/VerificationCode/use-link-social-confirm-modal.ts delete mode 100644 packages/ui/src/hooks/use-bind-social.ts create mode 100644 packages/ui/src/hooks/use-social-link-account.ts create mode 100644 packages/ui/src/hooks/use-social-link-related-user.ts create mode 100644 packages/ui/src/hooks/use-social-register.ts rename packages/ui/src/pages/{SocialRegister => SocialLinkAccount}/index.test.tsx (57%) create mode 100644 packages/ui/src/pages/SocialLinkAccount/index.tsx delete mode 100644 packages/ui/src/pages/SocialRegister/index.tsx create mode 100644 packages/ui/src/utils/format.test.ts create mode 100644 packages/ui/src/utils/format.ts diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts index 1053ecc63..caa019c17 100644 --- a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts @@ -112,7 +112,7 @@ describe('verifyUserAccount', () => { code: 'user.identity_not_exist', status: 422, }, - { email: 'email@logto.io' } + {} ) ); diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.ts index 7b5a86634..618783d34 100644 --- a/packages/core/src/routes/interaction/verifications/user-identity-verification.ts +++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.ts @@ -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] }), } ); } diff --git a/packages/core/src/utils/format.test.ts b/packages/core/src/utils/format.test.ts deleted file mode 100644 index 9caa7723c..000000000 --- a/packages/core/src/utils/format.test.ts +++ /dev/null @@ -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', - }); - }); -}); diff --git a/packages/core/src/utils/format.ts b/packages/core/src/utils/format.ts index 461cb0d79..06c466826 100644 --- a/packages/core/src/utils/format.ts +++ b/packages/core/src/utils/format.ts @@ -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)); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 2796b3926..657642e2f 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -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"] } diff --git a/packages/phrases-ui/src/locales/de.ts b/packages/phrases-ui/src/locales/de.ts index 6937ec256..59bab99ef 100644 --- a/packages/phrases-ui/src/locales/de.ts +++ b/packages/phrases-ui/src/locales/de.ts @@ -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 diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index 89ec552d0..adbc6c7b4 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -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.', diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index 207088506..3d17569bb 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -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 diff --git a/packages/phrases-ui/src/locales/ko.ts b/packages/phrases-ui/src/locales/ko.ts index 0e758f1bd..e639529a1 100644 --- a/packages/phrases-ui/src/locales/ko.ts +++ b/packages/phrases-ui/src/locales/ko.ts @@ -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: '{{seconds}} 초 후에 재전송', 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}} 이미 존재해요.', diff --git a/packages/phrases-ui/src/locales/pt-br.ts b/packages/phrases-ui/src/locales/pt-br.ts index 71c49e502..b7569197d 100644 --- a/packages/phrases-ui/src/locales/pt-br.ts +++ b/packages/phrases-ui/src/locales/pt-br.ts @@ -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 {{seconds}} 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.', diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index dd6f4d8ed..845b75858 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -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 {{seconds}} 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 diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index 722114298..7e0f2bc1c 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -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: '{{seconds}} 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 diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index b45453cfe..852819558 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -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: '在 {{ seconds }} 秒后重发', 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 }} 的帐号已存在', diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index fe8f584b9..8684e0cad 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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 */} } /> - } /> + } /> } /> {/* Always keep route path with param as the last one */} diff --git a/packages/ui/src/apis/interaction.ts b/packages/ui/src/apis/interaction.ts index 8581d23d1..5194d0a58 100644 --- a/packages/ui/src/apis/interaction.ts +++ b/packages/ui/src/apis/interaction.ts @@ -200,3 +200,21 @@ export const bindSocialRelatedUser = async (payload: SocialEmailPayload | Social return api.post(`${interactionPrefix}/submit`).json(); }; + +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(); +}; diff --git a/packages/ui/src/components/ConfirmModal/AcModal.tsx b/packages/ui/src/components/ConfirmModal/AcModal.tsx index a019afe90..34018e3e1 100644 --- a/packages/ui/src/components/ConfirmModal/AcModal.tsx +++ b/packages/ui/src/components/ConfirmModal/AcModal.tsx @@ -18,6 +18,8 @@ const AcModal = ({ children, cancelText = 'action.cancel', confirmText = 'action.confirm', + confirmTextI18nProps, + cancelTextI18nProps, onConfirm, onClose, }: ModalProps) => { @@ -56,8 +58,21 @@ const AcModal = ({
{children}
-
diff --git a/packages/ui/src/components/ConfirmModal/MobileModal.tsx b/packages/ui/src/components/ConfirmModal/MobileModal.tsx index 4ab814311..a927a1bfe 100644 --- a/packages/ui/src/components/ConfirmModal/MobileModal.tsx +++ b/packages/ui/src/components/ConfirmModal/MobileModal.tsx @@ -13,6 +13,8 @@ const MobileModal = ({ children, cancelText = 'action.cancel', confirmText = 'action.confirm', + cancelTextI18nProps, + confirmTextI18nProps, onConfirm, onClose, }: ModalProps) => { @@ -28,8 +30,15 @@ const MobileModal = ({
{children}
-
diff --git a/packages/ui/src/components/ConfirmModal/type.ts b/packages/ui/src/components/ConfirmModal/type.ts index 9d3412204..fc2668d73 100644 --- a/packages/ui/src/components/ConfirmModal/type.ts +++ b/packages/ui/src/components/ConfirmModal/type.ts @@ -7,6 +7,8 @@ export type ModalProps = { children: ReactNode; cancelText?: TFuncKey; confirmText?: TFuncKey; + cancelTextI18nProps?: Record; + confirmTextI18nProps?: Record; onConfirm?: () => void; onClose: () => void; }; diff --git a/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx b/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx index 2e72a6485..633e545a6 100644 --- a/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx +++ b/packages/ui/src/containers/EmailForm/EmailContinue.test.tsx @@ -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 } } ); }); diff --git a/packages/ui/src/containers/EmailForm/EmailRegister.test.tsx b/packages/ui/src/containers/EmailForm/EmailRegister.test.tsx index 8ab5090d7..f0bf4b429 100644 --- a/packages/ui/src/containers/EmailForm/EmailRegister.test.tsx +++ b/packages/ui/src/containers/EmailForm/EmailRegister.test.tsx @@ -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 } } ); }); diff --git a/packages/ui/src/containers/EmailForm/EmailResetPassword.test.tsx b/packages/ui/src/containers/EmailForm/EmailResetPassword.test.tsx index 815fc4d38..382426d0f 100644 --- a/packages/ui/src/containers/EmailForm/EmailResetPassword.test.tsx +++ b/packages/ui/src/containers/EmailForm/EmailResetPassword.test.tsx @@ -47,6 +47,7 @@ describe('EmailRegister', () => { expect(mockedNavigate).toBeCalledWith( { pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Email}/verification-code`, + search: '', }, { state: { email } } ); diff --git a/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx b/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx index f94a450e7..4f6b41724 100644 --- a/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx +++ b/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx @@ -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 } } ); }); diff --git a/packages/ui/src/containers/PasswordSignInForm/index.test.tsx b/packages/ui/src/containers/PasswordSignInForm/index.test.tsx index f0941d7fd..76351ecf5 100644 --- a/packages/ui/src/containers/PasswordSignInForm/index.test.tsx +++ b/packages/ui/src/containers/PasswordSignInForm/index.test.tsx @@ -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 }, diff --git a/packages/ui/src/containers/PhoneForm/PhoneContinue.test.tsx b/packages/ui/src/containers/PhoneForm/PhoneContinue.test.tsx index c70496b27..8c3a4e7ec 100644 --- a/packages/ui/src/containers/PhoneForm/PhoneContinue.test.tsx +++ b/packages/ui/src/containers/PhoneForm/PhoneContinue.test.tsx @@ -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 } } ); }); diff --git a/packages/ui/src/containers/PhoneForm/PhoneRegister.test.tsx b/packages/ui/src/containers/PhoneForm/PhoneRegister.test.tsx index a24c62898..74a30eb52 100644 --- a/packages/ui/src/containers/PhoneForm/PhoneRegister.test.tsx +++ b/packages/ui/src/containers/PhoneForm/PhoneRegister.test.tsx @@ -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 } } ); }); diff --git a/packages/ui/src/containers/PhoneForm/PhoneResetPassword.test.tsx b/packages/ui/src/containers/PhoneForm/PhoneResetPassword.test.tsx index 19712afec..943eb502b 100644 --- a/packages/ui/src/containers/PhoneForm/PhoneResetPassword.test.tsx +++ b/packages/ui/src/containers/PhoneForm/PhoneResetPassword.test.tsx @@ -55,6 +55,7 @@ describe('PhoneRegister', () => { expect(mockedNavigate).toBeCalledWith( { pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Phone}/verification-code`, + search: '', }, { state: { phone: fullPhoneNumber } } ); diff --git a/packages/ui/src/containers/PhoneForm/PhoneSignIn.test.tsx b/packages/ui/src/containers/PhoneForm/PhoneSignIn.test.tsx index e2e037e7b..aa9c46385 100644 --- a/packages/ui/src/containers/PhoneForm/PhoneSignIn.test.tsx +++ b/packages/ui/src/containers/PhoneForm/PhoneSignIn.test.tsx @@ -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 } } ); }); diff --git a/packages/ui/src/containers/SocialCreateAccount/index.tsx b/packages/ui/src/containers/SocialCreateAccount/index.tsx deleted file mode 100644 index 045830a2e..000000000 --- a/packages/ui/src/containers/SocialCreateAccount/index.tsx +++ /dev/null @@ -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 ( -
- {relatedIdentifier && ( - <> -
{t('description.social_bind_with_existing')}
-
- ); -}; - -export default SocialCreateAccount; diff --git a/packages/ui/src/containers/SocialCreateAccount/index.module.scss b/packages/ui/src/containers/SocialLinkAccount/index.module.scss similarity index 84% rename from packages/ui/src/containers/SocialCreateAccount/index.module.scss rename to packages/ui/src/containers/SocialLinkAccount/index.module.scss index 182516969..d0ce80006 100644 --- a/packages/ui/src/containers/SocialCreateAccount/index.module.scss +++ b/packages/ui/src/containers/SocialLinkAccount/index.module.scss @@ -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 { diff --git a/packages/ui/src/containers/SocialCreateAccount/index.test.tsx b/packages/ui/src/containers/SocialLinkAccount/index.test.tsx similarity index 53% rename from packages/ui/src/containers/SocialCreateAccount/index.test.tsx rename to packages/ui/src/containers/SocialLinkAccount/index.test.tsx index 36ef43cc0..b944f27e8 100644 --- a/packages/ui/src/containers/SocialCreateAccount/index.test.tsx +++ b/packages/ui/src/containers/SocialLinkAccount/index.test.tsx @@ -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( - - - +describe('SocialLinkAccount', () => { + const relatedUser = Object.freeze({ type: 'email', value: 'foo@logto.io' }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render bindUser Button', async () => { + const { getByText } = renderWithPageContext( + ); - 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(); + const { getByText } = renderWithPageContext( + + ); 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(); - const bindButton = getByText('action.bind'); - await waitFor(() => { - fireEvent.click(bindButton); - }); - expect(bindSocialRelatedUser).toBeCalledWith({ - connectorId: 'github', - email: 'email@logto.io', - }); - }); }); diff --git a/packages/ui/src/containers/SocialLinkAccount/index.tsx b/packages/ui/src/containers/SocialLinkAccount/index.tsx new file mode 100644 index 000000000..f93e88562 --- /dev/null +++ b/packages/ui/src/containers/SocialLinkAccount/index.tsx @@ -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 ( +
+
{t('description.social_bind_with_existing')}
+ +
+ ); +}; + +export default SocialLinkAccount; diff --git a/packages/ui/src/containers/VerificationCode/index.test.tsx b/packages/ui/src/containers/VerificationCode/index.test.tsx index f778c7ad4..e48c15860 100644 --- a/packages/ui/src/containers/VerificationCode/index.test.tsx +++ b/packages/ui/src/containers/VerificationCode/index.test.tsx @@ -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('', () => { })); const { container } = renderWithPageContext( - + + + ); const inputs = container.querySelectorAll('input'); @@ -301,7 +308,13 @@ describe('', () => { })); const { container } = renderWithPageContext( - + + + ); const inputs = container.querySelectorAll('input'); diff --git a/packages/ui/src/containers/VerificationCode/use-continue-flow-code-verification.ts b/packages/ui/src/containers/VerificationCode/use-continue-flow-code-verification.ts index 8adc75fc1..6c4f70299 100644 --- a/packages/ui/src/containers/VerificationCode/use-continue-flow-code-verification.ts +++ b/packages/ui/src/containers/VerificationCode/use-continue-flow-code-verification.ts @@ -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, ] diff --git a/packages/ui/src/containers/VerificationCode/use-identifier-error-alert.ts b/packages/ui/src/containers/VerificationCode/use-identifier-error-alert.ts index 746da80a8..fd857f405 100644 --- a/packages/ui/src/containers/VerificationCode/use-identifier-error-alert.ts +++ b/packages/ui/src/containers/VerificationCode/use-identifier-error-alert.ts @@ -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); }, diff --git a/packages/ui/src/containers/VerificationCode/use-link-social-confirm-modal.ts b/packages/ui/src/containers/VerificationCode/use-link-social-confirm-modal.ts new file mode 100644 index 000000000..39fb7dc04 --- /dev/null +++ b/packages/ui/src/containers/VerificationCode/use-link-social-confirm-modal.ts @@ -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; diff --git a/packages/ui/src/containers/VerificationCode/use-register-flow-code-verification.ts b/packages/ui/src/containers/VerificationCode/use-register-flow-code-verification.ts index 27d3118c7..e06dbc156 100644 --- a/packages/ui/src/containers/VerificationCode/use-register-flow-code-verification.ts +++ b/packages/ui/src/containers/VerificationCode/use-register-flow-code-verification.ts @@ -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, }), }); diff --git a/packages/ui/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts b/packages/ui/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts index 6af69ccde..1d3780384 100644 --- a/packages/ui/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts +++ b/packages/ui/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts @@ -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, }), }); diff --git a/packages/ui/src/hooks/use-bind-social.ts b/packages/ui/src/hooks/use-bind-social.ts deleted file mode 100644 index 5b48530f6..000000000 --- a/packages/ui/src/hooks/use-bind-social.ts +++ /dev/null @@ -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; diff --git a/packages/ui/src/hooks/use-required-profile-error-handler.ts b/packages/ui/src/hooks/use-required-profile-error-handler.ts index 8d472b7ba..4e3079dee 100644 --- a/packages/ui/src/hooks/use-required-profile-error-handler.ts +++ b/packages/ui/src/hooks/use-required-profile-error-handler.ts @@ -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; diff --git a/packages/ui/src/hooks/use-send-verification-code.ts b/packages/ui/src/hooks/use-send-verification-code.ts index 5f07ae28e..e4942d233 100644 --- a/packages/ui/src/hooks/use-send-verification-code.ts +++ b/packages/ui/src/hooks/use-send-verification-code.ts @@ -45,6 +45,7 @@ const useSendVerificationCode = { + const { result: linkResult, run: asyncLinkWithSocial } = useApi(linkWithSocial); + + useEffect(() => { + if (linkResult?.redirectTo) { + window.location.replace(linkResult.redirectTo); + } + }, [linkResult]); + + return asyncLinkWithSocial; +}; + +export default useLinkSocial; diff --git a/packages/ui/src/hooks/use-social-link-related-user.ts b/packages/ui/src/hooks/use-social-link-related-user.ts new file mode 100644 index 000000000..6ec2d4eb6 --- /dev/null +++ b/packages/ui/src/hooks/use-social-link-related-user.ts @@ -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; diff --git a/packages/ui/src/hooks/use-social-register.ts b/packages/ui/src/hooks/use-social-register.ts new file mode 100644 index 000000000..6f0ad6c9e --- /dev/null +++ b/packages/ui/src/hooks/use-social-register.ts @@ -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; diff --git a/packages/ui/src/hooks/use-social-sign-in-listener.ts b/packages/ui/src/hooks/use-social-sign-in-listener.ts index 95ca7818f..636edc98c 100644 --- a/packages/ui/src/hooks/use-social-sign-in-listener.ts +++ b/packages/ui/src/hooks/use-social-sign-in-listener.ts @@ -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; diff --git a/packages/ui/src/pages/Continue/SetPassword/use-set-password.ts b/packages/ui/src/pages/Continue/SetPassword/use-set-password.ts index 5de8902c9..183c63ca6 100644 --- a/packages/ui/src/pages/Continue/SetPassword/use-set-password.ts +++ b/packages/ui/src/pages/Continue/SetPassword/use-set-password.ts @@ -11,7 +11,7 @@ const useSetPassword = () => { const navigate = useNavigate(); const { show } = useConfirmModal(); - const requiredProfileErrorHandler = useRequiredProfileErrorHandler(true); + const requiredProfileErrorHandler = useRequiredProfileErrorHandler(); const errorHandlers: ErrorHandlers = useMemo( () => ({ diff --git a/packages/ui/src/pages/SignInPassword/index.tsx b/packages/ui/src/pages/SignInPassword/index.tsx index 0fa895136..1f077640b 100644 --- a/packages/ui/src/pages/SignInPassword/index.tsx +++ b/packages/ui/src/pages/SignInPassword/index.tsx @@ -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, }} > ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(() => ({ + state: { relatedUser: { type: 'email', value: 'foo@logto.io' } }, + })), +})); + describe('SocialRegister', () => { it('render', () => { const { queryByText } = render( - + - } /> + } /> ); diff --git a/packages/ui/src/pages/SocialLinkAccount/index.tsx b/packages/ui/src/pages/SocialLinkAccount/index.tsx new file mode 100644 index 000000000..d3bb269b6 --- /dev/null +++ b/packages/ui/src/pages/SocialLinkAccount/index.tsx @@ -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(); + const { state } = useLocation(); + + if (!is(state, socialAccountNotExistErrorDataGuard)) { + return ; + } + + if (!connector) { + return ; + } + + const { relatedUser } = state; + + return ( + + + + ); +}; + +export default SocialLinkAccount; diff --git a/packages/ui/src/pages/SocialRegister/index.tsx b/packages/ui/src/pages/SocialRegister/index.tsx deleted file mode 100644 index 213530e8d..000000000 --- a/packages/ui/src/pages/SocialRegister/index.tsx +++ /dev/null @@ -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(); - - if (!connector) { - return null; - } - - return ( - - - - ); -}; - -export default SocialRegister; diff --git a/packages/ui/src/pages/SocialSignInCallback/index.tsx b/packages/ui/src/pages/SocialSignInCallback/index.tsx index 91c009144..985f50c6f 100644 --- a/packages/ui/src/pages/SocialSignInCallback/index.tsx +++ b/packages/ui/src/pages/SocialSignInCallback/index.tsx @@ -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 ; }; diff --git a/packages/ui/src/pages/VerificationCode/index.tsx b/packages/ui/src/pages/VerificationCode/index.tsx index 34448a45a..d0124016a 100644 --- a/packages/ui/src/pages/VerificationCode/index.tsx +++ b/packages/ui/src/pages/VerificationCode/index.tsx @@ -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, }} > ['relatedUser']; diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index 8b18c9187..e94ddee01 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -15,6 +15,7 @@ export enum UserFlow { export enum SearchParameters { nativeCallbackLink = 'native_callback', redirectTo = 'redirect_to', + linkSocial = 'link_social', } export type Platform = 'web' | 'mobile'; diff --git a/packages/ui/src/utils/format.test.ts b/packages/ui/src/utils/format.test.ts new file mode 100644 index 000000000..52fe0b9e0 --- /dev/null +++ b/packages/ui/src/utils/format.test.ts @@ -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'); + }); +}); diff --git a/packages/ui/src/utils/format.ts b/packages/ui/src/utils/format.ts new file mode 100644 index 000000000..32f170ac0 --- /dev/null +++ b/packages/ui/src/utils/format.ts @@ -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)}`;