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

refactor(ui): add primary register page (#2282)

This commit is contained in:
simeng-li 2022-10-31 18:18:06 +08:00 committed by GitHub
parent 30f2f44706
commit c6aa5dd4e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 259 additions and 44 deletions

View file

@ -8,6 +8,7 @@ const translation = {
},
secondary: {
sign_in_with: 'Sign in with {{methods, list(type: disjunction;)}}',
register_with: 'Create account with {{methods, list(type: disjunction;)}}',
social_bind_with:
'Already have an account? Sign in to link {{methods, list(type: disjunction;)}} with your social identity.',
},
@ -25,7 +26,7 @@ const translation = {
nav_back: 'Back',
agree: 'Agree',
got_it: 'Got it',
sign_in_with: 'Sign in with {{name}}',
sign_in_with: 'Continue with {{name}}',
forgot_password: 'Forgot your password?',
switch_to: 'Switch to {{method}}',
},
@ -60,6 +61,8 @@ const translation = {
'Enter the phone number associated with your account, and well message you the verification code to reset your password.',
new_password: 'New password',
password_changed: 'Password Changed',
no_account: "Don't have an account?",
have_account: 'Already have an account?',
},
error: {
username_password_mismatch: 'Username and password do not match',

View file

@ -10,6 +10,7 @@ const translation = {
},
secondary: {
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'Vous avez déjà un compte ? Connectez-vous pour lier {{methods, list(type: disjunction;)}} avec votre identité sociale.',
},
@ -27,7 +28,7 @@ const translation = {
nav_back: 'Retour',
agree: 'Accepter',
got_it: 'Compris',
sign_in_with: 'Connexion avec {{name}}',
sign_in_with: 'Continuer avec {{name}}',
forgot_password: 'Mot de passe oublié ?',
switch_to: 'Passer au {{method}}',
},
@ -64,6 +65,8 @@ const translation = {
'Entrez le numéro de téléphone associé à votre compte et nous vous enverrons le code de vérification par SMS pour réinitialiser votre mot de passe.',
new_password: 'Nouveau mot de passe',
password_changed: 'Password Changed', // UNTRANSLATED
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
},
error: {
username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas",

View file

@ -10,6 +10,7 @@ const translation = {
},
secondary: {
sign_in_with: '{{methods, list(type: disjunction;)}} 로그인',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'이미 계정이 있으신가요? {{methods, list(type: disjunction;)}}로 로그인 해보세요!',
},
@ -27,7 +28,7 @@ const translation = {
nav_back: '뒤로',
agree: '동의',
got_it: '알겠습니다',
sign_in_with: '{{name}} 로그인',
sign_in_with: '{{name}} 계속',
forgot_password: '비밀번호를 잊어버리셨나요?',
switch_to: 'Switch to {{method}}', // UNTRANSLATED
},
@ -60,6 +61,8 @@ const translation = {
'계정과 연결된 전화번호를 입력하면 비밀번호 재설정을 위한 인증 코드를 문자로 보내드립니다.',
new_password: '새 비밀번호',
password_changed: 'Password Changed', // UNTRANSLATED
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
},
error: {
username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.',

View file

@ -10,6 +10,7 @@ const translation = {
},
secondary: {
sign_in_with: 'Entrar com {{methods, list(type: disjunction;)}}',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'Já tem uma conta? Faça login para agregar {{methods, list(type: disjunction;)}} com a sua identidade social.',
},
@ -27,7 +28,7 @@ const translation = {
nav_back: 'Anterior',
agree: 'Aceito',
got_it: 'Entendi',
sign_in_with: 'Entrar com {{name}}',
sign_in_with: 'Continuar com {{name}}',
forgot_password: 'Esqueceu a password?',
switch_to: 'Mudar para {{method}}',
},
@ -60,6 +61,8 @@ const translation = {
'Digite o número de telefone associado à sua conta e enviaremos uma mensagem de texto com o código de verificação para redefinir sua senha.',
new_password: 'Nova Senha',
password_changed: 'Password Changed', // UNTRANSLATED
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
},
error: {
username_password_mismatch: 'O Utilizador e a password não correspondem',

View file

@ -10,6 +10,7 @@ const translation = {
},
secondary: {
sign_in_with: '{{methods, list(type: disjunction;)}} ile giriş yapınız',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'Hesabınız zaten var mı? {{methods, list(type: disjunction;)}} bağlantısına tıklayarak giriş yapabilirsiniz',
},
@ -27,7 +28,7 @@ const translation = {
nav_back: 'Geri',
agree: 'Kabul Et',
got_it: 'Anladım',
sign_in_with: '{{name}} ile giriş yap',
sign_in_with: '{{name}} ile ilerle',
forgot_password: 'Şifremi Unuttum?',
switch_to: 'Switch to {{method}}', // UNTRANSLATED
},
@ -61,6 +62,8 @@ const translation = {
'Hesabınızla ilişkili telefon numarasını girin, şifrenizi sıfırlamak için size doğrulama kodunu kısa mesajla gönderelim.',
new_password: 'Yeni Şifre',
password_changed: 'Password Changed', // UNTRANSLATED
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
},
error: {
username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.',

View file

@ -10,6 +10,7 @@ const translation = {
},
secondary: {
sign_in_with: '通过 {{methods, list(type: disjunction;), zhOrSpaces}} 登录',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'绑定到已有账户? 使用 {{methods, list(type: disjunction;), zhOrSpaces}} 登录并绑定。',
},
@ -58,6 +59,8 @@ const translation = {
reset_password_description_sms: '输入手机号,领取验证码以重设密码。',
new_password: '新密码',
password_changed: '已重置密码!',
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
},
error: {
username_password_mismatch: '用户名和密码不匹配',

View file

@ -1,4 +1,3 @@
import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import type { ReactNode } from 'react';
@ -12,11 +11,11 @@ import TextLink from '@/components/TextLink';
import * as styles from './index.module.scss';
type Props = {
signInMethods: SignIn['methods'];
methods: SignInIdentifier[];
// Allows social page to pass additional query params to the sign-in pages
search?: string;
className?: string;
template?: TFuncKey<'translation', 'secondary'>;
template: TFuncKey<'translation', 'secondary'>;
};
const SignInMethodsKeyMap: {
@ -27,13 +26,12 @@ const SignInMethodsKeyMap: {
[SignInIdentifier.Sms]: 'phone_number',
};
const SignInMethodsLink = ({ signInMethods, template, search, className }: Props) => {
const SignInMethodsLink = ({ methods, template, search, className }: Props) => {
const { t } = useTranslation();
const identifiers = signInMethods.map(({ identifier }) => identifier);
const signInMethodsLink = useMemo(
const methodsLink = useMemo(
() =>
identifiers.map((identifier) => (
methods.map((identifier) => (
<TextLink
key={identifier}
className={styles.signInMethodLink}
@ -41,24 +39,21 @@ const SignInMethodsLink = ({ signInMethods, template, search, className }: Props
to={{ pathname: `/sign-in/${identifier}`, search }}
/>
)),
[identifiers, search]
[methods, search]
);
if (signInMethodsLink.length === 0) {
if (methodsLink.length === 0) {
return null;
}
// Without text template
if (!template) {
return <div className={classNames(styles.methodsLinkList, className)}>{signInMethodsLink}</div>;
}
// Raw i18n text
const rawText = t(`secondary.${template}`, { methods });
// With text template
const rawText = t(`secondary.${template}`, { methods: identifiers });
const textWithLink: ReactNode = identifiers.reduce<ReactNode>(
// Replace with link element
const textWithLink: ReactNode = methods.reduce<ReactNode>(
(content, identifier, index) =>
// @ts-expect-error: reactStringReplace type bug, using deprecated ReactNodeArray as its input type
reactStringReplace(content, identifier, () => signInMethodsLink[index]),
reactStringReplace(content, identifier, () => methodsLink[index]),
rawText
);

View file

@ -43,7 +43,7 @@ const SocialCreateAccount = ({ connectorId, className }: Props) => {
}}
/>
<SignInMethodsLink
signInMethods={signInMethods}
methods={signInMethods.map(({ identifier }) => identifier)}
template="social_bind_with"
className={styles.desc}
search={queryStringify({ [SearchParameters.bindWithSocial]: connectorId })}

View file

@ -0,0 +1,9 @@
type Props = {
className?: string;
};
const UsernameRegister = ({ className }: Props) => (
<div className={className}>username register</div>
);
export default UsernameRegister;

View file

@ -0,0 +1,35 @@
import type { SignInIdentifier, ConnectorMetadata } from '@logto/schemas';
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameRegister from '@/containers/UsernameRegister';
import * as styles from './index.module.scss';
type Props = {
signUpMethod?: SignInIdentifier;
socialConnectors: ConnectorMetadata[];
};
const Main = ({ signUpMethod, socialConnectors }: Props) => {
switch (signUpMethod) {
case 'email':
return <EmailPasswordless type="register" className={styles.main} />;
case 'sms':
return <PhonePasswordless type="register" className={styles.main} />;
case 'username':
return <UsernameRegister className={styles.main} />;
default: {
if (socialConnectors.length > 0) {
return <SocialSignIn />;
}
return null;
}
}
};
export default Main;

View file

@ -0,0 +1,43 @@
@use '@/scss/underscore' as _;
.main {
margin-bottom: _.unit(4);
}
.otherMethodsLink {
margin-bottom: _.unit(6);
}
.createAccount {
margin-top: _.unit(6);
text-align: center;
}
.placeHolder {
flex: 1;
}
:global(body.mobile) {
.divider {
margin-bottom: _.unit(5);
}
.createAccount {
padding-bottom: env(safe-area-inset-bottom);
}
}
:global(body.desktop) {
.main {
margin-bottom: _.unit(6);
}
.placeHolder {
flex: 0;
}
.divider {
margin-bottom: _.unit(4);
}
}

View file

@ -0,0 +1,82 @@
import { SignInIdentifier } from '@logto/schemas';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { defaultSize } from '@/containers/SocialSignIn/SocialSignInList';
import Register from '@/pages/Register';
jest.mock('i18next', () => ({
language: 'en',
}));
describe('<Register />', () => {
test('renders with username as primary', async () => {
const { queryByText, queryAllByText } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('username register')).not.toBeNull();
// Social
expect(queryAllByText('action.sign_in_with')).toHaveLength(defaultSize);
});
test('renders with email passwordless as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, methods: [SignInIdentifier.Email] },
}}
>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('renders with sms passwordless as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, methods: [SignInIdentifier.Sms] },
}}
>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('renders with social as primary', async () => {
const { container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, methods: [] },
}}
>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelectorAll('button')).toHaveLength(
mockSignInExperienceSettings.socialConnectors.length
);
});
});

View file

@ -1,16 +1,52 @@
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import Divider from '@/components/Divider';
import TextLink from '@/components/TextLink';
import LandingPageContainer from '@/containers/LandingPageContainer';
import { PageContext } from '@/hooks/use-page-context';
import SignInMethodsLink from '@/containers/SignInMethodsLink';
import { SocialSignInList } from '@/containers/SocialSignIn';
import { useSieMethods } from '@/hooks/use-sie';
import Main from './Main';
import * as styles from './index.module.scss';
const Register = () => {
const { experienceSettings } = useContext(PageContext);
const { signUpMethods, socialConnectors } = useSieMethods();
const otherMethods = signUpMethods.slice(1);
const { t } = useTranslation();
if (!experienceSettings) {
return null;
}
return <LandingPageContainer>signUp</LandingPageContainer>;
return (
<LandingPageContainer>
<Main signUpMethod={signUpMethods[0]} socialConnectors={socialConnectors} />
{
// Other create account methods
otherMethods.length > 0 && (
<SignInMethodsLink methods={otherMethods} template="register_with" />
)
}
{
// Social sign-in methods
signUpMethods.length > 0 && socialConnectors.length > 0 && (
<>
<Divider label="description.or" className={styles.divider} />
<SocialSignInList isCollapseEnabled socialConnectors={socialConnectors} />
</>
)
}
{
// SignIn footer
signUpMethods.length > 0 && (
<>
<div className={styles.placeHolder} />
<div className={styles.createAccount}>
{t('description.have_account')}{' '}
<TextLink replace to="/sign-in" text="action.sign_in" />
</div>
</>
)
}
</LandingPageContainer>
);
};
export default Register;

View file

@ -15,11 +15,7 @@ type Props = {
};
const Main = ({ signInMethod, socialConnectors }: Props) => {
if (!signInMethod) {
return socialConnectors.length > 0 ? <SocialSignIn /> : null;
}
switch (signInMethod.identifier) {
switch (signInMethod?.identifier) {
case 'email': {
if (signInMethod.password && !signInMethod.verificationCode) {
return <EmailPassword className={styles.main} />;

View file

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
import Divider from '@/components/Divider';
import TextLink from '@/components/TextLink';
import LandingPageContainer from '@/containers/LandingPageContainer';
@ -10,7 +12,8 @@ import * as styles from './index.module.scss';
const SignIn = () => {
const { signInMethods, signUpMethods, socialConnectors } = useSieMethods();
const otherMethods = signInMethods.slice(1);
const otherMethods = signInMethods.slice(1).map(({ identifier }) => identifier);
const { t } = useTranslation();
return (
<LandingPageContainer>
@ -18,7 +21,7 @@ const SignIn = () => {
{
// Other sign-in methods
otherMethods.length > 0 && (
<SignInMethodsLink signInMethods={otherMethods} template="sign_in_with" />
<SignInMethodsLink methods={otherMethods} template="sign_in_with" />
)
}
{
@ -35,12 +38,10 @@ const SignIn = () => {
signUpMethods.length > 0 && (
<>
<div className={styles.placeHolder} />
<TextLink
replace
className={styles.createAccount}
to="/register"
text="action.create_account"
/>
<div className={styles.createAccount}>
{t('description.no_account')}{' '}
<TextLink replace to="/register" text="action.create_account" />
</div>
</>
)
}